はじめに
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のコードも自分が適切な入力を行えば、おそらく同じレベルのコードを出力してもらうことはできただろう。
ただいきなり詳細の言語化を頑張って、一発でいいアウトプットを狙うより、
とりあえず雑な指示を送って、そこから詳細な指示を繰り返したり、自分で書き換えたりしていくのが、使い方としては正しいだろう。