ChatGPTと会話するSlack botを最速で実装する

## 概要

ChatGPTと会話できるSlack botを実装しました。SlackからすぐにChatGPTに質問することができるようになり、またSlack内でプロンプトの共有や回答内容の検索ができるようになり便利です。

Slack Bolt SDKとOpenAI SDKを活用し、AWS Lambdaにデプロイすることでスピーディに実装することができます。この記事ではその実装方法をステップバイステップでご紹介します。

## botの機能

実装したSlack botには以下の機能があります。

- メンションされた内容に対してスレッドでChatGPTが返信
- スレッド内でさらにメンションされるとそれまでの会話の内容を踏まえて回答

![スクリーンショット 2023-03-30 11.50.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/458395/7e657fd1-85ed-57cb-b15c-8da9390451bd.png)

## Slack APIアプリケーションの作成

まずはSlack APIを利用するためのアプリケーションを作成します。以下の画面から「Create New App」で新規作成しましょう。名前とbotを利用するワークスペースを入力してください。

https://api.slack.com/apps

次にアプリケーションの権限設定を行います。上記の機能を実現するために以下の権限スコープが必要です。

- `app_mentions:read`
- `channels:history`
- `chat:write`

「OAuth & Permissions」メニューの「Scopes」>「Bot Token Scopes」欄で該当する権限を追加してください。これらのスコープを有効にするために、場合によってはSlackワークスペース管理者にアプリケーションのインストールを許可してもらう必要があります。

ワークスペースにアプリケーションをインストールできたらBot User OAuth Tokenが発行されます。このトークンはbotの実装で利用するのでコピーしてメモしておきましょう。

アプリケーションの情報では「Basic Information」メニューの「App Credentials」>「Signing Secret」の内容も利用します。こちらもコピーしてメモしておいてください。

## OpenAI APIの利用

次にOpenAIのAPIを利用する準備を行います。まずはOpenAI APIにサインインします。

https://platform.openai.com/

次にAPIキーを発行します。以下の画面から発行できます。発行したAPIキーはコピーしてメモしておきましょう。

