Skip to content
This repository has been archived by the owner on Jan 3, 2025. It is now read-only.

Commit

Permalink
feat: initial Artist chatbot
Browse files Browse the repository at this point in the history
  • Loading branch information
rainx committed Oct 8, 2023
0 parents commit 13bf668
Show file tree
Hide file tree
Showing 17 changed files with 748 additions and 0 deletions.
41 changes: 41 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
// usually `true` for project root config
// see https://eslint.org/docs/latest/use/configure/configuration-files#cascading-and-hierarchy
root: true,

// use overrides to group different types of files
// see https://eslint.org/docs/latest/use/configure/configuration-files#configuration-based-on-glob-patterns
overrides: [
{
files: ['src/**/*.ts'],
extends: ['@rightcapital/eslint-config-typescript'],
env: { node: true },
},
{
// test files
files: ['tests/**/*.test.{ts,tsx}'],
extends: ['@rightcapital/eslint-config-typescript'],
env: { jest: true, node: true },
},
{
// JavaScript config and scripts
files: ['./**/*.{js,cjs,mjs,jsx}'],
excludedFiles: ['src/**'],
extends: ['@rightcapital/eslint-config-javascript'],
env: { node: true },
rules: {
'import/no-extraneous-dependencies': 'off',
},
},
{
// TypeScript config and scripts
files: ['./**/*.{ts,cts,mts,tsx}'],
excludedFiles: ['src/**'],
extends: ['@rightcapital/eslint-config-typescript'],
rules: {
'import/no-extraneous-dependencies': 'off',
},
},
],
};
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.git
.DS_Store
.env
node_modules
dist
pnpm-lock.yaml
4 changes: 4 additions & 0 deletions .husky/commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

pnpm commitlint --edit --config=commitlint.config.js
1 change: 1 addition & 0 deletions .node-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
18.18.0
20 changes: 20 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"diffEditor.ignoreTrimWhitespace": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.fontLigatures": true,
"editor.formatOnSave": true,
"editor.formatOnType": false,
"editor.tabSize": 2,
"editor.wordWrap": "on",
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"javascript.updateImportsOnFileMove.enabled": "always",
"cSpell.words": ["chatgpt"]
}
6 changes: 6 additions & 0 deletions babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-typescript',
],
};
1 change: 1 addition & 0 deletions commitlint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = { extends: ['@commitlint/config-conventional'] };
80 changes: 80 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
{
"name": "@rightcapital/artist",
"version": "0.0.1",
"keywords": [
"Artist",
"Image Generation",
"Slack bot",
"Slack",
"Dall-E",
"AI",
"AIGC"
],
"description": "The Artist is a Slack chatbot with ability to draw fantastic AI image",
"main": "dist/app.js",
"repository": "https://github.com/RightCapitalHQ/artist",
"author": "RightCapital Ecosystem team <[email protected]>",
"license": "MIT",
"packageManager": "[email protected]",
"engines": {
"node": ">=18.x",
"pnpm": ">=8.x"
},
"devDependencies": {
"@babel/core": "7.22.19",
"@babel/preset-env": "7.22.15",
"@babel/preset-typescript": "7.22.15",
"@commitlint/cli": "17.7.1",
"@commitlint/config-conventional": "17.7.0",
"@commitlint/cz-commitlint": "17.7.1",
"@rightcapital/eslint-config-javascript": "7.0.1",
"@rightcapital/eslint-config-typescript": "7.0.1",
"@rightcapital/prettier-config": "6.0.1",
"@tsconfig/node18": "^18.2.2",
"@types/async-retry": "^1.4.5",
"@types/jest": "29.5.5",
"@types/lodash": "4.14.198",
"@types/node": "18.17.15",
"@types/node-fetch": "^2.6.6",
"babel-jest": "29.7.0",
"beachball": "2.37.0",
"commitizen": "4.3.0",
"eslint": "8.49.0",
"husky": "8.0.3",
"inquirer": "9.2.11",
"jest": "29.7.0",
"nodemon": "^3.0.1",
"prettier": "3.0.3",
"ts-node": "10.9.1",
"typescript": "5.2.2"
},
"dependencies": {
"@rightcapital/exceptions": "^1.2.3",
"@slack/bolt": "^3.3.0",
"@slack/web-api": "^6.8.1",
"async-retry": "^1.3.3",
"dotenv": "^8.2.0",
"lodash": "4.17.21",
"openai": "^4.11.1"
},
"scripts": {
"commit": "cz",
"build": "pnpm run clean && tsc --project ./tsconfig.build.json",
"build:watch": "tsc -w -p ./tsconfig.build.json",
"clean": "tsc --build --clean ./tsconfig.build.json",
"change": "beachball change --no-commit",
"check": "beachball check",
"preinstall": "npx only-allow pnpm",
"prepare": "husky install",
"eslint": "eslint --report-unused-disable-directives 'src/**/*.ts*'",
"eslint:fix": "eslint --report-unused-disable-directives --fix 'src/**/*.ts*'",
"test": "jest",
"bolt:start": "nodemon ./dist/app.js",
"start": "pnpm run \"/(build:watch|bolt:start)/\""
},
"config": {
"commitizen": {
"path": "@commitlint/cz-commitlint"
}
}
}
1 change: 1 addition & 0 deletions prettier.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('@rightcapital/prettier-config');
131 changes: 131 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { App } from '@slack/bolt';
import { ChatPostMessageResponse, WebClient } from '@slack/web-api';
import { PromptParserHelpers } from './helpers/prompt-parser.helpers';
import { SlackHelpers } from './helpers/slack.helpers';
import { OpenAIService } from './service/openai.service';

