GPTのAPIを使って何にでも応答するSlackボットをGASで作る方法【改】(スレッドの文脈考慮機能付き)

# 何の記事か
以前、[ChatGPT APIを使ってSlackボットを作る方法の記事](https://qiita.com/noritsune/items/c4d58bc933198cfa101e)を書きました。
その記事を書いた時点からだいぶ改善を行ったのでそれを踏まえたより良いボット手順を解説する記事です。

# 今回作成するボットが持つ機能
- メンションされたらそのスレッドに返答を行う
  - ボットとの会話開始を意味します
- ボットの返答が存在するスレッドにユーザーからのメッセージが投稿されたら返答を行う
  - スレッド内ではメンションが不要なので入力の手間が省けます
  - スレッド内のメッセージを文脈として認識します
  - 他のユーザーをメンションした場合はボットに話しかけているとみなさずに返答を行いません
- ボットとのDMに投稿されたメッセージには必ず返答する
  - メンションは不要です

※口調がサーファーなのはGPTのAPIにそのようにリクエストしているためです。この記事では解説しません
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/9d8cffa9-5093-ca47-4e0b-389ae36f1626.png)

# 料金
GPTのAPIの利用料金はGTP-3.5の場合、質問と返答の1000トークン(日本語なら数文字で1トークン)あたり0.002ドルです。
これはかなり安く、個人でちょこちょこ使うくらいなら1ドルに届くことはなかなか無いと思います。
実際、ソースコードのような長い文章を返答してもらう使い方を100回以上繰り返してやっと1ドルに届きました。

# 作成手順
## 1. GPTのAPIを使用するための準備をする
1. OpenAIダッシュボードにログインする
1. [OpenAIのダッシュボードのAPIキーページ](https://beta.openai.com/account/api-keys)でAPIキーを発行して控えておく
1. 支払い方法を登録する
    - OpenAIに最近サインアップしたアカウントなら$18分の無料枠があるので不要です
1. OpenAIのダッシュボードの使用制限ページ で料金上限を設定する
    - 支払い方法を登録していないなら不要です
    - ひとまずこの様に設定しました
    - ハードリミットがあるので安心して使えます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/d2593512-3e67-8f61-0353-25a6428f673b.png)

## 2. Slackアプリを作る
1. [Slackの自作アプリ一覧ページ](https://api.slack.com/apps/)からアプリを新規作成する
    - アプリ名は「AnswerAnythingBot」としました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/c3286234-8152-a625-a4f9-5edb39148800.png)
1. 作成したSlackアプリのダッシュボードを開く
1. 「OAuth & Permissions」タブでBot Token Scopeを設定する
    - 以下を設定しました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/e5483ac4-8ff7-21de-a96e-b0f92b09d874.png)
1. 「App Home」タブでボットにDMを送れるように設定する
  - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/34e2a221-b758-3d8d-155a-2623ca36b2b8.png)
1. 「Install App」タブからワークスペースにSlackアプリをインストールする
1. 「Install App」タブからSlackアプリのアクセストークンを控えておく
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/4e1a794f-e5bb-cc6f-51d4-9e64f1946546.png)

## 3. GASでSlackアプリのバックエンドを作る
### 3.1 工夫した点
今回作る機能を実現するためにはSlackからのメッセージイベントに対して単純応答するのではなく、そこそこ工夫が必要です。
それぞれの工夫した点は次のとおりです。

#### 3.1.1 メッセージ送信以外のイベントを無視する
以降の手順でSlackのメッセージ系のイベントを購読するとメッセージ送信以外のイベントでもリクエストが発生します。
これらのイベントをそのまま処理しようとすると必要な情報が足りず、エラーを起こしてしまいます。
そのため、メッセージ送信以外のイベントは無視する必要があります。

例えば、以下のイベントがあります
- メッセージ削除
- メッセージ編集
- メンバーがチャンネルに入った/抜けた

イベントの種類はリクエストボディの`event.subType`フィールドから判別可能です。
以下のコードで判定することができます。
``` js
reqObj.event.subtype !== undefined
```

