ChatGPTを使ったSlack Botを作ってみました。
流行りのChatGPT APIについては割と素朴で普通に使えたので、残念ながら(?)あまり特筆するような事がありませんでした。ですが、Slackとの連携で色々と詰まったのでポイントをメモしておきます。
Slack Botの様々な種類から使う技術を決める
まずSlack Botを開発する方法は結構色々あり、物によってはDeprecatedだったりもするようです。選定する前にどういう要件か、そのために何の技術を使うか、はっきりさせておく必要があります。
今回は
- 通常の投稿に対して反応させたい
- Slash Commandなど特殊な使用方法は、覚えるのも入力するのも避けたい
- 指定のchannelに投稿があったら(ChatGPTで)投稿したい
- 裏側で待ち受けるサーバは用意できるが、常時起動はしない(サーバレスである)
という事にしたので、Slack Events APIを使うことにしました。
使う技術が決まれば、ドキュメントを読んだり、ググったり、ChatGPTに聞くなりして開発が進められますが、最初にここが定まっていないと不必要な情報の要否を判断しにくくなり、簡単な開発であっても生産性が落ちます。
今回の構成
- 人間の投稿をSlack Events APIでSubscribe
- サーバにはCloud Runを利用
- サーバからBotで投稿するにはIncoming Webhookを利用
サーバサイドアプリケーションの開発
Cloud RunのサーバにはRuby on RailsでAPIを開発しました。
直近数回の会話を保持してChatGPTに与えたかったので会話履歴をDBに保存するようなアプリケーションにしました。SQLiteを本番環境コンテナ内に入れて、起動スクリプトで毎回db:create
するようにしてデプロイしました。※良い子は真似しないでね
Cloud Runはmax-instances=1
にしました。Cloud Runがスケールインで寝るまでの間は会話のキャッチボールが続きます。
今回ChatGPTのクライアントライブラリなどは使わず、HTTPリクエストを以下のような感じで組み立てました。
prompt_messages = [{ role: "system", content: SYSTEM_PROMPT }]
messages.each do |message|
role = message.message_type == "ai" ? "assistant" : "user"
prompt_messages.push(
{ role: role, content: message.content }
)
end
request.body = JSON.dump({
model: "gpt-3.5-turbo",
messages: prompt_messages,
})
予めMessage.order(created_at: :desc).limit(10)
とかで適当にmessages
を取り出しておいて、こんな感じで会話履歴を順番に組み立ててChatGPTに渡しました。実際にはChatGPTに渡せるトークン数に限りがあるので文字数を数えて上限まで過去の会話を渡すのが良いと思います。詳しくはOpenAI APIを参考にしてください。
Botの投稿に反応してしまい無限ループする
Slackアプリを追加したChannelに投稿があると、Slack Events APIからPOSTリクエストがあります。これは人間の投稿も、Botの投稿も、「○○さんがに参加しました。」といったシステムメッセージも全部イベントとして発火します。
人間とBotの会話が交互に続くようにしたいのですが、何もしてないとBotの投稿に対してBotが反応してしまい無限ループします。
無限ループを防ぐには、Botからの投稿を見分ける必要があります。Slack Events APIからのPOSTリクエストにはsubtype
というフィールドがあります。ここがbot_message
となっているのものはBotからの投稿なので、これをキャンセルします。
通常の人間の投稿にはsubtype
が設定されないという仕様だったようなので、結局subtype
キーが有ったらキャンセルするようにしました。
Events APIがリトライされて重複実行される
Slack Events APIが発火した後、3秒以内にレスが無いとリトライされる仕様のようです。正常に処理できているのに3秒以上かかっていると、リトライされて同じ投稿に何度も反応してしまいます。
今回Cloud Run起動とOpenAI APIのオーバーヘッドがあるので、3秒というのは結構ギリギリで、間に合ったり間に合わなかったりします。再現条件が不安定なので対処が遅れるポイントです。
リトライによるリクエストにはX-Slack-Retry-Num
というヘッダーが付くので、今回はこのヘッダーが付いていたらキャンセルするようにしました。これだと、何らかのエラーでレスポンスが返せなくて本当にリトライしたいような時に正常に機能しませんが、今回はそこまで厳密に対応する必要は無かったのでこれで良しとしました。
ひとつの投稿に対して1回だけ反応するようにちゃんと対応するには、受けたリクエストを保存しておいて同一のリクエストが処理中でないかチェックするような実装が必要だと思います。