本記事は2024年4月頃の実装に基づくため、公開時点で最新の状況と異なる可能性があります。
はじめに
本記事では、(N番煎じか分かりませんが)SlackからChatGPTのAPIを呼び出すBotを作成する方法について説明します。
Hono
+Cloudflare Wokers
を触ってみたい、というのが今回のBot作成の理由のため複雑な処理ではないですが、Slackのスレッド内でBotにメンションすると、スレッドの投稿をすべて収集してChatGPTに投げるという汎用的な処理になっているので、参考になれば幸いです。
準備
Hono
及びCloudflare
を初めて使用する場合は、最初にhono
とwrangler
をインストールし、 Hono
+Cloudflare Workers
のプロジェクトを作成します。
$ npm install hono
$ npm install -g wrangler
$ wrangler login
$ npm create hono@latest my-app
Ok to proceed? (y)
? Which template do you want to use? cloudflare-workers
? Do you want to install project dependencies? yes
? Which package manager do you want to use? npm
プロジェクトの作成後cd my-app && npm run deploy
とするだけで、Cloudflare Wokers
にアプリケーションがデプロイされます。
Honoの初期テンプレートでは、GET /
でHello Hono!
というテキストを返すようになっています。
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => {
return c.text('Hello Hono!')
})
export default app
デプロイ完了後に表示されるhttps://my-app.***.workers.dev
をブラウザで開き、Hello Hono!
と表示されれば、デプロイが成功していることを確認できます。
参考
実装
処理の流れ
- ユーザーがSlackでボットにメンションを送ると
app_mention
イベントが発火 - イベントをトリガーに
Cloudflare Workers
を起動 -
Workers
からSlack API
を呼び出し、メンションのあったスレッドの会話履歴を取得 - 会話履歴を
ChatGPT API
に渡して、ボットの返答を生成 - 生成された返答を、
Slack API
を通じて元のスレッドに投稿
SlackのBotを作成
- 「 https://api.slack.com/apps 」にアクセスし「Create New App」
- 「From an app manifest」から以下のマニフェストを入力
※
display_information: name: 表示名 features: bot_user: display_name: Bot名 always_online: true oauth_config: scopes: bot: - app_mentions:read - chat:write - groups:history - channels:history settings: event_subscriptions: request_url: https://my-app.***.workers.dev bot_events: - app_mention org_deploy_enabled: false socket_mode_enabled: false token_rotation_enabled: false
display_information.name
、features.bot_user.display_name
は任意の名称を、settings.event_subscriptions.request_url
は先ほどデプロイしたプロジェクトのURLを設定します。 - 作成後「Install App」からワークスペースにインストールし、
xoxb-
から始まるBot User OAuth Token
を記録
Cloudflare Workersの処理を作成
- 処理の実装
コードの全文を記載し、要点のみを説明しますindex.tsimport { Hono } from "hono"; type Bindings = { SLACK_BOT_USER_OAUTH_TOKEN: string; OPENAI_API_SECRET_KEY: string; OPENAI_API_ENDPOINT: string; PROMPT: string; }; const app = new Hono<{ Bindings: Bindings }>(); interface SlackMessage { type: string; user: string; text: string; thread_ts: string; reply_count?: number; subscribed?: boolean; last_read?: string; unread_count?: number; ts: string; parent_user_id?: string; } interface SlackConversationsRepliesResponse { messages: SlackMessage[]; has_more: boolean; ok: boolean; response_metadata: { next_cursor: string; }; } interface AiChatCompletionsResponse { id: string; model: string; created: number; usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number; }; object: string; choices: { index: number; finish_reason: string; message: { role: string; content: string; }; delta: { role: string; content: string; }; }[]; } app.post("/", async (c) => { const req = await c.req.json(); if (req.type === "url_verification") { return c.text(req.challenge); } if (req.event.type === "app_mention") { // slack info const token: string = c.env.SLACK_BOT_USER_OAUTH_TOKEN; const channelId: string = req.event.channel; const threadTs: string = req.event.thread_ts || req.event.ts; const botUser: string = req.event.text.match(/<@([A-Z0-9]+)>/)[1]; // openai info const apiKey: string = c.env.OPENAI_API_SECRET_KEY; const endPoint: string = c.env.OPENAI_API_ENDPOINT; const prompt: string = c.env.PROMPT; // main logic c.executionCtx.waitUntil( main(token, channelId, threadTs, botUser, apiKey, endPoint, prompt), ); return c.text("ok"); } }); export default app; async function main( token: string, channelId: string, threadTs: string, botUser: string, apiKey: string, endPoint: string, prompt: string, ): Promise<void> { const messages = await slackConversationsReplies(token, channelId, threadTs); const messageText: string = messages .filter( (message) => message.user !== botUser && !message.text.includes(botUser), ) .sort((a, b) => a.ts.localeCompare(b.ts)) .map((message) => message.text) .join(","); const resultText = await aiChatCompletions( apiKey, endPoint, messageText, prompt, ); await slackChatPostMessage(token, channelId, threadTs, resultText); } async function slackChatPostMessage( token: string, channelId: string, threadTs: string, text: string, ) { const url = "https://slack.com/api/chat.postMessage"; const params = { channel: channelId, thread_ts: threadTs, text: text, }; await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify(params), }); } async function slackConversationsReplies( token: string, channelId: string, threadTs: string, ): Promise<SlackMessage[]> { const url = `https://slack.com/api/conversations.replies?channel=${channelId}&ts=${threadTs}`; const response = await fetch(url, { method: "GET", headers: { Authorization: `Bearer ${token}`, }, }); const data: SlackConversationsRepliesResponse = await response.json(); return data.messages; } async function aiChatCompletions( apiKey: string, endPoint: string, messageText: string, prompt: string, ): Promise<string> { const params = { model: "gpt-3.5-turbo", messages: [ { role: "system", content: prompt }, { role: "user", content: messageText }, ], }; const response = await fetch(`${endPoint}/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify(params), }); const data: AiChatCompletionsResponse = await response.json(); return data.choices[0].message.content; }
- 環境変数の読み込み
Slackの
type Bindings = { SLACK_BOT_USER_OAUTH_TOKEN: string; OPENAI_API_SECRET_KEY: string; OPENAI_API_ENDPOINT: string; PROMPT: string; }; const app = new Hono<{ Bindings: Bindings }>(); ~ // slack info const token: string = c.env.SLACK_BOT_USER_OAUTH_TOKEN; ~ // openai info const apiKey: string = c.env.OPENAI_API_SECRET_KEY; const endPoint: string = c.env.OPENAI_API_ENDPOINT; const prompt: string = c.env.PROMPT;
Bot User OAuth Token
、ChatGPTのAPIキー、APIエンドポイント(後述)、ChatGPTのAPIで実行するプロンプトを環境変数から取得します。
各種トークンやAPIキーなどの機密情報は、Cloudflare Workers
の機能で暗号化して保存することができます(後述)。 - Botがメンションされたスレッドのメッセージを取得する
スレッドのメッセージ一覧は
const channelId: string = req.event.channel; const threadTs: string = req.event.thread_ts || req.event.ts; ~ async function slackConversationsReplies( token: string, channelId: string, threadTs: string, ): Promise<SlackMessage[]> { const url = `https://slack.com/api/conversations.replies?channel=${channelId}&ts=${threadTs}`; const response = await fetch(url, { method: "GET", headers: { Authorization: `Bearer ${token}`, }, }); const data: SlackConversationsRepliesResponse = await response.json(); return data.messages; }
GET conversations.replies
で取得できます。
スレッドを特定するために必要なパラメータであるchannel
とts
は、app_mention
が呼び出された際のリクエストボディから取得します。メッセージの一覧をChatGPTへ問い合わせる文字列に変換するため、Botを呼び出す際のメッセージやBot自身の返答メッセージを除き、タイムスタンプ順に並べてconst botUser: string = req.event.text.match(/<@([A-Z0-9]+)>/)[1]; ~ const messages = await slackConversationsReplies(token, channelId, threadTs); const messageText: string = messages .filter( (message) => message.user !== botUser && !message.text.includes(botUser), ) .sort((a, b) => a.ts.localeCompare(b.ts)) .map((message) => message.text) .join(",");
,
で結合しています。 - ChatGPTの処理を非同期で実行する
大きく詰まったポイントとして、Slackの
// main logic c.executionCtx.waitUntil( main(token, channelId, threadTs, botUser, apiKey, endPoint, prompt), ); return c.text("ok");
Events API
における3秒ルールというものがあります。
これは、「We wait longer than 3 seconds to receive a valid response from your server.」という記載の通り、Botの呼び出しから3秒以内に200レスポンスを返さなければ処理が失敗するというものです。
スレッドのテキスト量やプロンプトの内容によって、ChatGPTからのレスポンスが3秒以上かかる場合もあり、処理が成功したり失敗したりという状況でした。
そこで、実際の処理をmain()
関数にまとめて、c.executionCtx.waitUntil
で実行することで、Slackには先に200レスポンスを返しつつ時間のかかる処理を非同期で実行し、ChatGPTの処理が完了後に結果をSlackのPOST chat.postMessage
で元のスレッドに通知することができるようになりました。
- 環境変数の読み込み
参考
- https://api.slack.com/methods/conversations.replies
- Slackの会話履歴の取得をTypeScriptで実装する ~conversations.replies~
- https://api.slack.com/apis/connections/events-api#failure
環境変数と機密情報の設定
環境変数
Cloudflare Workers
では環境変数をwrangler.toml
ファイルにて設定できます。
プロジェクトの作成時に生成される以下のテンプレートの通り、[vars]
に記載するだけでデプロイ時に自動で環境変数を設定してくれます。
- # [vars]
- # MY_VAR = "my-variable"
+ [vars]
+ PROMPT = "ChatGPTのプロンプト"
機密情報
ファイルに記載すべきではないトークンやシークレットキーなどの機密情報を管理する手段として、Secrets
という機能が用意されています。
以下のようにwrangler secret put
コマンドで登録することができます。
wrangler secret put SLACK_BOT_USER_OAUTH_TOKEN
環境変数はCloudflareのコンソールの「設定→変数」から確認できますが、wrangler secret put
で登録された機密情報は「値が暗号化されました」と表示されます。
参考
AI Gateway
ChatGPTを含む、様々な生成AIサービスのAPIのエンドポイントを、AI Gatewayの提供するエンドポイントに置き換えることで、ログの記録や、リクエスト数やトークン数、コストなどの指標を可視化できるようになるほか、キャッシュ、レート制限、リクエストの再試行、フォールバックモデルの定義など様々な機能が提供されています。詳細は後述のURLを参照してください。
今回はログの参照やコストの確認などの用途でしか使用できていませんが、ChatGPTにリクエストが送信されているかの確認や、gpt-4の値段にびっくりしてgpt-3に戻すなどに役立ちました。
参考
おわりに
Hono
とCloudflare Workers
は初めて触りましたが非常にとっつきやすく、特にプロジェクト作成からデプロイまでが爆速で開発体験の良さを実感しました。ちょっとしたサービスやシステムを作る際の選択肢として、この組み合わせは今後も積極的に検討していきたいと思います。
CloudflareにはWorkers以外にも面白そうなサービスがいくつもあるので、活用できそうなアイデアが浮かんだらまた触ってみたいです。