#### 3.1.2 ボットによるメッセージ送信は無視する
ボットによるメッセージ送信を無視しないと無限ループが発生することがあります。スレッド内のメッセージにはメンションなしでも応答するよう機能をつけるためです。
そのため、ボットによるメッセージ送信には応答しないようにする必要があります。

以下のコードで判定することができます。
``` js
reqObj.event.user === BOT_USER_ID
```

#### 3.1.3 ボットへ話しかけている会話の文脈を取得する
このボットとの会話のトリガーとその文脈は以下のとおりです
- ボットとのDMにメッセージを送信した　
  - スレッド外なら文脈はそのメッセージのみ
  - スレッド内なら文脈はスレッドのすべてのメッセージ
- スレッド外でボットをメンションした
  - 文脈はそのメッセージのみ
- ボットが会話に参加しているスレッドにメンション無しでメッセージを送信した
  - 文脈はスレッドのすべてのメッセージ

また、これらのケースに該当しない場合は文脈なしとして判定し、無視する必要があります。

送信されたメッセージがスレッド内のものかどうかはメッセージオブジェクトの`thread_ts`フィールドの有無で判定可能です

スレッド内のメッセージはSlack APIで取得することができます。
今回は自分で作ったGASライブラリのメソッドを使用しました。
ライブラリIDは`1O20VxEbcHIYIrrpe_HeqkiaAXNEjIKcTKe3rLl2r_1KJ6GQ_Ib-xkGJG`です。

以上の各ケースにおける文脈を取得する関数は以下のとおりです。(かなり複雑になってしまったのでリファクタ案を募集中)
``` js
/**
 * @returns {object[]}
 */
function fetchSlackMsgsAskedToBot(triggerMsg) {
  const isInThread = triggerMsg.thread_ts;
  const isMenthionedBot = triggerMsg.text.includes(BOT_USER_ID);
  if (!isInThread) {
    if (triggerMsg.channel === BOT_DM_CHANNEL_ID) {
      // ボットとのDMでの会話なら毎回ボットへの問いかけて間違いない
      console.log("ボットとのDMでのスレッド外のメッセージなので応答します");
      return [triggerMsg];
    }

    if (isMenthionedBot) {
      // スレッド外でメンションしたならそのメッセージからボットとの会話が始まっているはず
      console.log("スレッド外でボットに話しかけているので応答します");
      return [triggerMsg];
    } else {
      // スレッド外でメンションも無いならボットに無関係なメッセージ
      console.log("スレッド外でボットに話しかけていないので無視します");
      return [];
    }
  } else {
    const isMentionedNonBot = !isMenthionedBot && triggerMsg.text.includes("<@");
    if (isMentionedNonBot) {
      // スレッド内でボット以外をメンションしているならボットに無関係なメッセージ
      console.log("スレッド内でボット以外に話しかけているので無視します");
      return [];
    } else {
      // スレッド内でボット以外をメンションしていない場合はボットに話しかけている可能性がある
      const msgsInThread = TsunesSlackLib.fetchMsgsInThresd(
        triggerMsg.channel,
        triggerMsg.thread_ts,
        PropertiesService.getScriptProperties().getProperty('SLACK_TOKEN'),
      );

      const isBotInvolvedThread = msgsInThread.find(msg => msg.user === BOT_USER_ID) == null;
      if (isBotInvolvedThread && !isMenthionedBot) {
        console.log("ボットに無関係のスレッドなので無視します")
        return [];
      } else {
        console.log("ボットに関係のあるスレッドなので応答します")
        return msgsInThread;
      }
    }
  }
}
```

#### 3.1.4 同じメッセージに複数回応答しないようにする
ChatGPT APIのレスポンスには数秒がかかります。
また、Slackからのイベントリクエストに3秒以内にレスポンスを返さないと同じリクエストが再送されます。
そのため、単純にSlackからのリクエストの度に毎回応答していると同じメッセージに複数回応答することがあります。
これだとうざったいので対策する必要があります。