[Account API Keys - OpenAI API](https://platform.openai.com/account/api-keys)

OpenAI APIの無料枠がなかったり、使い切ってしまっている場合は支払い情報の登録も行いましょう。

[Billing overview - OpenAI API](https://platform.openai.com/account/billing/overview)

## Slack botの実装

今回は以下のような構成で実装しました。

- 言語: TypeScript
- バンドラ: Rollup

まずNode.jsのプロジェクトを作成します。依存ライブラリやビルドコマンドは`package.json`の内容を参照してください。

```json
{
    "name": "chatgptbot",
    "version": "1.0.0",
    "description": "",
    "main": "index.js",
    "scripts": {
        "build": "rollup --config --bundleConfigAsCjs"
    },
    "author": "",
    "license": "ISC",
    "dependencies": {
        "@slack/bolt": "^3.12.2",
        "axios": "^1.3.4",
        "openai": "^3.2.1"
    },
    "devDependencies": {
        "@rollup/plugin-commonjs": "^24.0.1",
        "@rollup/plugin-json": "^6.0.0",
        "@rollup/plugin-node-resolve": "^15.0.1",
        "@rollup/plugin-replace": "^5.0.2",
        "@rollup/plugin-typescript": "^11.0.0",
        "dotenv": "^16.0.3",
        "rollup": "^3.20.2",
        "rollup-plugin-dotenv": "^0.4.1",
        "tslib": "^2.5.0",
        "typescript": "^5.0.2"
    }
}
```

次にRollupの設定ファイルの`rollup.config.js`を作成します。内容は以下のようになります。TypeScriptのトランスパイルのために`@rollup/plugin-typescript`プラグインを利用しています。`@rollup/plugin-commonjs`/`@rollup/plugin-node-resolve`/`@rollup/plugin-json`の各種プラグインは依存ライブラリのバンドルに必要です。

```js
import typescript from '@rollup/plugin-typescript';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import json from '@rollup/plugin-json';

export default {
    input: 'index.ts',
    output: {
        file: 'dist/bundle.js',
        format: 'cjs'
    },
    plugins: [
        typescript(),
        resolve(),
        commonjs(),
        json(),
    ]
};
```

TypeScriptの設定ファイル`tsconfig.json`も作成します。以下のような内容で特別な設定は必要ありません。

```json
{
    "compilerOptions": {
        "strict": true,
        "target": "es6",
        "module": "ESNext",
        "moduleResolution": "Node"
    },
    "exclude": [
        "node_modules"
    ]
}
```

では`index.ts`を追加して具体的なbotの処理を実装していきましょう。まずシンプルにメンションに反応してスレッドに返信を返すだけのbotの実装は以下のようになります。

```ts
import { App, AwsLambdaReceiver } from '@slack/bolt';
import {
    AwsCallback,
    AwsEvent
} from '@slack/bolt/dist/receivers/AwsLambdaReceiver';

if (!process.env.SLACK_SIGNING_SECRET) process.exit(1);

const awsLambdaReceiver = new AwsLambdaReceiver({
    signingSecret: process.env.SLACK_SIGNING_SECRET
});
const app = new App({
    token: process.env.SLACK_BOT_TOKEN,
    receiver: awsLambdaReceiver
});

app.event('app_mention', async ({ event, context, client, say }) => {
    try {
        const { channel, event_ts } = event;
        await say({
            channel,
            thread_ts: event_ts,
            text: 'hello'
        });
    } catch (error) {
        console.error(error);
    }
});

module.exports.handler = async (
    event: AwsEvent,
    context: unknown,
    callback: AwsCallback
) => {
    const handler = await awsLambdaReceiver.start();
    return handler(event, context, callback);
};

```

`SLACK_SIGNING_SECRET`と`SLACK_BOT_TOKEN`は環境変数から読み取ります。これらはLambdaの画面から環境変数を設定することで利用できるようになります(後述)。

`@slack/bolt`ライブラリの`AwsLambdaReceiver`クラスのインスタンスを`App`クラスのコンストラクタに渡してSlack Boltのインスタンスを作成します。メンションのイベントに反応させるために`app.event()`に処理内容を記述したコールバック関数を渡します。コールバックの引数にはイベントの内容`evnet`/`context`やSlack APIを呼び出すための`client`、イベントに応答するための`say`関数があります。上記コードでは`say()`を実行してメンションされたスレッドに返信するだけのシンプルな処理になっています。

最後にLambda関数のエントリポイントとなる`handler`関数をエクスポートしています。この関数内の処理によってLambda関数の呼び出しに応じてSlack Boltの処理が実行される仕組みです。SDKが抽象化している部分なのでひとまず詳細には立ち入りません。

## Slack botのデプロイ

この状態で一旦Lambdaにデプロイしていきましょう。まず以下のコマンドでビルドします。

```bash
npm run build
```

次にアップロードするためのzipファイルを作成します。

```bash
zip deploy_package.zip ./dist/bundle.js
```

次にAWSのコンソールからLambda関数を作成します。

ランタイムにはNode.jsを選択してください。今回はLambda関数のHTTPエンドポイントの機能を使ってSlackと通信します。「Advanced settings」の「Enable function URL」にチェックを入れてください。「Auth type」については「NONE」を選択します。公開のエンドポイントになりますが、Slack APIがリクエストに署名することで認証を行なってくれるのでセキュアです。その署名の検証のためにSigning Secretを利用します。

https://api.slack.com/authentication/verifying-requests-from-slack

以上の設定でLambda関数を作成したら先ほど作成したソースコードのzipファイルをアップロードします。また同じ画面の「Runtime settings」項目で「Handler」を`dist/bundle.handler`に設定してください。Lambda関数のエントリポイントの指定です。

次にLambda関数の各種設定を行います。まず「General configuration」項目でタイムアウトまでの時間を3分程度に延長しておきましょう。デフォルトの3秒ではChatGPTが回答を考えている間に処理が終了してしまいます。筆者が使っているところメモリは128MBで不足ありませんでしたのでそのままで大丈夫です。

「Environment variables」項目で`OPENAI_API_KEY`/`SLACK_BOT_TOKEN`/`SLACK_SIGNING_SECRET`の三つの変数を設定します。それぞれセットアップの手順でメモしておいた値を入力してください。

以上でLambda関数の設定が完了しました。

次にSlackアプリケーションがLambda関数のHTTPエンドポイントにリクエストを送る設定を行います。Slackアプリケーションの設定画面に戻って「Event Subscriptions」メニューを開きます。設定をオンにすると「Request URL」の入力欄が出てくるので、Lambda関数の設定画面の「Function URL」に記載のあるURLを入力します。Lambda関数が正しく動作していればURLが検証されて「Verified」の表示になるはずです。

次に「Subscribe to bot events」項目で`app_mention`イベントを追加します。これでbotにメンションがあるとLambda関数にリクエストが送信される設定が完了しました。

試しにSlack上で適当なチャンネルにbotを招待してメンションを送ってみましょう。スレッドに`hello`と投稿されれば成功です。

## OpenAI APIの呼び出し

さて、メンションに反応するSlack botの実装は以上で完了しました。いよいよOpenAIのAPIを呼び出してChatGPTと会話する機能を実装していきましょう。

まずSlackのイベントAPIはリクエストに対して3秒以内にレスポンスが返ってこないとリトライが実行される仕組みになっています。

https://api.slack.com/apis/connections/events-api#responding

ChatGPTに考えさせている間に3秒以上経過してしまうのでAPIを呼び出す前にひとまず「処理中……」といった返信をする処理にしました。ユーザーとしてもリプライにひとまず反応があることで処理されているのか不安を抱くことがなくなります。また即レスポンスするとしてもLambdaコールドスタートなどで3秒以上かかる場合があるので、リトライの場合の処理はスキップする実装にしました。`app.event()`のコールバックの引数の`context`に`retryNum`という項目がありリトライ回数がわかるのでそれを参照します。

返信するスレッドのタイムスタンプは`event`引数の`event_ts`または`thread_ts`を参照します。後者はスレッド内での返信の時のみ存在する値なので、チャンネルで直接メンションされた場合は前者を利用します。

```ts
app.event('app_mention', async ({ event, context, client, say }) => {
    if (context.retryNum) {
        console.log(
            `skipped retry. retryReason: ${context.retryReason}`
        );
        return;
    }
    console.log(event);
    try {
        const { channel, thread_ts, event_ts } = event;
        const threadTs = thread_ts ?? event_ts;
        await say({
            channel,
            thread_ts: threadTs,
            text: '`system` 処理中……'
        });
    } catch (error) {
        console.error(error);
    }
});
```

単に質問に答えるだけでなく文脈を踏まえて会話させるために、メンションされたスレッド全体を参照します。`client.conversations.replies()`でチャンネルのIDとスレッドのタイムスタンプを指定して呼び出すことで取得できます。

```ts
app.event('app_mention', async ({ event, context, client, say }) => {
    // ...
    try {
        const { channel, thread_ts, event_ts } = event;
        const threadTs = thread_ts ?? event_ts;
        // ...
        const threadResponse = await client.conversations.replies({
            channel,
            ts: threadTs
        });
    } catch (error) {
        console.error(error);
    }
});
```

スレッドの内容からChatGPTに送信するパラメータを整形していきます。

```ts
import { ChatCompletionRequestMessage } from "openai";

app.event('app_mention', async ({ event, context, client, say }) => {
    // ...
    try {
        const { channel, thread_ts, event_ts } = event;
        const threadTs = thread_ts ?? event_ts;
        // ...
        const chatCompletionRequestMessage: ChatCompletionRequestMessage[] =
            [];
        threadResponse.messages?.forEach((message) => {
            const { text, user } = message;
            if (!text) return;
            if (user && user === bot_userid) {
                if (!text.startsWith('`system`')) {
                    chatCompletionRequestMessage.push({
                        role: 'assistant',
                        content: text
                    });
                }
            } else {
                chatCompletionRequestMessage.push({
                    role: 'user',
                    content: text.replace(`<@${bot_userid}>`, '') ?? ''
                });
            }
        });
    } catch (error) {
        console.error(error);
    }
});
```

`ChatCompletionRequestMessage`型が用意されているのでそれに合わせましょう。今回は会話と関係のないシステムメッセージの先頭に``system``とつけることにしました。botが送信したメッセージからそうしたものを除外して文脈に追加していきます。botが送信したメッセージかどうかはスレッド内のメッセージの`user`項目を参照して、bot自身のユーザーIDと一致するかどうかで判定しています。botのユーザーIDはSlack画面上でbotの詳細を開くと記述があります。

ChatGPTが考えてbotが送信した内容は`role`を`assistant`に、ユーザーのプロンプトは`role`を`user`としてAPIのリクエストに含めます。

以上で整形したリクエストをOpenAIのAPIに送信します。まず`OPENAI_API_KEY`環境変数を使って以下のようにセットアップします。

```ts
import {
    ChatCompletionRequestMessage,
    Configuration,
    OpenAIApi
} from 'openai';

const configuration = new Configuration({
    apiKey: process.env.OPENAI_API_KEY
});
const openai = new OpenAIApi(configuration);
```

`openai.createChatCompletion()`を使ってChat APIを呼び出します。モデルには`gpt-3.5-turbo`を指定します。Chat APIの詳しい仕様については以下を参照してください。

https://platform.openai.com/docs/api-reference/chat

Slackへの送信は`client.chat.postMessage()`を利用します。パラメータでスレッドのタイムスタンプを指定するとスレッド内での返信になります。

```ts
app.event('app_mention', async ({ event, context, client, say }) => {
    // ...
    try {
        // ...
        const completion = await openai.createChatCompletion({
            model: 'gpt-3.5-turbo',
            messages: chatCompletionRequestMessage
        });
        const outputText = completion.data.choices
            .map(({ message }) => message?.content)
            .join('');
        await client.chat.postMessage({
            channel,
            thread_ts: threadTs,
            text: outputText
        });
    } catch (error) {
        console.error(error);
    }
});
```

以下が実装したbotのソースコードの全文です。上記の内容にエラーハンドリングなどを追加しています。

```ts
import { App, AwsLambdaReceiver } from '@slack/bolt';
import {
    AwsCallback,
    AwsEvent
} from '@slack/bolt/dist/receivers/AwsLambdaReceiver';
import { isAxiosError } from 'axios';
import {
    ChatCompletionRequestMessage,
    Configuration,
    OpenAIApi
} from 'openai';

if (!process.env.SLACK_SIGNING_SECRET) process.exit(1);

const configuration = new Configuration({
    apiKey: process.env.OPENAI_API_KEY
});
const openai = new OpenAIApi(configuration);

const awsLambdaReceiver = new AwsLambdaReceiver({
    signingSecret: process.env.SLACK_SIGNING_SECRET
});
const app = new App({
    token: process.env.SLACK_BOT_TOKEN,
    receiver: awsLambdaReceiver
});

const bot_userid = '...';

app.event('app_mention', async ({ event, context, client, say }) => {
    if (context.retryNum) {
        console.log(
            `skipped retry. retryReason: ${context.retryReason}`
        );
        return;
    }
    console.log(event);
    try {
        const { channel, thread_ts, event_ts } = event;
        const threadTs = thread_ts ?? event_ts;
        await say({
            channel,
            thread_ts: threadTs,
            text: '`system` 処理中……'
        });
        try {
            const threadResponse = await client.conversations.replies({
                channel,
                ts: threadTs
            });
            const chatCompletionRequestMessage: ChatCompletionRequestMessage[] =
                [];
            threadResponse.messages?.forEach((message) => {
                const { text, user } = message;
                if (!text) return;
                if (user && user === bot_userid) {
                    if (!text.startsWith('`system`')) {
                        chatCompletionRequestMessage.push({
                            role: 'assistant',
                            content: text
                        });
                    }
                } else {
                    chatCompletionRequestMessage.push({
                        role: 'user',
                        content:
                            text.replace(`<@${bot_userid}>`, '') ?? ''
                    });
                }
            });
            const completion = await openai.createChatCompletion({
                model: 'gpt-3.5-turbo',
                messages: chatCompletionRequestMessage
            });
            const outputText = completion.data.choices
                .map(({ message }) => message?.content)
                .join('');
            await client.chat.postMessage({
                channel,
                thread_ts: threadTs,
                text: outputText
            });
        } catch (error) {
            if (isAxiosError(error)) {
                console.error(error.response?.data);
            } else {
                console.error(error);
            }
            await client.chat.postMessage({
                channel,
                thread_ts: threadTs,
                text: '`system` エラーが発生しました。(管理人: <@...>)'
            });
        }
    } catch (error) {
        console.error(error);
    }
});

module.exports.handler = async (
    event: AwsEvent,
    context: unknown,
    callback: AwsCallback
) => {
    const handler = await awsLambdaReceiver.start();
    return handler(event, context, callback);
};

```

実装が完了したら再びzipファイルを作成してLambda関数にアップロードします。botにメンションするとChatGPTから回答が返ってくるようになっているはずです。

## 運用

弊社では`#free_chatgpt`というチャンネルを作ってそこでメンバーにChatGPTと会話してもらう運用にしました。他の人のプロンプトや回答を参考することができて良いと思います。このチャンネルへの参加の呼びかけもChatGPTに作成してもらいました。便利ですね。

## まとめ

この記事ではSlack Bolt、OpenAI SDKやAWS Lambdaを活用してスピーディにChatGPTと会話するSlack botを実装する方法をご紹介しました。実際に運用してみると、Slackでメンションするだけで気になったことをすぐに質問できる点がとても便利に感じます。また社内でもこれを機にChatGPTに初めて触れてみたメンバーもいました。ChatGPTの画面にアクセスしてログインする手間を削減したことで利用へのハードルを下げられたように感じます。

## 参考文献

[Getting started with Bolt for JavaScript - Slack | Bolt for JavaScript](https://slack.dev/bolt-js/tutorial/getting-started)

[Deploying to AWS Lambda - Slack | Bolt for JavaScript](https://slack.dev/bolt-js/deployments/aws-lambda)

[Chat API Reference - OpenAI API](https://platform.openai.com/docs/api-reference/chat)