├── .envrc_example ├── .eslintrc.yml ├── .gitignore ├── .nvmrc ├── README.md ├── app ├── acknowledger │ └── index.js ├── bot │ └── index.js └── common │ └── dynamo.js ├── assets ├── demo.gif └── logo.png ├── license.md ├── package-lock.json ├── package.json ├── serverless.yml └── webpack.config.js /.envrc_example: -------------------------------------------------------------------------------- 1 | export SLACK_BOT_TOKEN= 2 | export SLACK_SIGNING_SECRET= 3 | export OPENAI_API_KEY= 4 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: false 3 | es2021: true 4 | "jest/globals": true 5 | extends: 6 | - airbnb 7 | - prettier 8 | parserOptions: 9 | ecmaVersion: 12 10 | sourceType: module 11 | plugins: 12 | - prettier 13 | - jest 14 | settings: 15 | import/resolver: 16 | node: 17 | moduleDirectory: ["node_modules", "src/"] 18 | rules: { 19 | "prettier/prettier": "error", 20 | "no-use-before-define": [0], 21 | "no-unused-vars": ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }] 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .serverless 4 | .envrc 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GPTBot 2 | ![GPTBot Logo](assets/logo.png) 3 | 4 | *A fully serverless (AWS Lambda, API Gateway, DynamoDB) Slack bot with GPT-4 support and full conversation mode.* 5 | 6 | ## Demo 7 | ![GPTBot Logo](assets/demo.gif) 8 | 9 | ## Features & Usage 10 | * Support a one-off response from GPTBot when mentioning the bot (`@GPTBot`) in a channel you are both a member of. 11 | (Make sure the Bot is added to whichever channel in which you want to talk to the Bot). 12 | * Support full conversation with context carried over. To start a full conversation, run the command: `/gptbot start`. 13 | To end the conversation, run the command: `/gptbot stop`. 14 | * Support latest GPT-4 model. If you want to start a conversation with the new GPT-4 model, run the command 15 | `/gptbot start gpt-4`. In this mode, responses take a longer time. By default, the model used is `gpt-3.5-turbo`. 16 | * Support collaboration mode. eg: add the Bot to a channel, start a conversation with it (`/gptbot start`): every 17 | channel members can reply to the Bot to help refine the answers it provides. 18 | 19 | ## Installation and deployment to your AWS environment 20 | 1. `npm install` 21 | 2. `npm install -g serverless` 22 | 3. Ensure the environment variables set in `.envrc_example` are set properly. If you use [direnv](https://direnv.net/), 23 | you can execute `cp .envrc_example .envrc` to get you started. (`SLACK_BOT_TOKEN` is obtained after creating a first 24 | version of the Bot in the [Slack Admin Console](https://api.slack.com/apps/) and installing it. 25 | The token is then listed under `OAuth & Permissions` -> `Bot User OAuth Token`). 26 | 4. Deploy the stack: `npm run deploy`. For this command to be successful, you need to have your AWS environment variables 27 | properly set (Access Key Id and Security Token). Using temporary credentials via a tool like 28 | [aws-vault](https://github.com/99designs/aws-vault) is recommended. 29 | 30 | ## Setting up Slack 31 | 1. If you haven't already, create a new bot using this link: https://api.slack.com/apps/new 32 | 2. Our bot replies to a slash command. On the left Menu, click `Slash Commands` -> `Create new Command` and set: 33 | * Command: `/gptbot` 34 | * Request URL: [Url of API Gateway provided by `npm run deploy`] 35 | * Short Description: `Start or stop a full conversation with GPTBot` 36 | * Usage Hint: `start|stop [engine]` 37 | 3. Our bot subscribes to Events. On the left Menu, click `Event Subscriptions`. Under Request URL, add again the link 38 | to API Gateway created by `npm run deploy`. Then make sure to select these events under `Subscribe to Bot Events`: 39 | * `app_mention` 40 | * `message.channels` 41 | * `message.groups` 42 | * `message.im` 43 | 5. Our bot requires permissions. On the left Menu, click `OAuth & Permissions` -> `Scopes`. 44 | Make sure all these scopes are added: 45 | * `app_mentions:read` 46 | * `channels:history` 47 | * `chat:write` 48 | * `commands` 49 | * `groups:history` 50 | * `im:history` 51 | 6. Our bot can talk to people via DM. On the left Menu, click `App Home` -> `Show Tabs`. 52 | Make sure `Messages Tab` is ticked and also `Allow users to send Slash commands and messages from the messages tab`. 53 | 54 | ## What is being installed in my AWS account? 55 | Here are the main resources this Bot deploys: 56 | * 1 API Gateway instance 57 | * 2 AWS Lambda functions 58 | * 1 DynamoDB table 59 | * 1 S3 deployment bucket (used by serverless) 60 | 61 | To be able to subscribe to Slack events, we need an API endpoint, so we create an API Gateway instance. 62 | 63 | Events that are sent via Slack need acknowledgement within 3 seconds, otherwise they are retried. To go around this 64 | we are provisioning the lambda function. But calling OpenAI is taking often longer than 3 seconds. So we create two 65 | functions: 66 | * 1 acknowledger: this function acknowledges the event as fast as it possibly can. It then invokes asynchronously the 67 | bot function. 68 | * 1 bot function: this function calls OpenAI and sends the OpenAI response back to Slack. 69 | 70 | The Bot supports full conversation mode, ie it keeps the context of what was previously exchanged to provide a new 71 | answer. To do that `/gptbot start` creates a record in DynamoDB for the conversation. When `/gptbot stop` is called, 72 | the conversation is deleted. 73 | 74 | ## Deprovision 75 | Should you want to remove all the resources created on AWS and deprovision the bot, you could just type: 76 | `npm run deprovision` 77 | -------------------------------------------------------------------------------- /app/acknowledger/index.js: -------------------------------------------------------------------------------- 1 | const { App, AwsLambdaReceiver } = require("@slack/bolt"); 2 | const AWS = require("aws-sdk"); 3 | const { createConversation, deleteConversation } = require("../common/dynamo"); 4 | 5 | const lambda = new AWS.Lambda(); 6 | const { BOT_FUNCTION_NAME, SLACK_SIGNING_SECRET, SLACK_BOT_TOKEN } = 7 | process.env; 8 | 9 | const awsLambdaReceiver = new AwsLambdaReceiver({ 10 | signingSecret: SLACK_SIGNING_SECRET, 11 | }); 12 | 13 | const app = new App({ 14 | token: SLACK_BOT_TOKEN, 15 | receiver: awsLambdaReceiver, 16 | }); 17 | 18 | const validModels = ["gpt-3.5-turbo", "gpt-4"]; 19 | 20 | app.command("/gptbot", async ({ command, ack, respond, say }) => { 21 | await ack(); 22 | const channel = command.channel_id; 23 | 24 | const args = command.text.trim().split(/\s+/); 25 | const action = args[0]; 26 | const model = args[1]; 27 | 28 | if (action === "start") { 29 | const selectedModel = validModels.includes(model) ? model : "gpt-3.5-turbo"; 30 | await createConversation({ 31 | channel_id: channel, 32 | model: selectedModel, 33 | history: [], 34 | created_at: new Date().toISOString(), 35 | updated_at: new Date().toISOString(), 36 | }); 37 | await respond( 38 | `You have started a conversation with GPTBot in this channel using the engine '${selectedModel}'.` 39 | ); 40 | await say("Hey there :wave: How can I help?"); 41 | } else if (action === "stop") { 42 | await deleteConversation(channel); 43 | await say("Until next time! :call_me_hand: Take care!"); 44 | await respond( 45 | "You have stopped the conversation with GPTBot in this channel." 46 | ); 47 | } else { 48 | await respond( 49 | `Invalid subcommand. Use "/GPTBot start [engine]" or "/GPTBot stop".` 50 | ); 51 | } 52 | }); 53 | 54 | app.message(async ({ message, context }) => { 55 | const { channel, subtype } = message; 56 | const userMessage = message.text; 57 | const { botUserId } = context; 58 | 59 | const params = { 60 | FunctionName: BOT_FUNCTION_NAME, 61 | InvocationType: "Event", // Asynchronous invocation 62 | Payload: JSON.stringify({ 63 | channel, 64 | subtype, 65 | userMessage, 66 | botUserId, 67 | }), 68 | }; 69 | 70 | try { 71 | await lambda.invoke(params).promise(); 72 | console.log("Lambda function invoked successfully"); 73 | } catch (error) { 74 | console.error("Invocation error:", error); 75 | } 76 | }); 77 | 78 | module.exports.handler = async (event, context, callback) => { 79 | const handler = await awsLambdaReceiver.start(); 80 | return handler(event, context, callback); 81 | }; 82 | -------------------------------------------------------------------------------- /app/bot/index.js: -------------------------------------------------------------------------------- 1 | const { App } = require("@slack/bolt"); 2 | const { Configuration, OpenAIApi } = require("openai"); 3 | const { 4 | getConversation, 5 | updateConversationHistory, 6 | } = require("../common/dynamo"); 7 | 8 | const { SLACK_SIGNING_SECRET, SLACK_BOT_TOKEN, OPENAI_API_KEY } = process.env; 9 | const DEFAULT_MODEL = "gpt-3.5-turbo"; 10 | 11 | const app = new App({ 12 | token: SLACK_BOT_TOKEN, 13 | signingSecret: SLACK_SIGNING_SECRET, 14 | }); 15 | 16 | const configuration = new Configuration({ 17 | apiKey: OPENAI_API_KEY, 18 | }); 19 | const openai = new OpenAIApi(configuration); 20 | const BOT_SYSTEM_PROMPT = "You are a helpful assistant."; 21 | 22 | async function chatGPTReply({ channel, message, conversation }) { 23 | const history = conversation ? conversation.history : []; 24 | const model = conversation ? conversation.model : DEFAULT_MODEL; 25 | const prompt = { role: "user", content: message }; 26 | 27 | const completion = await openai.createChatCompletion({ 28 | model, 29 | messages: [ 30 | { role: "system", content: BOT_SYSTEM_PROMPT }, 31 | ...history, 32 | prompt, 33 | ], 34 | }); 35 | 36 | const reply = completion.data.choices[0].message.content.trim(); 37 | 38 | if (conversation) { 39 | const updatedHistory = [ 40 | ...history, 41 | prompt, 42 | { role: "assistant", content: reply }, 43 | ]; 44 | await updateConversationHistory(channel, updatedHistory); 45 | } 46 | 47 | return reply; 48 | } 49 | 50 | async function handleNewMessage({ channel, userMessage, botUserId, subtype }) { 51 | console.log("Handle new message"); 52 | 53 | if (subtype === "message_deleted") { 54 | return; 55 | } 56 | 57 | if (!userMessage || !userMessage.length) { 58 | return; 59 | } 60 | 61 | const conversation = await getConversation(channel); 62 | const isConversationMode = !!conversation; 63 | const isMentioned = userMessage.includes(`<@${botUserId}>`); 64 | 65 | if (isMentioned && !isConversationMode) { 66 | await app.client.chat.postMessage({ 67 | channel, 68 | text: "Hey there :wave: Let me take a look at this for you!", 69 | }); 70 | } 71 | 72 | if (isMentioned || isConversationMode) { 73 | const mentionRegex = new RegExp(`<@${botUserId}>`, "g"); 74 | const messageWithoutMention = userMessage.replace(mentionRegex, "").trim(); 75 | 76 | // Only process the message and respond if there's remaining text 77 | if (messageWithoutMention.length > 0) { 78 | const reply = await chatGPTReply({ 79 | channel, 80 | message: messageWithoutMention, 81 | conversation, 82 | }); 83 | await app.client.chat.postMessage({ 84 | channel, 85 | text: reply, 86 | }); 87 | } 88 | } 89 | } 90 | 91 | module.exports.handler = async (event) => { 92 | await handleNewMessage(event); 93 | 94 | return { 95 | statusCode: 200, 96 | body: JSON.stringify({ 97 | message: "Event processed successfully", 98 | }), 99 | }; 100 | }; 101 | -------------------------------------------------------------------------------- /app/common/dynamo.js: -------------------------------------------------------------------------------- 1 | const AWS = require("aws-sdk"); 2 | 3 | const dynamoDB = new AWS.DynamoDB.DocumentClient(); 4 | const dynamoTable = process.env.DYNAMODB_TABLE; 5 | 6 | async function createConversation(conversation) { 7 | const params = { 8 | TableName: dynamoTable, 9 | Item: conversation, 10 | }; 11 | try { 12 | await dynamoDB.put(params).promise(); 13 | } catch (error) { 14 | console.error("Error creating conversation:", error); 15 | } 16 | } 17 | 18 | async function deleteConversation(channel) { 19 | const params = { 20 | TableName: dynamoTable, 21 | Key: { 22 | channel_id: channel, 23 | }, 24 | }; 25 | try { 26 | await dynamoDB.delete(params).promise(); 27 | } catch (error) { 28 | console.error("Error deleting conversation:", error); 29 | } 30 | } 31 | 32 | async function getConversation(channel) { 33 | const params = { 34 | TableName: dynamoTable, 35 | Key: { 36 | channel_id: channel, 37 | }, 38 | }; 39 | try { 40 | const result = await dynamoDB.get(params).promise(); 41 | return result.Item; 42 | } catch (error) { 43 | console.error("Error retrieving conversation:", error); 44 | return null; 45 | } 46 | } 47 | 48 | async function updateConversationHistory(channel, history) { 49 | const params = { 50 | TableName: dynamoTable, 51 | Key: { 52 | channel_id: channel, 53 | }, 54 | UpdateExpression: "set history = :history, updated_at = :updated_at", 55 | ExpressionAttributeValues: { 56 | ":history": history, 57 | ":updated_at": new Date().toISOString(), 58 | }, 59 | ReturnValues: "UPDATED_NEW", 60 | }; 61 | 62 | try { 63 | await dynamoDB.update(params).promise(); 64 | } catch (error) { 65 | console.error("Error updating conversation history:", error); 66 | } 67 | } 68 | 69 | module.exports = { 70 | createConversation, 71 | deleteConversation, 72 | getConversation, 73 | updateConversationHistory, 74 | }; 75 | -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Electric-Hydrogen/GPTBot/a3850d1d1c2b2571ecba552509864a75694d986a/assets/demo.gif -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Electric-Hydrogen/GPTBot/a3850d1d1c2b2571ecba552509864a75694d986a/assets/logo.png -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023, LIFTE H2, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gptbot", 3 | "version": "1.0.0", 4 | "description": "Chat GPT Slack Bot", 5 | "scripts": { 6 | "build": "npm run clean && NODE_EN=production webpack", 7 | "clean": "rm -rf dist", 8 | "lint": "eslint -c .eslintrc.yml --ext .js app/", 9 | "lint:fix": "eslint --fix -c .eslintrc.yml --ext .js app/", 10 | "deploy": "npm run build && serverless deploy", 11 | "deprovision": "npm run clean && serverless remove" 12 | }, 13 | "keywords": [ 14 | "ChatGPT", 15 | "Slack", 16 | "Bot" 17 | ], 18 | "author": "Olivier Pichon", 19 | "license": "MIT", 20 | "devDependencies": { 21 | "cz-conventional-changelog": "3.3.0", 22 | "eslint": "^8.33.0", 23 | "eslint-config-airbnb": "^19.0.4", 24 | "eslint-config-prettier": "^8.6.0", 25 | "eslint-plugin-import": "^2.27.5", 26 | "eslint-plugin-jest": "^27.2.1", 27 | "eslint-plugin-jsx-a11y": "^6.7.1", 28 | "eslint-plugin-prettier": "^4.2.1", 29 | "eslint-plugin-react": "^7.32.2", 30 | "jest": "^29.4.2", 31 | "prettier": "2.8.4", 32 | "webpack": "^5.75.0", 33 | "webpack-cli": "^5.0.1", 34 | "webpack-node-externals": "^3.0.0" 35 | }, 36 | "dependencies": { 37 | "@slack/bolt": "^3.12.2", 38 | "aws-sdk": "^2.1354.0", 39 | "openai": "^3.2.1" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: gptbot 2 | 3 | provider: 4 | name: aws 5 | runtime: nodejs16.x 6 | stage: ${opt:stage, 'dev'} 7 | region: us-east-1 8 | environment: 9 | BOT_FUNCTION_NAME: ${self:service}-${self:provider.stage}-botFunction 10 | DYNAMODB_TABLE: ${self:service}-${opt:stage, self:provider.stage} 11 | SLACK_BOT_TOKEN: ${env:SLACK_BOT_TOKEN} 12 | SLACK_SIGNING_SECRET: ${env:SLACK_SIGNING_SECRET} 13 | iam: 14 | role: 15 | statements: 16 | - Effect: Allow 17 | Action: 18 | - dynamodb:Query 19 | - dynamodb:Scan 20 | - dynamodb:GetItem 21 | - dynamodb:PutItem 22 | - dynamodb:UpdateItem 23 | - dynamodb:DeleteItem 24 | Resource: "arn:aws:dynamodb:${opt:region, self:provider.region}:*:table/${self:provider.environment.DYNAMODB_TABLE}" 25 | - Effect: Allow 26 | Action: 27 | - lambda:InvokeFunction 28 | - lambda:InvokeAsync 29 | Resource: "arn:aws:lambda:${opt:region, self:provider.region}:*:function:${self:provider.environment.BOT_FUNCTION_NAME}" 30 | 31 | functions: 32 | acknowledgerFunction: 33 | handler: dist/acknowledger.handler 34 | provisionedConcurrency: 1 35 | events: 36 | - httpApi: 'POST /slack/events' 37 | botFunction: 38 | handler: dist/bot.handler 39 | environment: 40 | OPENAI_API_KEY: ${env:OPENAI_API_KEY} 41 | timeout: 300 42 | resources: 43 | Resources: 44 | conversationsTable: 45 | Type: AWS::DynamoDB::Table 46 | Properties: 47 | TableName: ${self:provider.environment.DYNAMODB_TABLE} 48 | AttributeDefinitions: 49 | - AttributeName: channel_id 50 | AttributeType: S 51 | KeySchema: 52 | - AttributeName: channel_id 53 | KeyType: HASH 54 | BillingMode: PAY_PER_REQUEST 55 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const nodeExternals = require('webpack-node-externals') 2 | const rules = [ 3 | { 4 | test: /\.css$/, 5 | use: 'null-loader' 6 | } 7 | ] 8 | const acknowledgerConfig = { 9 | entry: './app/acknowledger/index.js', 10 | target: 'node', 11 | externals: [nodeExternals()], 12 | output: { 13 | libraryTarget: 'commonjs', 14 | path: __dirname + '/dist', 15 | filename: 'acknowledger.js' 16 | }, 17 | module: { rules }, 18 | mode: process.env.NODE_ENV === 'production' ? 'production' : 'development' 19 | }; 20 | 21 | const botConfig = { 22 | entry: './app/bot/index.js', 23 | target: 'node', 24 | externals: [nodeExternals()], 25 | output: { 26 | libraryTarget: 'commonjs', 27 | path: __dirname + '/dist', 28 | filename: 'bot.js' 29 | }, 30 | module: { rules }, 31 | mode: process.env.NODE_ENV === 'production' ? 'production' : 'development' 32 | }; 33 | 34 | module.exports = [ 35 | acknowledgerConfig, 36 | botConfig, 37 | ]; 38 | --------------------------------------------------------------------------------