47
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-03-30

概要

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

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

botの機能

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

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

スクリーンショット 2023-03-30 11.50.55.png

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

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

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

  • 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にサインインします。

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

Account API Keys - OpenAI API

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

Billing overview - OpenAI API

Slack botの実装

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

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

まずNode.jsのプロジェクトを作成します。依存ライブラリやビルドコマンドはpackage.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の各種プラグインは依存ライブラリのバンドルに必要です。

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も作成します。以下のような内容で特別な設定は必要ありません。

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

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

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_SECRETSLACK_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にデプロイしていきましょう。まず以下のコマンドでビルドします。

npm run build

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

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を利用します。

以上の設定で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秒以内にレスポンスが返ってこないとリトライが実行される仕組みになっています。

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

返信するスレッドのタイムスタンプはevent引数のevent_tsまたはthread_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とスレッドのタイムスタンプを指定して呼び出すことで取得できます。

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に送信するパラメータを整形していきます。

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が送信した内容はroleassistantに、ユーザーのプロンプトはroleuserとしてAPIのリクエストに含めます。

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

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の詳しい仕様については以下を参照してください。

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

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のソースコードの全文です。上記の内容にエラーハンドリングなどを追加しています。

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

Deploying to AWS Lambda - Slack | Bolt for JavaScript

Chat API Reference - OpenAI API

47
36
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
47
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?