はじめに
ChatGPT APIの練習として、N番煎じだが、SlackからChatGPTを呼び出すSlack Appを実装した。
Appの概要は、メンションで受け取ったメッセージをChatGPT APIにリクエストし、返答を同チャンネルに書き込む。
ふるまいの前提となるようなキャラクター、文脈も設定できるので、あなた好みのChatBotを実装しよう!
シンディ*1は Waifu Labs - Magical Anime Portraits で作成した。生成AIに頼りきっている。
Cloud Functions
https://cloud.google.com/functions?hl=ja
CPU .167 vCPU メモリ 256 MB リージョン asia-northeast1 ランタイム PHP 8.1
Slack Appをつくる
必要な権限は下記の2つ。
- app_mentions:read
- chat:write
またCloud Functionデプロイ後にFunctionのURLをEvent SubscriptionsのRequest URLに設定する。
Subscribe to bot eventsにはapp_mentionsを設定する。
Slackからのイベントを受け取るCloud Functionの実装
function execute(ServerRequestInterface $request): ResponseInterface { $params = json_decode((string)$request->getBody(), true); if ($_ENV['SLACK_VERIFY_TOKEN'] !== $params['token']) { return new Response(403, [], 'invalid token'); } // Slackの初期設定で利用するバリデーションリクエスト対応 if ($params['type'] === 'url_verification') { return new Response(200, [], $params['challenge']); } // SlackのEvents APIのタイムアウトになり、再リクエストがくることがある // 今回は再リクエストの場合はすぐに200返し、完了させ、再々リクエストがこないようにする if (isset($request->getHeader('X-Slack-Retry-Num')[0])) { return new Response(200, ['X-Slack-No-Retry' => 1], 'No need to resend.'); } if ($params['type'] === 'event_callback' && $params['event']['type'] === 'app_mention') { $client = new Client(); $message = preg_replace('/<@[A-Z0-9]+>/', '', $params['event']['text']); $chatGPTResponse = sendRequestToChatGPT($client, $message); postMessageToSlack($client, $params['event']['channel'], $chatGPTResponse); return new Response(200, [], 'OK'); } return new Response(400, ['X-Slack-No-Retry' => 1], "type:{$params['type']} This type is not supported."); }
We wait longer than 3 seconds to receive a valid response from your server.
Slackは上記のようにイベント処理を3秒でタイムアウトし、失敗とする仕様になっている。
このためChat GPTのAPIが少し時間がかかるとタイムアウトしてまい、リトライが発生する。
そうするとSlackに複数メッセージが書き込まれてしまうので、ここへの対応が必要である。
解決方法は非同期処理にしたり、色々とあるが、今回はすこし強引にリトライリクエストであれば、SlackにOKを伝えてしまうかたちにした。
// SlackのEvents APIのタイムアウトになり、再リクエストがくることがある // 今回は再リクエストの場合はすぐに200返し、完了させ、再々リクエストがこないようにする if (isset($request->getHeader('X-Slack-Retry-Num')[0])) { return new Response(200, ['X-Slack-No-Retry' => 1], 'No need to resend.'); }
より正確に行うには X-Slack-Retry-Reason
にリトライ理由が記述されているので、タイムアウトのみOKを返すべきである。
ChatGPTへ質問を送る
function sendRequestToChatGPT(Client $client, string $message): string { $url = 'https://api.openai.com/v1/chat/completions'; $systemMessage = 'ふるまいの前提となるような設定、文脈を記入する'; $payload = [ 'model' => 'gpt-3.5-turbo', 'max_tokens' => 2048, 'temperature' => 1.0, 'messages' => [ ['role' => 'system', 'content' => $systemMessage], ['role' => 'user', 'content' => $message] ] ]; $response = $client->post($url, [ 'headers' => [ 'Authorization' => 'Bearer ' . $_ENV['CHAT_GPT_API_KEY'], 'Content-Type' => 'application/json', ], 'body' => json_encode($payload) ]); $result = json_decode((string)$response->getBody(), true); return $result['choices'][0]['message']['content']; }
max_tokens
は生成されるトークンの最大数、temperature
はランダム度合いで0から2の範囲で設定する。
他のパラメータなどは下記を参照。
Slackに対するレスポンスの生成
function postMessageToSlack(Client $client, string $channel, string $message): ResponseInterface { $slackApiUrl = 'https://slack.com/api/chat.postMessage'; $options = [ 'headers' => [ 'Authorization' => 'Bearer ' . $_ENV['SLACK_API_TOKEN'], 'Content-Type' => 'application/json', ], 'body' => json_encode([ 'channel' => $channel, 'text' => $message, ]), ]; return $client->post($slackApiUrl, $options); }
Slack API経由で返答内容を送る。
SLACK_API_TOKENにはBot User OAuth Tokenを設定する。
デプロイ
gcloud functions deploy my-function \ --gen2 \ --region=asia-northeast1 \ --runtime=php81 \ --source=. \ --entry-point=execute \ --trigger-http \ --allow-unauthenticated \ --env-vars-file .env.yaml \ --timeout=600
Cloud Functionのデフォルトのタイムアウトは60秒で設定されており、ChatGPTのAPIを呼び出す関係で、60秒だと心もとないため、デプロイ時に設定する。
ChatGPTと対話しながらの実装
はじめはGASで手軽にと思いながら、ChatGPTと対話しながら、実装していた。
感覚としては60-70点くらいのコードを出してくれて、そこから修正していくかたちで実現できた。
その後、GASがリクエストのHeaderを取得できず、Slackのリトライへの対応がやりづらかったため、Cloud Functionsに移行した。
移行の際にGASのコードをCloud FunctionsのPHPで動作するようにChat GPTに書き換えてもらった。
こちらの書き換えは80点くらいで、こちらのが感覚的にはそのまま使えるコード比率が高かった。
これはChatGPTの得意不得意もあるかもしれないが、書き換えのが明確な指示だからなのかなと考えている。
最初のGASのコードも自分が適切な入力を行えば、おそらく同じレベルのコードを出力してもらうことはできただろう。
ただいきなり詳細の言語化を頑張って、一発でいいアウトプットを狙うより、
とりあえず雑な指示を送って、そこから詳細な指示を繰り返したり、自分で書き換えたりしていくのが、使い方としては正しいだろう。