├── .gitignore ├── screenshots ├── dm.png ├── command.png ├── header.jpeg ├── mention.png ├── global-shortcut.png └── shortcut-on-message.png ├── wrangler.toml ├── src ├── Env.ts ├── Endpoints │ ├── Endpoint.ts │ ├── SlackCommandsEndpoint.ts │ ├── SlackEventsEndpoint.ts │ └── SlackInteractivityEndpoint.ts ├── Slack │ ├── SlackActionID.ts │ ├── SlackMessage.ts │ ├── SlackCallbackID.ts │ ├── SlackResponse.ts │ ├── SlackEventType.ts │ ├── SlackLoadingMessage.ts │ ├── SlackClient.ts │ └── SlackView.ts ├── NetworkService │ ├── NetworkService.ts │ └── NetworkServiceLive.ts ├── readRequestBody.ts ├── ResponseFactory.ts ├── ChatGPTClient.ts ├── verifySlackSignature.ts ├── index.ts └── CompositionRoot.ts ├── package.json ├── LICENSE ├── README.md └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .dev.vars 3 | -------------------------------------------------------------------------------- /screenshots/dm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shapehq/slack-chatgpt/HEAD/screenshots/dm.png -------------------------------------------------------------------------------- /screenshots/command.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shapehq/slack-chatgpt/HEAD/screenshots/command.png -------------------------------------------------------------------------------- /screenshots/header.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shapehq/slack-chatgpt/HEAD/screenshots/header.jpeg -------------------------------------------------------------------------------- /screenshots/mention.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shapehq/slack-chatgpt/HEAD/screenshots/mention.png -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "slack-chatgpt" 2 | main = "src/index.ts" 3 | compatibility_date = "2023-03-02" 4 | -------------------------------------------------------------------------------- /screenshots/global-shortcut.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shapehq/slack-chatgpt/HEAD/screenshots/global-shortcut.png -------------------------------------------------------------------------------- /screenshots/shortcut-on-message.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shapehq/slack-chatgpt/HEAD/screenshots/shortcut-on-message.png -------------------------------------------------------------------------------- /src/Env.ts: -------------------------------------------------------------------------------- 1 | export interface Env { 2 | OPENAI_API_KEY: string 3 | SLACK_TOKEN: string 4 | SLACK_SIGNING_SECRET: string 5 | } 6 | -------------------------------------------------------------------------------- /src/Endpoints/Endpoint.ts: -------------------------------------------------------------------------------- 1 | export interface Endpoint { 2 | fetch(request: Request, ctx: ExecutionContext): Promise 3 | } 4 | -------------------------------------------------------------------------------- /src/Slack/SlackActionID.ts: -------------------------------------------------------------------------------- 1 | export const SlackActionID = { 2 | SEND: "send", 3 | PROMPT: "prompt", 4 | CONVERSATION: "conversation" 5 | } 6 | -------------------------------------------------------------------------------- /src/Slack/SlackMessage.ts: -------------------------------------------------------------------------------- 1 | export interface SlackMessage { 2 | channel: string 3 | thread_ts?: string 4 | blocks?: any[] 5 | text?: string 6 | } -------------------------------------------------------------------------------- /src/NetworkService/NetworkService.ts: -------------------------------------------------------------------------------- 1 | export interface NetworkService { 2 | post(url: string, body: any, headers: {[key: string]: string}): Promise 3 | } -------------------------------------------------------------------------------- /src/Slack/SlackCallbackID.ts: -------------------------------------------------------------------------------- 1 | export const SlackCallbackID = { 2 | ASK_CHATGPT_ON_MESSAGE: "ask_chatgpt_on_message", 3 | ASK_CHATGPT_GLOBAL: "ask_chatgpt_global" 4 | } 5 | -------------------------------------------------------------------------------- /src/Slack/SlackResponse.ts: -------------------------------------------------------------------------------- 1 | export interface SlackResponse { 2 | delete_original?: boolean 3 | replace_original?: boolean 4 | response_type?: "in_channel" 5 | blocks?: any[] 6 | text?: string 7 | } -------------------------------------------------------------------------------- /src/Slack/SlackEventType.ts: -------------------------------------------------------------------------------- 1 | export const SlackEventType = { 2 | APP_MENTION: "app_mention", 3 | BLOCK_ACTIONS: "block_actions", 4 | EVENT_CALLBACK: "event_callback", 5 | MESSAGE: "message", 6 | MESSAGE_ACTION: "message_action", 7 | SHORTCUT: "shortcut", 8 | URL_VERIFICATION: "url_verification", 9 | VIEW_SUBMISSION: "view_submission" 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slack-chatgpt", 3 | "version": "0.0.0", 4 | "devDependencies": { 5 | "@cloudflare/workers-types": "^4.20230228.0", 6 | "typescript": "^4.9.5", 7 | "wrangler": "2.6.0" 8 | }, 9 | "private": true, 10 | "scripts": { 11 | "start": "wrangler dev", 12 | "deploy": "wrangler publish" 13 | }, 14 | "dependencies": { 15 | "@slack/web-api": "^6.8.1", 16 | "openai": "^3.2.1" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/NetworkService/NetworkServiceLive.ts: -------------------------------------------------------------------------------- 1 | import {NetworkService} from "./NetworkService" 2 | 3 | export class NetworkServiceLive implements NetworkService { 4 | async post(url: string, body: any, headers: {[key: string]: string}): Promise { 5 | let allHeaders = headers || {} 6 | allHeaders["Content-Type"] = "application/json;charset=utf-8" 7 | return await fetch(url, { 8 | method: "post", 9 | body: JSON.stringify(body), 10 | headers: allHeaders 11 | }) 12 | } 13 | } -------------------------------------------------------------------------------- /src/readRequestBody.ts: -------------------------------------------------------------------------------- 1 | export async function readRequestBody(request: Request): Promise { 2 | const contentType = request.headers.get("content-type") 3 | if (contentType?.includes("application/json")) { 4 | return await request.json() 5 | } else if (contentType?.includes("form")) { 6 | const formData = await request.formData() 7 | const body: any = {} 8 | for (const entry of formData.entries()) { 9 | body[entry[0]] = entry[1] 10 | } 11 | return body 12 | } else { 13 | throw new Error("Unexpected content type. Expected application/json or form data.") 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/ResponseFactory.ts: -------------------------------------------------------------------------------- 1 | export class ResponseFactory { 2 | static badRequest(message: string): Response { 3 | return new Response(message, { 4 | status: 400, 5 | statusText: "Bad Request" 6 | }) 7 | } 8 | 9 | static internalServerError(message: string): Response { 10 | return new Response(message, { 11 | status: 500, 12 | statusText: "Internal Server Error" 13 | }) 14 | } 15 | 16 | static unauthorized(message: string): Response { 17 | return new Response(message, { 18 | status: 401, 19 | statusText: "Unauthorized" 20 | }) 21 | } 22 | 23 | static json(body: any): Response { 24 | const json = JSON.stringify(body, null, 2); 25 | return new Response(json, { 26 | headers: { 27 | "content-type": "application/json;charset=UTF-8" 28 | } 29 | }) 30 | } 31 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Shape ApS 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 | -------------------------------------------------------------------------------- /src/ChatGPTClient.ts: -------------------------------------------------------------------------------- 1 | import { NetworkService } from "./NetworkService/NetworkService" 2 | 3 | export class ChatGPTClient { 4 | networkService: NetworkService 5 | apiKey: string 6 | 7 | constructor(networkService: NetworkService, apiKey: string) { 8 | this.networkService = networkService 9 | this.apiKey = apiKey 10 | } 11 | 12 | async getResponse(prompt: string): Promise { 13 | const url = "https://api.openai.com/v1/chat/completions" 14 | const body = { 15 | model: "gpt-3.5-turbo", 16 | messages: [ 17 | {"role": "system", "content": "You are a helpful assistant."}, 18 | {"role": "user", "content": prompt} 19 | ] 20 | } 21 | const headers = { 22 | "Authorization": "Bearer " + this.apiKey 23 | } 24 | try { 25 | const response = await this.networkService.post(url, body, headers) 26 | const json = await response.json() 27 | if ("error" in json) { 28 | throw new Error(json.error.message) 29 | } else if ("choices" in json && json.choices.length > 0) { 30 | return json.choices[0].message.content 31 | } else { 32 | throw new Error("Did not receive any message choices") 33 | } 34 | } catch (error) { 35 | console.log(error) 36 | throw error 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/verifySlackSignature.ts: -------------------------------------------------------------------------------- 1 | // Implementation from https://gist.github.com/phistrom/3d691a2b4845f9ec9421faaebddc0904 2 | 3 | const SIGN_VERSION = "v0" 4 | 5 | export async function verifySlackSignature(request: Request, signingSecret: string) { 6 | const timestamp = request.headers.get("x-slack-request-timestamp") 7 | // remove starting 'v0=' from the signature header 8 | const signatureStr = request.headers.get("x-slack-signature")?.substring(3) 9 | if (signatureStr == null) { 10 | return false 11 | } 12 | // convert the hex string of x-slack-signature header to binary 13 | const signature = hexToBytes(signatureStr) 14 | const content = await request.text() 15 | const authString = `${SIGN_VERSION}:${timestamp}:${content}` 16 | let encoder = new TextEncoder() 17 | const key = await crypto.subtle.importKey( 18 | "raw", 19 | encoder.encode(signingSecret), 20 | { name: "HMAC", hash: "SHA-256" }, 21 | false, 22 | ["verify"] 23 | ) 24 | return await crypto.subtle.verify( 25 | "HMAC", 26 | key, 27 | signature, 28 | encoder.encode(authString) 29 | ) 30 | } 31 | 32 | function hexToBytes(hex: string) { 33 | const bytes = new Uint8Array(hex.length / 2) 34 | for (let c = 0; c < hex.length; c += 2) { 35 | bytes[c / 2] = parseInt(hex.substr(c, 2), 16) 36 | } 37 | return bytes.buffer 38 | } -------------------------------------------------------------------------------- /src/Slack/SlackLoadingMessage.ts: -------------------------------------------------------------------------------- 1 | export class SlackLoadingMessage { 2 | static getRandom(): string { 3 | const messages = [ 4 | "Let me think about that for a second...", 5 | "Crunching, crunching, crunching...", 6 | "Let me see...", 7 | "Hold on a second, please.", 8 | "Loading... It's not you, it's me. I'm always this slow.", 9 | "Hold tight! I'm working to find the best response for you.", 10 | "I'm thinking, please wait a moment.", 11 | "Just a few more seconds, I am is processing your query.", 12 | "I am looking up the information you requested. Thanks for your patience!", 13 | "This might take a moment.", 14 | "I'm working hard to get you the answer you need. Please wait a few more seconds.", 15 | "Searching for the best solution to your query. Hang in there.", 16 | "Preparing your response. It will be with you in no time!", 17 | "I'm working overtime to give you the best response. Thank you for your patience.", 18 | "Good things come to those who wait, like unicorns and rainbows.", 19 | "Grab a snack while you wait, we'll be here for a while.", 20 | "Loading... Don't worry, I'm just taking a coffee break.", 21 | "If patience is a virtue, you're about to become a saint. I'm loading...", 22 | "I'm running on caffeine and code. I'll be with you shortly!" 23 | ] 24 | const idx = Math.floor(Math.random() * messages.length) 25 | return messages[idx] 26 | } 27 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { CompositionRoot } from "./CompositionRoot" 2 | import { Endpoint } from "./Endpoints/Endpoint" 3 | import { Env } from "./Env" 4 | import { ResponseFactory } from "./ResponseFactory" 5 | import { verifySlackSignature } from "./verifySlackSignature" 6 | 7 | export default { 8 | async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { 9 | const url = new URL(request.url) 10 | const endpoint = getEndpoint(url.pathname, env) 11 | if (endpoint != null) { 12 | const clonedRequest = await request.clone() 13 | const isSlackSignatureVerified = await verifySlackSignature(clonedRequest, env.SLACK_SIGNING_SECRET) 14 | if (isSlackSignatureVerified) { 15 | return await endpoint.fetch(request, ctx) 16 | } else { 17 | return ResponseFactory.unauthorized("The Slack signature is invalid") 18 | } 19 | } else { 20 | return ResponseFactory.badRequest("Unknown path: " + url.pathname) 21 | } 22 | } 23 | } 24 | 25 | function getEndpoint(pathname: string, env: Env): Endpoint | null { 26 | const pathComponents = pathname.slice(1).split("/").filter(e => e.length > 0) 27 | if (pathComponents.length == 1 && pathComponents[0] == "events") { 28 | return CompositionRoot.getSlackEventsEndpoint(env.OPENAI_API_KEY, env.SLACK_TOKEN) 29 | } else if (pathComponents.length == 1 && pathComponents[0] == "commands") { 30 | return CompositionRoot.getSlackCommandsEndpoint(env.OPENAI_API_KEY, env.SLACK_TOKEN) 31 | } else if (pathComponents.length == 1 && pathComponents[0] == "interactivity") { 32 | return CompositionRoot.getSlackInteractivityEndpoint(env.OPENAI_API_KEY, env.SLACK_TOKEN) 33 | } else { 34 | return null 35 | } 36 | } -------------------------------------------------------------------------------- /src/CompositionRoot.ts: -------------------------------------------------------------------------------- 1 | import { ChatGPTClient } from "./ChatGPTClient" 2 | import { NetworkService } from "./NetworkService/NetworkService" 3 | import { NetworkServiceLive } from "./NetworkService/NetworkServiceLive" 4 | import { SlackClient } from "./Slack/SlackClient" 5 | import { SlackEventsEndpoint } from "./Endpoints/SlackEventsEndpoint" 6 | import { SlackCommandsEndpoint } from "./Endpoints/SlackCommandsEndpoint" 7 | import { SlackInteractivityEndpoint } from "./Endpoints/SlackInteractivityEndpoint" 8 | 9 | export class CompositionRoot { 10 | static getSlackEventsEndpoint(openAIAPIKey: string, slackToken: string): SlackEventsEndpoint { 11 | return new SlackEventsEndpoint( 12 | this.getChatGPTClient(openAIAPIKey), 13 | this.getSlackClient(slackToken) 14 | ) 15 | } 16 | 17 | static getSlackCommandsEndpoint(openAIAPIKey: string, slackToken: string): SlackCommandsEndpoint { 18 | return new SlackCommandsEndpoint( 19 | this.getChatGPTClient(openAIAPIKey), 20 | this.getSlackClient(slackToken) 21 | ) 22 | } 23 | 24 | static getSlackInteractivityEndpoint(openAIAPIKey: string, slackToken: string): SlackInteractivityEndpoint { 25 | return new SlackInteractivityEndpoint( 26 | this.getChatGPTClient(openAIAPIKey), 27 | this.getSlackClient(slackToken) 28 | ) 29 | } 30 | 31 | private static getChatGPTClient(apiKey: string): ChatGPTClient { 32 | return new ChatGPTClient(this.getNetworkService(), apiKey) 33 | } 34 | 35 | private static getSlackClient(token: string): SlackClient { 36 | return new SlackClient(this.getNetworkService(), token) 37 | } 38 | 39 | private static getNetworkService(): NetworkService { 40 | return new NetworkServiceLive() 41 | } 42 | } -------------------------------------------------------------------------------- /src/Endpoints/SlackCommandsEndpoint.ts: -------------------------------------------------------------------------------- 1 | import { ChatGPTClient } from "../ChatGPTClient" 2 | import { Endpoint } from "./Endpoint" 3 | import { readRequestBody } from "../readRequestBody" 4 | import { ResponseFactory } from "../ResponseFactory" 5 | import { SlackClient } from "../Slack/SlackClient" 6 | import { SlackLoadingMessage } from "../Slack/SlackLoadingMessage" 7 | 8 | export class SlackCommandsEndpoint implements Endpoint { 9 | chatGPTClient: ChatGPTClient 10 | slackClient: SlackClient 11 | 12 | constructor(chatGPTClient: ChatGPTClient, slackClient: SlackClient) { 13 | this.chatGPTClient = chatGPTClient 14 | this.slackClient = slackClient 15 | } 16 | 17 | async fetch(request: Request, ctx: ExecutionContext): Promise { 18 | if (request.method == "POST") { 19 | return await this.handlePostRequest(request, ctx) 20 | } else { 21 | return ResponseFactory.badRequest("Unsupported HTTP method: " + request.method) 22 | } 23 | } 24 | 25 | private async handlePostRequest(request: Request, ctx: ExecutionContext): Promise { 26 | const body = await readRequestBody(request) 27 | let answerPromise = this.postAnswer(body.response_url, body.user_id, body.text) 28 | ctx.waitUntil(answerPromise) 29 | return ResponseFactory.json({ 30 | text: SlackLoadingMessage.getRandom() 31 | }) 32 | } 33 | 34 | private async postAnswer(responseURL: string, userId: string, prompt: string) { 35 | const answer = await this.chatGPTClient.getResponse(prompt) 36 | await this.slackClient.postResponse(responseURL, { 37 | text: answer, 38 | response_type: "in_channel", 39 | blocks: [{ 40 | type: "section", 41 | text: { 42 | type: "plain_text", 43 | text: answer 44 | } 45 | }, { 46 | type: "context", 47 | elements: [{ 48 | type: "mrkdwn", 49 | text: "by <@" + userId + ">" 50 | }] 51 | }] 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Slack/SlackClient.ts: -------------------------------------------------------------------------------- 1 | import { NetworkService } from "../NetworkService/NetworkService" 2 | import { SlackMessage } from "./SlackMessage" 3 | import { SlackResponse } from "./SlackResponse" 4 | 5 | export class SlackClient { 6 | networkService: NetworkService 7 | token: string 8 | 9 | constructor(networkService: NetworkService, token: string) { 10 | this.networkService = networkService 11 | this.token = token 12 | } 13 | 14 | async postMessage(message: SlackMessage): Promise { 15 | await this.post("https://slack.com/api/chat.postMessage", message) 16 | } 17 | 18 | async postEphemeralMessage(message: SlackMessage): Promise { 19 | await this.post("https://slack.com/api/chat.postEphemeral", message) 20 | } 21 | 22 | async postResponse(responseURL: string, response: SlackResponse): Promise { 23 | await this.post(responseURL, response) 24 | } 25 | 26 | async deleteMessage(responseURL: string): Promise { 27 | await this.post(responseURL, { 28 | delete_original: true 29 | }) 30 | } 31 | 32 | async openView(triggerId: string, view: any): Promise { 33 | await this.post("https://slack.com/api/views.open", { 34 | trigger_id: triggerId, 35 | view: view 36 | }) 37 | } 38 | 39 | async updateView(viewId: string, view: any): Promise { 40 | await this.post("https://slack.com/api/views.update", { 41 | view_id: viewId, 42 | view: view 43 | }) 44 | } 45 | 46 | private async post(url: string, body: any) { 47 | const response = await this.networkService.post(url, body, { 48 | "Authorization": "Bearer " + this.token 49 | }) 50 | this.processResponse(response) 51 | } 52 | 53 | private async processResponse(response: any) { 54 | if (!response.ok) { 55 | const metadata = response.response_metadata 56 | if (metadata.messages != null && metadata.messages.length > 0) { 57 | throw new Error(response.error + ": " + metadata.messages[0]) 58 | } else { 59 | throw new Error(response.error) 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Slack/SlackView.ts: -------------------------------------------------------------------------------- 1 | import { SlackActionID } from "./SlackActionID" 2 | import { SlackLoadingMessage } from "./SlackLoadingMessage" 3 | 4 | interface SlackPromptViewOptions { 5 | isLoading?: boolean 6 | answer?: string 7 | } 8 | 9 | export class SlackView { 10 | static prompt(options?: SlackPromptViewOptions) { 11 | let blocks: any[] = [{ 12 | type: "input", 13 | block_id: "prompt", 14 | label: { 15 | type: "plain_text", 16 | text: "Question" 17 | }, 18 | element: { 19 | type: "plain_text_input", 20 | action_id: SlackActionID.PROMPT, 21 | placeholder: { 22 | type: "plain_text", 23 | text: "Write an announcement for the next iPhone" 24 | }, 25 | multiline: false, 26 | dispatch_action_config: { 27 | trigger_actions_on: ["on_enter_pressed"] 28 | } 29 | }, 30 | dispatch_action: true, 31 | optional: false 32 | }] 33 | if (options?.isLoading || options?.answer != null) { 34 | blocks.push({ 35 | type: "section", 36 | block_id: "answer_title", 37 | text: { 38 | type: "mrkdwn", 39 | text: "*Message 🧠*" 40 | } 41 | }) 42 | } 43 | if (options?.isLoading) { 44 | blocks.push({ 45 | type: "section", 46 | block_id: "loading", 47 | text: { 48 | type: "plain_text", 49 | text: SlackLoadingMessage.getRandom() 50 | } 51 | }) 52 | } 53 | if (options?.answer != null) { 54 | blocks = blocks.concat([{ 55 | type: "section", 56 | block_id: "answer", 57 | text: { 58 | type: "plain_text", 59 | text: options.answer 60 | } 61 | }, { 62 | type: "input", 63 | block_id: "conversations", 64 | label: { 65 | type: "plain_text", 66 | text: "Conversation" 67 | }, 68 | element: { 69 | type: "conversations_select", 70 | action_id: SlackActionID.CONVERSATION, 71 | default_to_current_conversation: true, 72 | response_url_enabled: true 73 | } 74 | }]) 75 | } 76 | let view: any = { 77 | type: "modal", 78 | title: { 79 | type: "plain_text", 80 | text: "Ask ChatGPT" 81 | }, 82 | blocks: blocks, 83 | submit: { 84 | type: "plain_text", 85 | text: "Send" 86 | } 87 | } 88 | if (options?.answer != null && options.answer.length > 0) { 89 | view.private_metadata = options.answer 90 | } 91 | return view 92 | } 93 | } -------------------------------------------------------------------------------- /src/Endpoints/SlackEventsEndpoint.ts: -------------------------------------------------------------------------------- 1 | import { ChatGPTClient } from "../ChatGPTClient" 2 | import { Endpoint } from "./Endpoint" 3 | import { readRequestBody } from "../readRequestBody" 4 | import { ResponseFactory } from "../ResponseFactory" 5 | import { SlackClient } from "../Slack/SlackClient" 6 | import { SlackEventType } from "../Slack/SlackEventType" 7 | import { SlackLoadingMessage } from "../Slack/SlackLoadingMessage" 8 | 9 | export class SlackEventsEndpoint implements Endpoint { 10 | chatGPTClient: ChatGPTClient 11 | slackClient: SlackClient 12 | 13 | constructor(chatGPTClient: ChatGPTClient, slackClient: SlackClient) { 14 | this.chatGPTClient = chatGPTClient 15 | this.slackClient = slackClient 16 | } 17 | 18 | async fetch(request: Request, ctx: ExecutionContext): Promise { 19 | if (request.method == "POST") { 20 | return await this.handlePostRequest(request, ctx) 21 | } else { 22 | return ResponseFactory.badRequest("Unsupported HTTP method: " + request.method) 23 | } 24 | } 25 | 26 | private async handlePostRequest(request: Request, ctx: ExecutionContext): Promise { 27 | const body = await readRequestBody(request) 28 | if (body.type == SlackEventType.URL_VERIFICATION) { 29 | return new Response(body.challenge) 30 | } else if (body.type == SlackEventType.EVENT_CALLBACK) { 31 | // Make sure the message was not sent by a bot. If we do not have this check the bot will keep a conversation going with itself. 32 | const event = body.event 33 | if (!this.isEventFromBot(event)) { 34 | await this.postEphemeralLoadingMessage(event.type, event.user, event.channel, event.thread_ts) 35 | const answerPromise = this.postAnswer(event.type, event.channel, event.ts, event.text) 36 | ctx.waitUntil(answerPromise) 37 | return new Response() 38 | } else { 39 | return new Response() 40 | } 41 | } else { 42 | return new Response("Unsupported request from from Slack of type " + body.type, { 43 | status: 400, 44 | statusText: "Bad Request" 45 | }) 46 | } 47 | } 48 | 49 | private async postEphemeralLoadingMessage(eventType: any, user: string, channel: string, threadTs: string | null) { 50 | const message: any = { 51 | text: SlackLoadingMessage.getRandom(), 52 | channel: channel, 53 | user: user 54 | } 55 | if (eventType == SlackEventType.APP_MENTION) { 56 | message.thread_ts = threadTs 57 | } 58 | await this.slackClient.postEphemeralMessage(message) 59 | } 60 | 61 | private async postAnswer(eventType: any, channel: string, threadTs: string, prompt: string) { 62 | const answer = await this.chatGPTClient.getResponse(prompt) 63 | const message: any = { 64 | text: answer, 65 | channel: channel 66 | } 67 | if (eventType == SlackEventType.APP_MENTION) { 68 | message.thread_ts = threadTs 69 | } 70 | await this.slackClient.postMessage(message) 71 | } 72 | 73 | private isEventFromBot(event: any): boolean { 74 | return event.bot_profile != null 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Endpoints/SlackInteractivityEndpoint.ts: -------------------------------------------------------------------------------- 1 | import { ChatGPTClient } from "../ChatGPTClient" 2 | import { Endpoint } from "./Endpoint" 3 | import { readRequestBody } from "../readRequestBody" 4 | import { ResponseFactory } from "../ResponseFactory" 5 | import { SlackActionID } from "../Slack/SlackActionID" 6 | import { SlackCallbackID } from "../Slack/SlackCallbackID" 7 | import { SlackClient } from "../Slack/SlackClient" 8 | import { SlackEventType } from "../Slack/SlackEventType" 9 | import { SlackView } from "../Slack/SlackView" 10 | 11 | export class SlackInteractivityEndpoint implements Endpoint { 12 | chatGPTClient: ChatGPTClient 13 | slackClient: SlackClient 14 | 15 | constructor(chatGPTClient: ChatGPTClient, slackClient: SlackClient) { 16 | this.chatGPTClient = chatGPTClient 17 | this.slackClient = slackClient 18 | } 19 | 20 | async fetch(request: Request, ctx: ExecutionContext): Promise { 21 | if (request.method == "POST") { 22 | return await this.handlePostRequest(request, ctx) 23 | } else { 24 | return ResponseFactory.badRequest("Unsupported HTTP method: " + request.method) 25 | } 26 | } 27 | 28 | private async handlePostRequest(request: Request, ctx: ExecutionContext): Promise { 29 | const body = await readRequestBody(request) 30 | const payload = JSON.parse(body.payload) 31 | if (payload.type == SlackEventType.MESSAGE_ACTION) { 32 | return await this.handleMessageAction(payload, ctx) 33 | } else if (payload.type == SlackEventType.SHORTCUT) { 34 | return await this.handleShortcut(payload, ctx) 35 | } else if (payload.type == SlackEventType.BLOCK_ACTIONS) { 36 | return await this.handleBlockAction(payload, ctx) 37 | } else if (payload.type == SlackEventType.VIEW_SUBMISSION) { 38 | return await this.handleViewSubmission(payload, ctx) 39 | } else { 40 | return ResponseFactory.badRequest("Unsupported payload type: " + payload.type) 41 | } 42 | } 43 | 44 | private async handleMessageAction(payload: any, ctx: ExecutionContext): Promise { 45 | if (payload.callback_id == SlackCallbackID.ASK_CHATGPT_ON_MESSAGE) { 46 | let answerPromise = this.postAnswer(payload.message.text, payload.channel.name, payload.message.ts) 47 | ctx.waitUntil(answerPromise) 48 | return new Response() 49 | } else { 50 | return ResponseFactory.badRequest("Unsupported callback ID: " + payload.callback_id) 51 | } 52 | } 53 | 54 | private async handleShortcut(payload: any, ctx: ExecutionContext): Promise { 55 | if (payload.callback_id == SlackCallbackID.ASK_CHATGPT_GLOBAL) { 56 | await this.slackClient.openView(payload.trigger_id, SlackView.prompt()) 57 | return new Response() 58 | } else { 59 | return ResponseFactory.badRequest("Unsupported callback ID: " + payload.callback_id) 60 | } 61 | } 62 | 63 | private async handleBlockAction(payload: any, ctx: ExecutionContext): Promise { 64 | if (payload.actions.length == 0) { 65 | return ResponseFactory.badRequest("No action found") 66 | } 67 | const action = payload.actions[0] 68 | if (action.action_id == SlackActionID.PROMPT) { 69 | await this.slackClient.updateView(payload.view.id, SlackView.prompt({ isLoading: true })) 70 | const prompt = payload.view.state.values.prompt.prompt.value 71 | let answerPromise = this.showAnswerInPromptView(payload.view.id, prompt) 72 | ctx.waitUntil(answerPromise) 73 | return new Response() 74 | } else if (action.action_id == SlackActionID.CONVERSATION) { 75 | return new Response() 76 | } else { 77 | return ResponseFactory.badRequest("Unsupported callback ID: " + payload.callback_id) 78 | } 79 | } 80 | 81 | private async handleViewSubmission(payload: any, ctx: ExecutionContext): Promise { 82 | const answer = payload.view.private_metadata 83 | if (answer == null || answer.length == 0) { 84 | return ResponseFactory.json({ 85 | response_action: "errors", 86 | errors: { 87 | prompt: "Please enter a prompt and wait for ChatGPT to answer." 88 | } 89 | }) 90 | } 91 | const responseURL = payload.response_urls.length > 0 ? payload.response_urls[0].response_url : null 92 | if (answer == null || answer.length == 0) { 93 | return ResponseFactory.json({ 94 | response_action: "errors", 95 | errors: { 96 | prompt: "Please ensure you have selected a conversation." 97 | } 98 | }) 99 | } 100 | await this.slackClient.postResponse(responseURL, { 101 | text: answer, 102 | response_type: "in_channel", 103 | blocks: [{ 104 | type: "section", 105 | text: { 106 | type: "plain_text", 107 | text: answer 108 | } 109 | }, { 110 | type: "context", 111 | elements: [{ 112 | type: "mrkdwn", 113 | text: "by <@" + payload.user.id + ">" 114 | }] 115 | }] 116 | }) 117 | return new Response() 118 | } 119 | 120 | private async showAnswerInPromptView(viewId: string, prompt: string) { 121 | const answer = await this.chatGPTClient.getResponse(prompt) 122 | await this.slackClient.updateView(viewId, SlackView.prompt({ answer: answer })) 123 | } 124 | 125 | private async postAnswer(prompt: string, channel: string, threadTs?: string) { 126 | const answer = await this.chatGPTClient.getResponse(prompt) 127 | await this.slackClient.postMessage({ 128 | text: answer, 129 | channel: channel, 130 | thread_ts: threadTs 131 | }) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Screenshot of a conversation between a person and ChatGPT 3 |

Integrate ChatGPT into Slack using a Slack app hosted on Cloudflare Workers.

4 | 5 | [✨ Features](https://github.com/shapehq/slack-chatgpt/#-features)   •   [🚀 Getting Started](https://github.com/shapehq/slack-chatgpt/#-getting-started)   •   [💻 Running the Project Locally](https://github.com/shapehq/slack-chatgpt/#-running-the-project-locally)   •   [Built with ❤️ by Shape](https://github.com/shapehq/slack-chatgpt/#built-with-%EF%B8%8F-by-shape)
6 | 7 | ## ✨ Features 8 | 9 | slack-chatgpt can be used to interact with ChatGPT in several ways on Slack. 10 | 11 | #### Mentions 12 | 13 | When mentioning the bot, it will post a reply in a thread so it does not clutter the conversation. 14 | 15 | 16 | 17 | #### Direct Messages 18 | 19 | People in the workspace can write messages messages to the bot in which case it replies directly within the conversation. 20 | 21 | 22 | 23 | #### Slash Command 24 | 25 | Use the slash command to ask ChatGPT a question and have it reply within the conversation. 26 | 27 | 28 | 29 | #### Shortcut on Message 30 | 31 | The shortcut on messages can be used to answer a message using ChatGPT. 32 | 33 | 34 | 35 | #### Global Shortcut 36 | 37 | The global shortcut can be used to have ChatGPT help you write a message and then send that message to a channel or you can copy the message and send it yourself. 38 | 39 | 40 | 41 | ## 🚀 Getting Started 42 | 43 | Follow the steps below to get started. 44 | 45 | ### Create a Cloudflare Worker 46 | 47 | The Slack app was built to be hosted on Cloudflare Workers, and as such, we will need to create a worker on Cloudflare Workers. 48 | 49 | 1. Go to [workers.cloudflare.com](https://workers.cloudflare.com) and create a worker. Choose any name you would like. 50 | 2. Take note of the URL of your worker as you will need it when creating a Slack app. 51 | 52 | Cloudflare's [wrangler](https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler) CLI is used to deploy, update, and monitor Cloudflare workers. We will need the CLI later so make sure to install it by running the following command. 53 | 54 | ```bash 55 | npm install -g wrangler 56 | ``` 57 | 58 | ### Register on OpenAI 59 | 60 | You will need an OpenAI account to access the ChatGPT API. 61 | 62 | 1. Register for an account on [platform.openai.com](https://platform.openai.com). 63 | 2. Add billing information to your account if you have already used your free credits or they have expired. 64 | 3. Generate an API key and save it for later. 65 | 66 | ### Create a Slack App 67 | 68 | The Slack app will be used to listen for request in Slack and post messages back into Slack. In order to support all of slack-chatgpt's features, there are a couple of steps needed. However, you can also choose to setup just the features you need. 69 | 70 | Start by creating a Slack app on [api.slack.com/apps](https://api.slack.com/apps). You are free to choose any name for the app that fits your needs. 71 | 72 | Make sure to add the Bots feature to the Slack app and add the following scopes: 73 | 74 | - `app_mentions:read` 75 | - `chat:write` 76 | - `commands` 77 | - `im:history` 78 | - `chat:write.public` 79 | 80 | Take note of your bot's OAuth token and your app's signing secret as you will need [add both to your Cloudflare worker later](https://github.com/shapehq/slack-chatgpt#add-your-secrets-to-the-cloudflare-worker). 81 | 82 | #### Responding to Mentions and Direct Messages 83 | 84 | In order for the bot to respond to mentions and direct messages, you must enable Event Subscriptions in your Slack app and pass the URL to your Cloudflare Worker followed by the path `/events`, e.g. `https://slack-chatgpt.shapehq.workers.dev/events`. 85 | 86 | Make sure to subscribe to the following events: 87 | 88 | - `app_mention` 89 | - `message.im` 90 | 91 | #### Enabling the Slash Command 92 | 93 | Add a slash command to your Slack app. You are free to choose the command, description, and usage hint that fits your needs but make sure to set the URL to your Cloudflare Worker followed by the path `/commands`, e.g. `https://slack-chatgpt.shapehq.workers.dev/commands`. 94 | 95 | #### Adding the Shortcut to Messages 96 | 97 | In order to respond to a message using ChatGPT, you must enable interactivity on your Slack app. Make sure to set the URL to your Cloudflare Worker followed by the path `/interactivity`, e.g. `https://slack-chatgpt.shapehq.workers.dev/interactivity`. 98 | 99 | Then create a new shortcut and select "On messages" when asked about where the shortcut should appear. You are free to choose the name and description that fit your needs but make sure to set the callback ID to `ask_chatgpt_on_message`. 100 | 101 | #### Adding the Global Shortcut 102 | 103 | To add the global shortcut to your workspace, you must enable interactivity on your Slack app. You may have already done this when adding the shortcut to messages. When enabling interactivity, you should make sure to set the URL to your Cloudflare Worker followed by the path `/interactivity`, e.g. `https://slack-chatgpt.shapehq.workers.dev/interactivity`. 104 | 105 | Then create a new shortcut and select "Global" when asked about where the shortcut should appear. You are free to choose the name and description that fit your needs but make sure to set the callback ID to `ask_chatgpt_global`. 106 | 107 | ### Add Your Secrets to the Cloudflare Worker 108 | 109 | Your Cloudflare worker will need to know your OpenAI API key and the Slack bot's token. 110 | 111 | Start by adding the OpenAI API key by running the following command. Paste the API key when prompted to enter it. 112 | 113 | ```bash 114 | wrangler secret put OPENAI_API_KEY 115 | ``` 116 | 117 | Then add your bot's token running the following command. Paste the token when prompted to enter it. 118 | 119 | ```bash 120 | wrangler secret put SLACK_TOKEN 121 | ``` 122 | 123 | Finally add the Slack signing secret. Paste the secret when prompted to enter it. 124 | 125 | ```bash 126 | wrangler secret put SLACK_SIGNING_SECRET 127 | ``` 128 | 129 | ### Deploy to Cloudflare 130 | 131 | After cloning the repository you can deploy it to Cloudflare by running the following command. 132 | 133 | ```bash 134 | npx wrangler publish 135 | ``` 136 | 137 | ChatGPT should now be integrated with your Slack workspace. 138 | 139 | ## 💻 Running the Project Locally 140 | 141 | To run the project locally, you will need to create a file named `.dev.vars` that contains your secrets. The content of the file should be as shown below. 142 | 143 | ``` 144 | OPENAI_API_KEY=xxx 145 | SLACK_TOKEN=xxx 146 | SLACK_SIGNING_SECRET=xxx 147 | ``` 148 | 149 | Remember to replace the OpenAI API key and Slack token with your actual credentials. 150 | 151 | Then start the server by running the following command. 152 | 153 | ```bash 154 | npx wrangler dev 155 | ``` 156 | 157 | ## Built with ❤️ by [Shape](https://shape.dk) 158 | 159 | We built slack-chatgpt at Shape in a couple of hours to try out the ChatGPT APIs when they were published on March 1st, 2023 and are now having a great time asking ChatGPT both serious and silly questions in our Slack 😄 160 | 161 | Want to build cool and fun products with us? [We are hiring](https://careers.shape.dk)! 😃🫶 162 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | "lib": [ 16 | "es2021" 17 | ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, 18 | "jsx": "react" /* Specify what JSX code is generated. */, 19 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 20 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 21 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 22 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 23 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 24 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 25 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 26 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 27 | 28 | /* Modules */ 29 | "module": "es2022" /* Specify what module code is generated. */, 30 | // "rootDir": "./", /* Specify the root folder within your source files. */ 31 | "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, 32 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 33 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 34 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 35 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 36 | "types": [ 37 | "@cloudflare/workers-types", 38 | "jest" 39 | ] /* Specify type package names to be included without being referenced in a source file. */, 40 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 41 | "resolveJsonModule": true /* Enable importing .json files */, 42 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 43 | 44 | /* JavaScript Support */ 45 | "allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */, 46 | "checkJs": false /* Enable error reporting in type-checked JavaScript files. */, 47 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 48 | 49 | /* Emit */ 50 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 51 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 52 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 53 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 54 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 55 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 56 | // "removeComments": true, /* Disable emitting comments. */ 57 | "noEmit": true /* Disable emitting files from a compilation. */, 58 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 59 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 60 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 61 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 62 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 63 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 64 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 65 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 66 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 67 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 68 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 69 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 70 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 71 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 72 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 73 | 74 | /* Interop Constraints */ 75 | "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */, 76 | "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */, 77 | // "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, 78 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 79 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 80 | 81 | /* Type Checking */ 82 | "strict": true /* Enable all strict type-checking options. */, 83 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 84 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 85 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 86 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 87 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 88 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 89 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 90 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 91 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 92 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 93 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 94 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 95 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 96 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 97 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 98 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 99 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 100 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 101 | 102 | /* Completeness */ 103 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 104 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 105 | } 106 | } 107 | --------------------------------------------------------------------------------