通常のサーバーのような環境だとSlackからのリクエストにはすぐレスポンスを返しておいて、裏で返答を作るという手があります。
しかし、GASは非同期処理ができないためこの方法は取れません。

この問題を回避する方法はGoogle Formを経由して擬似的に非同期トリガーを起動する方法やGASのスクリプトプロパティにIDを保存する方法などがあります。
今回はその中で最も今回の要件に適しているCacheServiceという機能を使います。
これは指定した値を指定した秒数だけGASのプロジェクトごとに保存しておける機能です。
メッセージのIDを保存しておくことで一度受け取ったメッセージについてのリクエストを無視することができます。

以下の関数で判定可能です
``` js
function isCachedId(id) {
  const cache = CacheService.getScriptCache();
  const isCached = cache.get(id);
  if (isCached)
  {
    return true;
  }

  // 5分経ったら流石に同じリクエストは飛んで来ないだろう
  cache.put(id, true, 60 * 5);
  return false;
}
```

### 3.2　実装手順
1. GASプロジェクトを作成する
1. Slackにメッセージを送信するためのライブラリを追加する
    - 登録名は`SlackApp`とする
    - スクリプトIDは`1on93YOYfSmV92R5q59NpKmsyWIQD8qnoLYk-gkQBI92C58SPyA2x1-bq` 
    - これを使わなくてもいいが使うと楽になる
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/ef4d8793-8101-e485-11f4-c2fd108c9eb2.png)
1. Slackのスレッドのメッセージを取得するためのライブラリを追加する
    - 登録名は`TsunesSlackLib`とする
    - スクリプトIDは`1O20VxEbcHIYIrrpe_HeqkiaAXNEjIKcTKe3rLl2r_1KJ6GQ_Ib-xkGJG` 
    - これを使わなくてもいいが使うと楽になる
1. GASの設定画面からスクリプトプロパティを以下の様に設定する
    - `OPENAI_KEY`: 先の手順で取得したOpenAIのAPIキー
    - `SLACK_TOKEN`: 先の手順で取得したSlackアプリのトークン
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/45990f3b-ea77-79c8-dce1-95b783e6f663.png)
1. コードを書く
作成したコードはこの章の最下部に貼りました。ご自由にコピペしてお使い下さい。コピペされた場合はコメントでご一報頂けると喜びます。
`console.log`をちょくちょく書いているのはログを後から確認する為です。GCPと連携すると見られる様になります。
実行するためにはコードの冒頭にある以下を変更する必要があります。
    - BOT_USER_ID: ボットユーザーID
    - BOT_DM_CHANNEL_ID: ボットとのDMのチャンネルID
1. 右上の「デプロイ」ボタンからウェブアプリとしてデプロイする
    - デプロイされたらウェブアプリのURLを控えておく
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/702d349d-3940-c64d-b870-673ac78c2e5b.png)

