こんにちは、サーバーサイドエンジニアの竹田です。
今回は昨今話題になっているChatGPTを社内Slackで使えるようにしたいと声があり、それをSlack Botで実装したので、手順やつまづいたところなどを話していきます。
また、上記画像はGPT-4にいい感じの表現を書いてもらって、niji journey5で生成してもらいました。
リポジトリ
https://github.com/ambr-tech/chat-gpt-slack
システム構成図
構成は上記のようになっており、SlackでユーザーがBotにメンションすることでイベントが発生します。 ChatGPTへ質問を投げて回答が得られたら、ユーザーが送信したメッセージのスレッド内に返信します。
以下のような具合で動作します。(レスポンスに時間がかかるため、頑張って生成中であることを絵文字で表現してます)
実装した機能
- Botへメンションをつけて質問を投げると、ChatGPTからのレスポンスがスレッド内で返ってくる
- パブリックチャンネル、BotへのDMで利用可能
- スレッド内にある過去のBotへのメンション付きのリプライもChatGPTへ送信するようにした
- 一度送信したメッセージを編集するとレスポンスも再生成される
- setコマンドでChatGPTへのsystemロールのcontentを設定できる
- listコマンドで設定内容を確認できる
- Slackが発行するSigning Secretを利用した認証
セットアップ
Slackアプリの作成
まず、Slackのアプリの作成します。
上記のページに行き、右上にある「Create New App」をクリックします。
「From scratch」をクリックします。
「App Name」には適当な名前を入力し、「Pick a workspace to develop your app in:」にはインストール先のワークスペースを選択します。
Slackアプリの設定
OAuth & Permissions
左のサイドメニューから「OAuth & Permissions」をクリックします。
ページ中部あたりにある、Scopesを以下のように設定します。 Botがアクセスできる範囲を指定します。
- app_mentions:read
- Botがメンション付きのメッセージを読み取れる
- channels:history
- Botが追加されたパブリックチャンネルで過去のメッセージを読み取れる
- chat:write
- Botが投稿できる
- chat:write.customize
- im:history
- Botが追加されたDMで過去のメッセージを読み取れる
ページ上部にいき、「Install to Workspace」をクリックします。
「Allow」をクリックします。
インストールが完了して、「OAuth & Permissions」のページに戻ると、「OAuth Tokens for Your Workspace」にトークンが表示されます。
今回は「Bot User OAuth Token」を使って、BotからSlackへメッセージを送信するのに使用します。
App Home
BotへのDMができるように、以下の設定をする必要があります。
左のサイドメニューから「App Home」をクリックし、ページ下部あたりにある「Show Tabs」の以下の画像のように設定してください。
Slackアプリの設定は一旦ここまでにして、次はAWS上で環境を構築します。
リポジトリのクローン
リポジトリのリンク: https://github.com/ambr-tech/chat-gpt-slack
まずはリポジトリをクローンし、ワーキングディレクトリをリポジトリへ移動します。
$ git clone https://github.com/ambr-tech/chat-gpt-slack
$ cd chat-gpt-slack
このリポジトリにはAWSのインフラを作成するcdk
フォルダと、SlackでユーザーがBotへメッセージを送った時に実際に実行されるコード、app
フォルダがそれぞれあります。
$ ls -d -1 */ app/ cdk/
インフラの構築
Lambdaのビルド
まずはLambdaのビルドを行います。
LambdaはPython3.9を使っていますので、お好きなツールでバージョンを切り替えてください。
今回はpyenvを使ってバージョンを切り替えます。
$ pyenv install 3.9 $ pyenv global 3.9 $ python -V Python 3.9.16 $ make help Show help message lint-lambda Lint lambda lint-fix-lambda Lint fix lambda build Build lambda function build-ignore-lib Build lambda function, but not re-installing the libraries deploy Deploy using AWS CDK $ make build
ビルドが成功するとapp/dist/lambda.zip
が作成されます。
$ ls -1 app/dist lambda.zip
Systems ManagerのParameter Storeへキーの登録
CDKのデプロイに必要な以下のキーを、AWS Systems ManagerのParameter Storeに登録します。
- /chat-gpt-slack/OPEN_AI_API_KEY
- OpenAIのAPIリクエストに必要なAPIキー
- https://platform.openai.com/account/api-keysから発行できます
- /chat-gpt-slack/SLACK_BOT_TOKEN
- /chat-gpt-slack/SLACK_SIGNING_SECRET
- Lambdaを実行する際に、リクエストがSlackから来たのかを検証するのに必要なシークレット
- SlackAPIのサイトのサイドメニューにある
Basic Information
からAppCredentials
にあるSigning Secret
以下コマンドで登録します。
<>
で囲んでいるものをそれぞれ変えて実行してください。
aws ssm put-parameter --name "/chat-gpt-slack/OPEN_AI_API_KEY" --value <OPEN_AI_API_KEY> --type "String" aws ssm put-parameter --name "/chat-gpt-slack/SLACK_BOT_TOKEN" --value <SLACK_BOT_TOKEN> --type "String" aws ssm put-parameter --name "/chat-gpt-slack/SLACK_SIGNING_SECRET" --value <SLACK_SIGNING_SECRET> --type "String"
登録が完了するとAWSマネジメントコンソールから確認できます。
https://ap-northeast-1.console.aws.amazon.com/systems-manager/parameters
CDKを使ってデプロイ
Makefileを使ってCDKをデプロイします。
CDKを使ったデプロイが初めての場合、cd cdk && cdk bootstrap <AWS_ACCOUNT_NUMBER>/<REGION>
する必要があります。1
$ make deploy
デプロイが成功するとAWSマネジメントコンソールから確認できます。
https://ap-northeast-1.console.aws.amazon.com/cloudformation/home
該当のスタックを選択し、「Resources」タブから作成したリソースを確認できます。
SlackのEvent Subscriptionsの設定
作成されたLambdaのページに行き、「Configuration」タブからサイドメニューにある「Triggers」を押下します。
URLが表示されているので、末尾が/prod/
のものをコピーしてください。
Slack APIのページに行き、サイドメニューから「Event Subscriptions」をクリックします。
以下の画像のように、「Enable Events」をOnにし、RequestURLにLambdaからコピーしたURLを貼り付けます。
そして、「Subscribe to bot events」に以下のイベントを追加します。
- app_mention
- Botへメンションが投げられたときイベントを発火する
- message.im
- DMでメッセージが投稿されたときイベントを発行する
これらで設定は完了です。
SlackでパブリックチャンネルにBotを招待してメンションをつけてメッセージを送るとレスポンスが返ってくるようになります。
実装内容についての説明
ts, thread_tsについて
Botからユーザーへレスポンスを送る際にスレッド内で返信を送るようにしているのですが、送るのに必要なものがts
またはthread_ts
です。
ts
及びthread_ts
はチャンネルごとにユニークなタイムスタンプです。
これを使用することでスレッド内の返信が行えるようになります。
今回は以下のように順に取得しています。
まずbody_event
からthread_ts
を取得してみます。これがあれば、すでにスレッドが生成されている状態です。
ない場合、body_event.message
を取得し、あればbody_event.message.thread_ts
を取得します。
body_event.message
はDM上でユーザーがメッセージを変更した際に送信されるもので、body_event.ts
の前にbody_event.message.thread_ts
を確認します。
最後まで何も見つけられなければ、body_event.ts
を取得します。
thread_ts = body_event.get("thread_ts") if not thread_ts: message = body_event.get("message") if message: thread_ts = message.get("thread_ts") if not thread_ts: thread_ts = body_event.get("ts")
過去のリプライもChatGPTへ送信
1回切りのやりとりではなく、過去の会話内容も含めた上でChatGPTにレスポンスを生成してもらいたかったので、
Slackの「OAuth & Permissions」にchannels:history
(パブリックチャンネル内の履歴読み取り権限)とim:history
(BotとのDM内の履歴読み取り権限)を付与し、
以下のようなコードを書いて履歴を取得し、フィルタしてself.thread_messages
に詰め込んでいます。
メッセージ履歴からユーザーかを判別し、ユーザだった場合はメンションがついているかを確認し、メンションを除去した上でrole
がuser
として追加しています。
Botだった場合は、メンションを除去した上でrole
がassistant
として追加しています。
def _append_assistant_role(self, text: str) -> dict: return self.thread_messages.append( {"role": "assistant", "content": text}) def _append_user_role(self, text: str) -> dict: return self.thread_messages.append({"role": "user", "content": text}) def thread_replies(self, updated_text: str = None) -> List[Dict]: messages: list = self.client.conversations_replies( channel=self.channel, ts=self.thread_ts ).get("messages") logger.info(f'THREAD REPLIES: {messages}') for message in messages: text = message.get("text") # プログレスメッセージは無視する if text == constants.SLACK_PROGRESS_MESSAGE: continue # Botが送信したメッセージの場合 if message.get("bot_id"): text = utils.remove_mention(text) self._append_assistant_role(text) continue # ユーザがメンション指定している場合 if utils.mention_matches(text): text = utils.remove_mention(text) if text != "": self._append_user_role(text) continue # ... return self.thread_messages
BotへのDMの対応
SlackのEvent Subscriptionsの「Subscribe to bot events」にmessage.im
を追加することで、
DMでBot宛てにメッセージを送信すると反応するようになりますが、
message.im
が検知するイベントは幅広く、以下全てをLambda側でキャッチし、ChatGPTのレスポンス生成をしないようにしなければなりません。
def event_triggered_by_bot(body_event: dict) -> bool: # Botによるメッセージ送信がトリガーとなったとき bot_id = body_event.get("bot_id") if bot_id: return True subtype = body_event.get("subtype") # Botによるメッセージ変更、削除がトリガーとなったとき if subtype in [ SLACK_MESSAGE_SUB_TYPE_MESSAGE_CHANGED, # message_changed SLACK_MESSAGE_SUB_TYPE_MESSAGE_DELETED # message_deleted ]: message = body_event.get("message") if message: bot_id = message.get("bot_id") if bot_id: return True return False
- ユーザがメッセージを削除した場合
- ユーザがメッセージを削除後に、削除したメッセージがSlackBotの投稿に変わるとき
ユーザがメッセージを削除後に、削除したメッセージがSlackBotの投稿に変わるとき
上記に関しては中々トリッキーで、ユーザーがメッセージを削除したとき、以下のような画像になりますが、変わった瞬間にtombstone
なるイベントが発生します。
def event_triggered_by_user_message_delete_on_dm(body_event: dict) -> bool: subtype = body_event.get("subtype") if subtype == SLACK_MESSAGE_SUB_TYPE_MESSAGE_DELETED: # message_deleted previous_message = body_event.get("previous_message") if previous_message: client_msg_id = previous_message.get("client_msg_id") if client_msg_id: return True # メッセージが削除された後に、削除したメッセージがSlackBotの投稿に変わってイベントが発生してしまう if subtype == SLACK_MESSAGE_SUB_TYPE_MESSAGE_CHANGED: # message_changed message = body_event.get("message") if message: message_subtype = message.get("subtype") if message_subtype == SLACK_MESSAGE_SUB_TYPE_MESSAGE_TOMBSTONE: # tombstone return True return False
ユーザーがメッセージを変更したときのChatGPTのレスポンス再生成
ユーザーが送ったメッセージに何か誤字が合った場合などに、すでに送ったメッセージを編集してChatGPTからのレスポンスを再生成して欲しい際に対応できるようにしました。
まず、以下のようにユーザーがメッセージを変更したことと、変更したメッセージを取得しておいて、
user_edited_message = False # DMでユーザがメッセージを変更したとき if event_triggered_by_user_message_edit_on_dm(body_event): text = body_event.get("message").get("text") user_edited_message = True # チャンネルでユーザがメッセージを変更したとき if event_triggered_by_user_message_edit_on_channel(body_event): text = body_event.get("text") user_edited_message = True
ユーザーがメッセージを変更したことを格納する変数user_edited_message
がTrue
であれば、
ChatGPTへリクエストする際に含めるメッセージの一番後ろに追加するようにしました。
def _append_user_role(self, text: str) -> dict: return self.thread_messages.append({"role": "user", "content": text}) def thread_replies(self, updated_text: str = None) -> List[Dict]: # ... # ユーザがメッセージを変更したとき、最新のメッセージとして扱う if updated_text: self._append_user_role(updated_text) return self.thread_messages
各ユーザーでChatGPTのsystemロールのcontentの設定
元々はLambdaに定数としてsystemロールのcontentをハードコードしていましたが、ユーザーごとに設定した方がいいだろうとのことで対応しました。
データの保存先はDynamoDBにしました。
また、Slack上でBotへの問い合わせでsystemロールのcontentを設定できるようにしたかったので、set
コマンドを用意しました。
例として以下のようなコマンドが実行できます。
@bot_name set system_role_content あなたの名前はAliceです
まずユーザーがBotへメッセージを送信した時に、set
から始まるコマンドであることを確認し、2つ目の引数(system_role_content
)が設定可能なフィールドであることを確認。最後に3つ目の引数(あなたの名前はAliceです
)を設定します。
if utils.is_set_command(text): result, message = utils.validate_set_command(text) if not result: slackClient.send_text_to_channel(message) return Response.success() set_command = SetCommand(text, user_id) set_command.set_key_value() slackClient.send_text_to_channel( f"SET {set_command.key}: {set_command.value}" ) return Response.success()
また、設定内容を確認するためにlist
コマンドも確認しました。
例として以下のようなコマンドが実行できます。
@bot_name list user_config
まずユーザーがBotへメッセージを送信した時に、list
コマンドであることを確認し、2つ目の引数(user_config
)が存在しているテーブルであることを確認し、あれば内容を表示します。
elif utils.is_list_command(text): result, message = utils.validate_list_command(text) if not result: slackClient.send_text_to_channel(message) return Response.success() list_command = ListCommand(text, user_id) message = list_command.list_key_value() slackClient.send_text_to_channel(message) return Response.success()
Signing Secretを利用した認証
LambdaへリクエストされるものがSlackから来たかを認証するために、Signing Secret
を利用します。
以下の方法で認証します。
ヘッダーから
X-Slack-Request-Timestamp
とX-Slack-Signature
を取得します。- 片方がなければ無効な署名です。
X-Slack-Request-Timestamp
が現在の時間から5分より前から来ていないことを確認します- replay attack(反射攻撃)を防止するために確認しています。
- 最近のリクエストであることを確認します。
Signing Secret
を利用してv0:timestamp:body
をHMAC SHA256でハッシュ化し、そのハッシュの16進数文字列を取得します。X-Slack-Signature
と計算したハッシュが同じかを確認します。
def has_valid_signature(headers: dict, body: dict) -> bool: timestamp = headers.get("X-Slack-Request-Timestamp") signature = headers.get("X-Slack-Signature") if not timestamp or not signature: return False time_diff = int(time.time()) - int(timestamp) if time_diff > 60 * 5: return False request_body_sig = "v0=" + hmac.new( constants.SLACK_SIGNING_SECRET, f'v0:{timestamp}:{body}'.encode(), hashlib.sha256 ).hexdigest() if signature != request_body_sig: return False return True
余談
弊社でChatGPTを導入し始めたのは3月末あたりからで、その頃はgpt-3.5-turboを使っていました。
ですがウェイトリスト待ちだったgpt-4が5月頃にようやく解禁されました!
gpt-4は、gpt-3.5-turboより抜群に文脈を理解してくれますね。
解禁までおおよそ1ヶ月くらいでしたので、目安にしていただければと思います。
参考
https://pkg.go.dev/github.com/aws/aws-cdk-go/awscdk/v2
https://boto3.amazonaws.com/v1/documentation/api/latest/index.html
https://qiita.com/melty_go/items/a6929b0a341e75d24f01
https://dev.classmethod.jp/articles/slack-chat-gpt-bot/
https://cly7796.net/blog/other/validate-requests-from-slack-using-signing-secret/
https://api.slack.com/authentication/verifying-requests-from-slack
https://e-words.jp/w/%E5%8F%8D%E5%B0%84%E6%94%BB%E6%92%83.html