はじめに
執筆時点でつい最近公開されたChatGPTを流行りに乗って試してみた所、その品質に驚かされ、会社のSlackにチャットボットとして組み込んでみたいと思いつきました。
ただ、ChatGPTをAPI経由で利用する方法は発見できず、代わりに同時期リリースされたtext-davinci-003というモデルも高性能(実際触ってみても、チャットボットへ問い合わせての単一の応答としては、ChatGPTと遜色ありません)であるという事で[1]、代わりにこちらのモデルで応答してくれるチャットボットを導入しました。
普段はあまりこういった分野には触れないズブの素人なのですが、備忘録として残しておきます。
導入するにあたり、複数のページの内容を参考にし、何とかいい具合に組み合わせる形で導入したので、参考にしたページは本記事末尾の参考文献のセクションに記載しております。
特に、ベースとしては参考文献[2]が非常にまとまっており明快であったため、こちらのやり方を軸としつつ適宜必要な処理を追加しています。
2023/3/12追記: 正式にChatGPT同様対話が可能なモデル(gpt-3.5-turbo)を利用できるAPIが公開されたため、これを使用して対話を行えるBotを実装するためのGAS用のスクリプトコードを追記しました。
参考文献[2]:
【雑学】SlackとGASで自由度の高いSlackのチャットBotを作る
https://tektektech.com/slack-auto-reply-bot-with-gas/
SlackのAppを作成・設定する
SlackのAppを作成するため、はじめに以下のリンク先にアクセスします。
https://api.slack.com/apps
遷移先において、右上の「Create New App」ボタンをクリックします。
以下の画像のような表示が出現したら、「From Scratch」の方を選択します。
App(チャットボット)の表示名とインストール先ワークスペースを設定します(今回、既にClifffordというアプリを既に実験で作ったため2ndにしています)。
左サイドバーの「Basic Information」をクリック後、ページ内にある以下の部分から、好みに応じてアイコンや背景色、説明を変更しておきます(任意)。
同じくBasic Informationの画面から、以下の部分の「Incoming Webhooks」をクリックします。
遷移先にて、右上のトグルをクリックし、Incoming WebhooksをOnにします。
その後、画面の下の方にある以下の部分の「Add New Webhook to Workspace」をクリックします。
投稿先チャンネルを設定し、Appによるアクセスを許可します。
すると、Webhook URLの部分に追加されていますので、このWebhook URLをCopyにて控えておきます。
OpenAIのAPI Keyの取得
次に、要となるtext-davinci-003を提供しているOpenAIのAPI Keyを取得するため、以下のリンク先にアクセスします。
https://beta.openai.com/overview
ちなみに、アカウントを作成すると執筆時点で18ドルのクレジットが付与されます。今回APIで使用するモデルはLanguage ModelのDavinciに分類されますので、1000トークン(トークンはおおよそ単語みたいなものです)あたり0.02ドル消費します。
https://openai.com/api/pricing/
ユーザを作成しログインしたら、画面右上のアイコン(下の画像では黒塗りにしています)をクリックし、「View API keys」をクリックします。
遷移先画面で、「+ Create new secret key」をクリックします。筆者は既に作成してあったため、既にSECRET KEYが存在しますが、初回の場合は勿論存在しません。
API Keyが発行され表示されるので、コピーボタンでコピーし控えておきます。
GAS(Google Apps Script)で実行定義を記述する
今回、チャットボットの挙動はGAS(Google Apps Script)にて定義します。そこで、まずは以下のリンクからGASにアクセスしてください。
https://script.google.com/home
画面左上の「新しいプロジェクト」をクリックします(筆者の場合、既に他のプロジェクトを作成済みであるため「無題のプロジェクト」が存在していますが、初回であれば空となっています)。
以下のような開発画面に遷移しますので、必要であれば任意でプロジェクト名を変更後、
以下のコードをコード部分にコピペしてください(簡単のため、text-davinci-003をChatGPTとしてしまっている部分があります)。
function doPost(e)
{
// ユーザIDの文字列長
var userIdLength = 11;
// 疎通確認用
var params = JSON.parse(e.postData.getDataAsString());
if('challenge' in params)
{
return ContentService.createTextOutput(params.challenge);
}
// 再送無視
const eventId = params.event_id;
const cache = CacheService.getScriptCache();
const cached = cache.get(eventId);
if (cached)
{
Logger.log(`Already processed. Skip. (eventId: ${eventId})`);
return;
}
cache.put(eventId, true, 60 * 10) ;
// Botによるメンションは無視
if('subtype' in params.event) {
return;
}
userName = params.event.user;
if('text' in params.event)
{
var textTmp = params.event.text.substr(userIdLength + 3, params.event.text.length);
var resultStr = getChatGptMessage(textTmp);
if(typeof resultStr === "undefined")
{
return;
}
var contents = `<@${userName}> ${resultStr}`;
}
else
{
var resultStr = getChatGptMessage('ChatGPTについて教えてください。') ;
if(typeof resultStr === "undefined")
{
return;
}
var contents = `<@${userName}> ${resultStr}`;
}
var options =
{
"method" : "post",
"contentType" : "application/json",
"payload" : JSON.stringify(
{
"text" : contents,
link_names: 1
}
)
};
UrlFetchApp.fetch("[WEBHOOK_URL_HERE]", options);
}
// ChatGPTのAPIを呼び出し応答を取得する
function getChatGptMessage(message) {
var uri = 'https://api.openai.com/v1/completions';
var headers = {
'Authorization': 'Bearer [OPENAI_API_KEY_HERE]',
'Content-type': 'application/json',
'X-Slack-No-Retry': 1
};
var options = {
'muteHttpExceptions' : true,
'headers': headers,
'method': 'POST',
'payload': JSON.stringify({
"model": "text-davinci-003",
"max_tokens" : 1024,
"prompt": message})
};
try {
const response = UrlFetchApp.fetch(uri, options);
var json=JSON.parse(response.getContentText());
return json["choices"][0]["text"];
} catch(e) {
console.log('error');
}
}
コードについての話
こちらのコードは、参考文献[3], [4], [5]のサイトにおけるコードを適宜引用しつつ組み上げています。
// 疎通確認用
の部分は、これを追加しないと、後述のEvent SubscriptionにおけるURL登録にて検証に失敗する(Challengeリクエストに対応できない)ため、それを解決するために定義しています。
また、Slack Eventsには3秒以内にレスポンスが返って来ないと、タイムアウトによる再送処理を行う仕様があります。
しかし、text-davinci-003モデルのAPIは混み合っているのもあり、3秒以内に返ってこない事もままあります。
こうなると、OpenAIへの疎通自体は出来ているのに再送処理が重なり、結果的に1メンションに対して複数回返答をつけてくる、といった挙動になってしまいます。これでは、視覚的にも冗長ですし、何しろ完全にAPI利用料の無駄遣いです。
(↑再送処理のため、1つの問い合わせに対して4回返答が来てしまっている例)
これに対応するには、ヘッダにX-Slack-Retry-Num
というものが存在すれば処理をスキップする、といったやり方が通常は取れるのですが、GASではdoPostへのリクエストのヘッダの中身を見ることが出来ないので、この手段は採用できません。
代わりに、参考文献[3]より引用し(上記コードの// 再送無視
の部分)、EventId
のキャッシュを取り、それによって再送時には処理をスキップするようにしています。
話は戻りますが、前述のコードの内、[WEBHOOK_URL_HERE]
は前述のIncoming WebhooksのWebhook URLで、[OPENAI_API_KEY_HERE]
部分は前述のOpenAIのAPI Keyで置き換えてください。
また、text-davinci-003にプロンプトを投げる際に、そのままではチャットボットへのメンションに含まれる、チャットボットのユーザIDが含まれてしまいます。
このメンションのIDですが、プレーンテキストでは<@username>
のような形で表現されるため、ユーザIDの文字列長と、<, @, >
の3文字足した分の長さだけメッセージの先頭からsubstr
関数で削ってしまえば、非常に頭の悪いやり方ですがプロンプトから排除できます。
さらに、このユーザIDは表示上のものではなく、内部のIDで表現されています。この内部のユーザIDは、以下のように確認します。
まず、Slackデスクトップアプリの左サイドバーの「App」部分にて、目的のアプリの部分で右クリックし、ポップアップから「アプリの詳細を表示する」を選択します。
すると、以下のような画面が出るので、「メンバーID」の方をコピーします。
この内部ユーザIDの長さは、上述のコードの以下の部分:
// ユーザIDの文字列長
var userIdLength = 11;
で設定しているので、もし11文字でないようであれば適宜変更してください。
(どう考えてもメンションしてきたユーザの内部IDをリクエストから取るより良い方法はあるとは思いますが…)
上記のコードでは記述していませんが、複数チャンネル分Webhook URLを発行し、問い合わせが行われたチャンネルに返答するように動作を定義する事も出来ます。
この動作定義の方法については、すぐ後のChatGPT APIを使用した実装例の項目でご紹介します。
(追記)ChatGPT APIを使用する場合の実装例
直前までのやり取りも回答に反映される完全な対話が可能なChatGPTモデル(gpt-3.5-turbo)のAPIを使用し、ChatGPT同様完全に対話可能なBotをSlackに組み込む場合は、前述のコードの代わりに以下のコードをGAS上で記述します:
// chat用のCache作成関数
// 出典:https://qiita.com/golyat/items/ba5d9ce38ec3308d3757
function makeCache() {
const cache = CacheService.getScriptCache();
return {
get: function(key) {
return JSON.parse(cache.get(key));
},
put: function(key, value, sec) {
//デフォルトでは10分間(600秒)保存される。最大値は6時間(21600秒)
cache.put(key, JSON.stringify(value), (sec === undefined) ? 7200 : sec);
return value;
},
remove: function(key) {
cache.remove(key);
}
};
}
// slackに投稿する
function doPost(e) {
// 疎通確認
var params = JSON.parse(e.postData.getDataAsString());
if('challenge' in params){
return ContentService.createTextOutput(params.challenge);
}
// 再送無視
const eventId = params.event_id
const cache = CacheService.getScriptCache()
const cached = cache.get(eventId)
if (cached)
{
Logger.log(`すでに処理したイベントなのでスルーします。(eventId: ${eventId})`);
return;
}
cache.put(eventId, true, 60 * 10) ;
// Botの投稿に反応しないように修正
if('subtype' in params.event) {
return;
}
userName = params.event.user;
userIdLength = 11;
channelId = params.event.channel;
// cacheを作成
const gCache = makeCache();
transactionArray = [];
transTmp = gCache.get(channelId);
// cacheがあればそれを丸ごと代入
if(transTmp !== null)
{
transactionArray = transTmp;
}
// 履歴が往復5セットに達したら先頭を削る(利用料の問題)
if(transactionArray.length >= 10)
{
transactionArray.shift();
transactionArray.shift();
}
if('text' in params.event) {
// 実際に表示されるメッセージ内容
var textTmp = params.event.text.substr(userIdLength + 3, params.event.text.length);
if(textTmp.includes("履歴消去") || textTmp.includes("hflush")) {
gCache.remove(channelId);
var contents = `<@${userName}> チャット履歴を消去しました。`;
}
else {
// やり取りの履歴にリクエストを追加
transactionArray.push({"role": "user", "content": textTmp});
var resultStr = getChatGptMessage(transactionArray);
if(typeof resultStr === "undefined")
{
return;
}
var contents = `<@${userName}> ${resultStr}`;
// 返答を履歴に追加
transactionArray.push({"role": "assistant", "content": resultStr});
gCache.put(channelId, transactionArray);
}
}
else
{
var resultStr = getChatGptMessage('ChatGPTについて教えてください。') ;
if(typeof resultStr === "undefined")
{
return;
}
var contents = `<@${userName}> ${resultStr}`;
}
// リクエスト内容を整形
var options =
{
"method" : "post",
"contentType" : "application/json",
"payload" : JSON.stringify(
{
"text" : contents,
link_names: 1
}
)
};
// チャンネル1(例)
if(channelId == "[CHANNEL_1_ID_HERE]")
{
UrlFetchApp.fetch("[CHANNEL_1_WEBHOOK_URL_HERE]", options);
}
// チャンネル2(例)
else if(channelId == "[CHANNEL_2_ID_HERE]")
{
UrlFetchApp.fetch("[CHANNEL_2_WEBHOOK_URL_HERE]", options);
}
}
// ChatGPTのAPIをcallしてresponseを得る
function getChatGptMessage(transactionArray) {
var uri = 'https://api.openai.com/v1/chat/completions'
var headers = {
'Authorization': 'Bearer [OPENAI_API_KEY_HERE]',
'Content-type': 'application/json',
'X-Slack-No-Retry': 1
};
var options = {
'muteHttpExceptions' : true,
'headers': headers,
'method': 'POST',
'payload': JSON.stringify({
"model": "gpt-3.5-turbo",
"max_tokens" : 1024,
//"messages": transactionArray.push({"role": "system", "content": "日本のおじいさんのような口調で話してください"})
"messages" : transactionArray,
})
};
try {
const response = UrlFetchApp.fetch(uri, options);
var json=JSON.parse(response.getContentText());
return json["choices"][0]["message"]['content'];
} catch(e) {
console.log('error');
}
}
上記のmakeCache()関数については、参考文献[6]に記載されているコードをそのまま使用させていただいております。
こちらの実装例では、問い合わせの発生したチャンネルのチャンネルIDを取得し、これを用いた条件分岐で問い合わせの発生したチャンネルのWebhook URLに投げる(返答する)実装を行っています。
ここでは、「チャンネル1」と「チャンネル2」という2つのSlackチャンネルからの問い合わせからチャンネルを識別し、問い合わせされた正しいチャンネルにおいて返信する例を取っています。必要に応じてチャンネル数を増やしたり、反対にtext-davinci-003の例の時のように単一チャンネルで問題ない場合は適宜省いてください。
[OPENAI_API_KEY_HERE]
部分にはOpenAIのAPIキーを、[CHANNEL_1_ID_HERE]
と[CHANNEL_2_ID_HERE]
の部分にはそれぞれチャンネル1とチャンネル2のチャンネルIDを、[CHANNEL_1_WEBHOOK_URL_HERE]
と[CHANNEL_2_WEBHOOK_URL_HERE]
の部分にはそれぞれチャンネル1とチャンネル2のIncoming WebhookのWebhook URLを入れてください。
Webhook条件分岐用のチャンネルIDを知るためには、まずSlackの左サイドバーのチャンネル一覧からIDを知りたいチャンネルを右クリックし、「チャンネル詳細を表示する」をクリックします。
その後、出てくるポップアップの最下部の「チャンネルID:」の部分にそのチャンネルのチャンネルIDが表示されているので、これを控えて上述のコード内で使用します。
ChatGPTのAPIでは、(最新の)質問を送信する際に、過去の質問と応答の履歴を一緒に送信する事で、その履歴を参照しながらもっともらしい対話らしくなるような返答を返してくる、という挙動をしてくれます。
この履歴を保存するために、前述のEventId
を用いた再送阻止処理でも使用した、GASのCache Serviceを使用しています。
上記コード例では、チャンネルIDごとに対話の最終更新から2時間まで履歴を保持するようにしています。また、対話履歴の文字列に対してもAPI利用料が取られてしまいますので、あまり履歴サイズが膨らまないよう、最新の5往復分のやり取りだけ保持し、古い順から履歴を逐次削除しています。
また、この履歴は「履歴削除」あるいは「hflush」を含むメッセージをこのBotに送ると、ユーザ側から即時削除できる実装にしています。
ちなみに、上のコード例の状態ではAIの性格設定を行っていない状態ですが、
//"messages": transactionArray.push({"role": "system", "content": "日本のおじいさんのような口調で話してください"})
の部分をアンコメントして「日本のおじいさん〜」の部分を好きなものに書き換えれば、AIの性格を設定する事も可能です。但し、その際は1回のやり取りにつき、質問・返答・性格設定の3つが履歴リストに追加される状態になりますので、履歴上限処理の部分である
// 履歴が往復5セットに達したら先頭を削る(利用料の問題)
if(transactionArray.length >= 10)
{
transactionArray.shift();
transactionArray.shift();
}
を
// 履歴が往復5セットに達したら先頭を削る(利用料の問題)
if(transactionArray.length >= 15)
{
transactionArray.shift();
transactionArray.shift();
transactionArray.shift();
}
のようにすると、応答の品質を損なう心配を手っ取り早く取り除けます(勿論、性格設定は質問の度に送信する必要がないため、一度きりで済むように修正するのが最善ですが、ここでは省きます)。
この実行定義により、Slackからリクエストを受け取った後、text-davinci-003のAPIに問い合わせて返答をもらい、Incoming Webhooks経由でSlackのチャンネルにてメンション付きで返信する動作を設定しています。
コードの設定が完了したら、GASの右上の「デプロイ」ボタンをクリックし、「新しいデプロイ」を選択します。
その後、種類の選択の右側の歯車アイコンをクリックし、「ウェブアプリ」を選択します。
出てきた表示において、「次のユーザーとして実行」部分を「自分」、「アクセスできるユーザー」を「全員」にしてください。
Slackからのアクセスが発生するため、アクセス可能ユーザを全員に設定しないと、リクエストが弾かれてしまいます。
データへのアクセスの承認が求められるので、以下の画像のような流れで承認してください。
最終的に、デプロイが完了しますので、ウェブアプリのURLの方をコピーし控えておきます。
このURLは、知っている人間であれば誰でもアクセスできてしまいますので、決して第三者に知られないようにしてください。
Slack AppからGASを呼び出せるようにする
最後に、Slack Eventsにより、チャットボットへのメンション発生時にGASへリクエストを飛ばすように紐付けます。
先程のSlack Appの設定画面(左サイドバーにBasic Informationなどがある画面です)の左サイドバーから、「Event Subscription」をクリックし、右上のトグルをクリックしてEventsを有効化します。
Request URLの欄に、先程GASでデプロイ完了した際に控えたウェブアプリURLをペーストします。
その少し下にある「Subscribe to bot events」をクリックし、展開後出てくる「Add Bot User Event」ボタンをクリックします。
以下の画像のように、「app_mentions:read」の「app_mension」を選択します。
同時に、以下のように黄色の帯で警告が出た場合、メッセージ中の「reinstall your app」をクリックし、Appの再インストールを行ってください。
Incoming Webhooksの追加時のアクセス許可画面と同じものが出てきますので、同じように承認すれば終わります。
実際の動作例
その後、アプリをアクセス許可したチャンネルに招待し、メンションで質問をしてあげると、かなり良い品質で返答が来ます。
ChatGPTと異なり、性質上前の質問への返答を踏まえてさらにやり取りする、みたいな芸当は出来ませんので(一問一答で完結します)その部分は異なってきますが、それでも質問に対しアシストしてくれるチャットボットとしてはかなり優秀です。
ただし、ChatGPTと同じく、普通に間違った内容で返答してくる事もありますので、あくまでも鵜呑みにはしないようにしてください。
追記: ChatGPT APIの場合の動作例
ChatGPT APIを用いた実装の場合は、以下のように完全にChatGPTよろしく対話を行う事が出来ます。
ChatGPTのAPIの場合も勿論、当然のように間違った内容で返答してくる事がありますのでお気をつけください。
(上の動作例のスクショでは、チャットボットからの2個目の返答の内容が滅茶苦茶間違っています)
参考文献
[1] ChatGPTとtext-davinci-003, https://devneko.jp/wordpress/?p=2631
[2] 【雑学】SlackとGASで自由度の高いSlackのチャットBotを作る, https://tektektech.com/slack-auto-reply-bot-with-gas/
[3] GPT-3 + GASで作るSlackbot, https://qiita.com/malleroid/items/36def200eadfce51c5f2
[4] GPT-3 を LINE チャットボットに組み込んでみた, https://dev.classmethod.jp/articles/chatgpt-line-chat-bot/
[5] Slackでスタンプを押すだけで勤怠打刻・勤怠サマリレポートしてくれる仕組みを作った, https://zenn.dev/24/articles/3176ee3a68fcb1
[6] 【GAS】GASで値を保存して次の実行時でも利用する, https://qiita.com/golyat/items/ba5d9ce38ec3308d3757