``` AnswerAnythingBot.js
const BOT_USER_ID = "";
const BOT_DM_CHANNEL_ID = "";

function doPost(e) {
  const reqObj = JSON.parse(e.postData.getDataAsString());

  // Event API Verification時
  if (reqObj.type == "url_verification") {
    return ContentService.createTextOutput(reqObj.challenge);
  }
  
  console.log("Slackからのリクエスト内容↓");
  // オブジェクトだけを指定してログを出すとログエクスプローラー上で整形されて見やすい
  console.log(reqObj);

  if (reqObj.type !== "event_callback" || reqObj.event.type !== "message") {
    // 念のため
    console.log(`応答不要。メッセージイベントではないため`);
    return ContentService.createTextOutput('OK');
  }

  if (reqObj.event.subtype !== undefined) {
    console.log(`応答不要。通常のメッセージ以外のイベントのため`);
    return ContentService.createTextOutput('OK');
  }
  
  // イベントトリガーとなったメッセージの情報
  const triggerMsg = reqObj.event;
  const userId = triggerMsg.user;
  const msgId = triggerMsg.client_msg_id;
  const channelId = triggerMsg.channel;
  const ts = triggerMsg.ts;

  // メンション以外のイベントを購読する場合はこれがないと無限ループになる
  if (userId === BOT_USER_ID) {
    console.log(`応答不要。ボット自身のメッセージのため: msgId=${msgId}`);
    return ContentService.createTextOutput('OK');
  }

  // 重複して返信をすることの対策
  if (isCachedId(msgId)) {
    // すでに受け取ったリクエストなら終わり
    console.log(`応答不要。既に受け取ったリクエストのため: msgId=${msgId}`);
    return ContentService.createTextOutput('OK');
  }

  try {
    // 入力をChatGPTに送信し、応答を受け取る
    const answerMsg = FetchAIAnswerText(triggerMsg);
    if (!answerMsg) {
      console.log(`応答不要。ボットへの問いかけではないため: msgId=${msgId}`);
      return ContentService.createTextOutput('OK');
    }

    console.log(`応答開始: msgId=${msgId}`);

    // 応答をSlack上でユーザーに表示する
    slackPostMessage(channelId, answerMsg, { thread_ts: ts });

    // ログ検索用
    console.log(`[INFO] ユーザーID: ${userId}, 返答: ${answerMsg}`);

    console.log(`応答正常終了: msgId=${msgId}`);
    return ContentService.createTextOutput('OK');
  } catch (e) {
    console.log(`応答異常終了: msgId=${msgId}`);
    console.error(e.stack);
    return ContentService.createTextOutput('NG');
  }
}

function isCachedId(id) {
  const cache = CacheService.getScriptCache();
  const isCached = cache.get(id);
  if (isCached)
  {
    return true;
  }

  // 5分経ったら流石に同じリクエストは飛んで来ないだろう
  cache.put(id, true, 60 * 5);
  return false;
}

function TrimMentionText(source) {
  const regex = /^<.+> /;
  return source.replace(regex, "").trim();
}

/**
 * @returns { string }
 */
function FetchAIAnswerText(triggerMsg) {
  const msgsAskedToBot = fetchSlackMsgsAskedToBot(triggerMsg);
  if (msgsAskedToBot.length === 0) {
    // 会話が始まっていないので返答不要
    return "";
  }

  // ChatGPT APIに投げるためのメッセージ履歴を作る
  const msgsForChatGpt = parseSlackMsgsToChatGPTQuesryMsgs(msgsAskedToBot);

  const ENDPOINT = 'https://api.openai.com/v1/chat/completions';
  const apiKey = PropertiesService.getScriptProperties().getProperty('OPENAI_KEY');

  // リクエストのボディを作成
  const requestBody = {
    // モデルを指定
    model: 'gpt-3.5-turbo',
    // クエリとなる文字列を指定
    messages: msgsForChatGpt,
    // 生成される文章の最大トークン数を指定。単語数というような意味
    // 1000辺り$0.002なので大きめでもOK
    max_tokens: 1000,
      // 0.5と指定すると生成される文章は入力となる文章に似たものが多くなる傾向があります。
      // 逆に、temperatureフィールドに1.0と指定すると、生成される文章は、より多様なものになる傾向があります。
    temperature: 0.5,
  };

  try {
    // リクエストを送信
    const res = UrlFetchApp.fetch(ENDPOINT, {
      method: 'POST',
      headers: {
        Authorization: 'Bearer ' + apiKey,
        // 答えはjsonでほしい
        Accept: 'application/json',
      },
      // これが無いとpayloadがOpen AIのサーバーに読まれない
      contentType: "application/json",
      payload: JSON.stringify(requestBody),
    });

    const resCode = res.getResponseCode();
    if(resCode !== 200) {
      if(resCode === 429) return "利用上限に達しました。このAPIの管理者が上限を変更しない限り来月まで使用できません"
      else return "AIへのAPIリクエストに失敗しました";
    }

    var resPayloadObj = JSON.parse(res.getContentText())
    
    if(resPayloadObj.choices.length === 0) return "予期しない原因でAIからの応答が空でした"; 

    const rawAnswerText = resPayloadObj.choices[0].message.content;
    // 先頭に改行文字が2つあるのは邪魔なので消す
    const trimedAnswerText = rawAnswerText.replace(/^\n+/, "");

    return trimedAnswerText
  } catch(e) {
    console.error(e.stack);
    return "AIへのAPIリクエストに失敗しました";
  }
}

/**
 * @returns {object[]}
 */
function fetchSlackMsgsAskedToBot(triggerMsg) {
  const isInThread = triggerMsg.thread_ts;
  const isMenthionedBot = triggerMsg.text.includes(BOT_USER_ID);
  if (!isInThread) {
    if (triggerMsg.channel === BOT_DM_CHANNEL_ID) {
      // ボットとのDMでの会話なら毎回ボットへの問いかけて間違いない
      console.log("ボットとのDMでのスレッド外のメッセージなので応答します");
      return [triggerMsg];
    }

    if (isMenthionedBot) {
      // スレッド外でメンションしたならそのメッセージからボットとの会話が始まっているはず
      console.log("スレッド外でボットに話しかけているので応答します");
      return [triggerMsg];
    } else {
      // スレッド外でメンションも無いならボットに無関係なメッセージ
      console.log("スレッド外でボットに話しかけていないので無視します");
      return [];
    }
  } else {
    const isMentionedNonBot = !isMenthionedBot && triggerMsg.text.includes("<@");
    if (isMentionedNonBot) {
      // スレッド内でボット以外をメンションしているならボットに無関係なメッセージ
      console.log("スレッド内でボット以外に話しかけているので無視します");
      return [];
    } else {
      // スレッド内でボット以外をメンションしていない場合はボットに話しかけている可能性がある
      const msgsInThread = TsunesSlackLib.fetchMsgsInThresd(
        triggerMsg.channel,
        triggerMsg.thread_ts,
        PropertiesService.getScriptProperties().getProperty('SLACK_TOKEN'),
      );

      const isBotInvolvedThread = msgsInThread.find(msg => msg.user === BOT_USER_ID) == null;
      if (isBotInvolvedThread && !isMenthionedBot) {
        console.log("ボットに無関係のスレッドなので無視します")
        return [];
      } else {
        console.log("ボットに関係のあるスレッドなので応答します")
        return msgsInThread;
      }
    }
  }
}

function parseSlackMsgsToChatGPTQuesryMsgs(slackMsgs) {
  return slackMsgs.map(msg => {
    return {
      role: msg.user == BOT_USER_ID ? "assistant" : "user",
      content: TrimMentionText(msg.text)
    }
  });
}

function slackPostMessage(channelId, message, option) {
  const token = PropertiesService.getScriptProperties().getProperty('SLACK_TOKEN');
  const slack = SlackApp.create(token);
  const res = slack.chatPostMessage(channelId, message, option);
  console.log("Slackへのメッセージ送信のレスポンス↓");
  console.log(res);
}
```

## 4. GASとSlackアプリを連携する
1. 作成したSlackアプリのダッシュボードを開く
1. 「Event Subscriptions」タブを開く
1. 先の手順で作成したGASのウェブアプリのリンクをリクエストURLに設定する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/83fc69f5-851d-745c-3936-74570e7145f7.png)
1. 「Event Subscriptions」タブで「message」系のイベントをすべて登録する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/cdaa102f-d57b-3472-7b59-fde1d251a23d.png)

# まとめ
以前作ったボットもそこそこ便利でした。しかし、メンションにしか反応しないし文脈を保持できないため少し使いづらかったです。
今回の改善でこれらの問題が解消され、とても使いやすくなりました。まるで人間のユーザーに話しかけるような気持ちで使っています。
みなさんもぜひ使ってみてください！仕事中の気分転換に便利です。

# 参考リンク
https://api.slack.com/events/message?filter=Events

https://qiita.com/Cliffford/items/162011abcc8c1e60f002

https://developers.google.com/apps-script/reference/cache?hl=ja