if (
!process.env.SLACK_BOT_TOKEN ||
!process.env.SLACK_SIGNING_SECRET ||
!process.env.SLACK_APP_TOKEN ||
!process.env.OPENAI_API_KEY
) {
// eslint-disable-next-line no-console
console.log(
'Please set required environment variables before starting this server',
);
}

const app = new App({
token: process.env.SLACK_BOT_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET,
socketMode: true,
appToken: process.env.SLACK_APP_TOKEN,
});

app.use(async ({ next }) => {
await next();
});

// Use MidJourney style command `/imagine` https://docs.midjourney.com/docs/quick-start#3-use-the-imagine-command
app.command('/imagine', async ({ command, ack, say, client }) => {
await ack({
response_type: 'in_channel',
});

const prompt = command.text;

const message = await say({
text: 'The elegant image generating :wink:',
channel: command.channel_id,
});

await generateReplyMessageByPrompt(
client,
prompt,
command.channel_id,
message,
undefined,
);
});

// subscribe to 'app_mention' event in your App config
// need app_mentions:read and chat:write scopes
app.event('app_mention', async ({ event, context, client, say }) => {
const conversationMessages =
await SlackHelpers.getConversationMessagesByEventMessage(client, {
text: event.text,
user: event.user,
thread_ts: event?.thread_ts,
channel: event.channel,
ts: event.ts,
});

const prompt = await OpenAIService.instance.getPromptByMessages(
conversationMessages,
context.botUserId,
);

const reply = await say({
text: 'The elegant image generating :wink:',
thread_ts: event.ts,
});

await generateReplyMessageByPrompt(
client,
prompt,
event.channel,
reply,
event.ts,
);
});

async function generateReplyMessageByPrompt(
client: WebClient,
prompt: string,
channelId: string,
message: ChatPostMessageResponse,
threadTs: string | undefined,
) {
const parsedPromptParts = PromptParserHelpers.parse(prompt);

try {
const generatedImageUrls =
await OpenAIService.instance.createNewImageByPrompt(parsedPromptParts);
for await (const generatedImageUrl of generatedImageUrls) {
if (generatedImageUrl)
await SlackHelpers.uploadImageToSlackFileServer(
client,
channelId,
threadTs,
generatedImageUrl,
prompt,
);
}

if (message.channel && message.ts) {
await client.chat.update({
channel: message.channel,
ts: message.ts,
text: `Your masterpiece! 👇🏻`,
});
}
} catch (exception) {
if (message.channel && message.ts) {
await client.chat.update({
channel: message.channel,
ts: message.ts,
text: 'Oh no! failed to generate image by DALL·E 2',
});
}
}
}

// eslint-disable-next-line @typescript-eslint/no-floating-promises
(async () => {
// Start your app
await app.start(Number(process.env.PORT) || 3000);

// eslint-disable-next-line no-console
console.log('⚡️ Artist app is running!');
})();
5 changes: 5 additions & 0 deletions src/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type _fetch from 'node-fetch';

declare global {
declare const fetch: typeof _fetch;
}
70 changes: 70 additions & 0 deletions src/helpers/image-source-url.helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { File } from 'buffer';
import { BinaryLike } from 'node:crypto';
import { InvalidArgumentException } from '@rightcapital/exceptions';

export class ImageSourceUrlHelpers {
public static isPasteboardUrl(possibleUrl: string): boolean {
let url: URL;

try {
url = new URL(possibleUrl);
} catch (_) {
return false;
}
return url.hostname === 'pasteboard.co';
}

public static async fetchImageFileByUrl(url: string): Promise<File> {
const { imageUrl, refererUrl, imageFileName } =
ImageSourceUrlHelpers.getImageUrlAndRefererBySourceUrl(url);

const headers = {
Referer: refererUrl,
};
const options = {
headers,
};
const response = fetch(imageUrl, options);
const responseArrayBuffer = await (await response).arrayBuffer();
const imageFile = new File(
[responseArrayBuffer as BinaryLike],
imageFileName,
);

return imageFile;
}

public static getImageUrlAndRefererBySourceUrl(sourceUrl: string): {
imageUrl: string;
refererUrl: string;
imageFileName: string;
} {
if (ImageSourceUrlHelpers.isPasteboardUrl(sourceUrl)) {
const pasteboardShareUrl =
/^https:\/\/pasteboard\.co\/(\w*.(png|jpg|jpeg|gif))$/;

const matches = sourceUrl.match(pasteboardShareUrl);
if (matches) {
return {
imageUrl: `https://gcdnb.pbrd.co/images/${matches[1]}?o=1`,
refererUrl: sourceUrl,
imageFileName: matches[1],
};
}

return {
imageUrl: sourceUrl,
refererUrl: sourceUrl,
imageFileName: new URL(sourceUrl).pathname,
};
}

throw new InvalidArgumentException(
`Unsupported source image provider URL: ${sourceUrl}`,
);
}

private static arrayBufferToBuffer(arrayBufferData: ArrayBuffer): Buffer {
return Buffer.from(arrayBufferData);
}
}
Loading

0 comments on commit 13bf668

Please sign in to comment.