(ChatGPT APIにも対応)GPT-3とGASを使ってあらゆる質問に応えられるSlackボットを作った

# 追記2(2023/04/23)
[改良版の記事](https://qiita.com/noritsune/items/17c20dccb0eb00f2622e)を書きました。
改良版の方が作りやすく多機能なのでそちらをお勧めします

# 追記1(2023/03/02)
ついにChatGPT APIが公開されました！
GASのコードに以下の差分を適用することでChatGPT APIを使うようにする事ができます。

### 1. ChatGPT APIにリクエストを投げるように変更
ChatGPT APIを叩くようにします。URL及びリクエストとレスポンスのフォーマットが変わっていることに注意です

130行目
```diff
+ const ENDPOINT = 'https://api.openai.com/v1/chat/completions';
- const ENDPOINT = 'https://api.openai.com/v1/completions'; 
```

136行目
```diff
+ model: 'gpt-3.5-turbo',
- model: 'text-davinci-003',
```

138行目
```diff
+ messages: [{ role: "user", content: prompt }],
- prompt: prompt,
```

172行目
```diff
+ const rawAnswerText = resPayloadObj.choices[0].message.content;
- const rawAnswerText = resPayloadObj.choices[0].text;
```

### 2. 仮応答を削除
ChatGPT APIのレスポンスはかなり早いので仮応答がむしろ邪魔に感じます。そのため削除します。
なんならフォームを経由する処理すらいらないかもしれません。

23~25行目
```diff:仮応答を削除
- // すぐに応答が無いと不安になるのでとりあえず何か返す
- const tempMessage = "回答を生成中です...。しばらくお待ち下さい";
- slackPostMessage(channelId, tempMessage, { thread_ts: ts });
```

# 概要
最近、高度な対話が出来るAIの[ChatGPT](https://chat.openai.com/chat)が話題です。自分も試してその便利さにとても驚きました。
そこで、これに似たことが手軽に出来ると楽しいと思いPT-3を使ったSlackボットを作りました。

# 使い方
Slackでボットをメンションして質問を投げるとしばらくして回答が返ってきます。
雑談の合間に挟んでAI君の答えを聞くもよし、ハマっているタスクの解消方法を聞くもよしでとても便利です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/45133f1d-10b2-8dc8-1cef-5f304d0c7910.png)

# 前提知識
## ChatGPTとGPT-3の違い
使ってみた所感としてはChatGPTの方がGPT-3より回答が高度です。

以下の解説はChatGPTによるものです。
> GPT-3（Generative Pretrained Transformer 3）とChatGPTは、異なるモデルです。GPT-3は、大規模なトランスフォーマーモデルであり、言語理解タスクを処理するためにトレーニングされます。一方、ChatGPTは、自然言語対話タスクを処理するためにトレーニングされたモデルです。つまり、GPT-3は一般的な言語理解タスクを処理することができますが、ChatGPTは特定のタスク（自然言語対話）に特化しています。これらのモデルの違いは、その目的とトレーニング方法にあります。

## GPT-3を使う方法
ChatGPTはAPIが公開されていないので今回はGPT-3を使用しました。

以下の解説はChatGPTによるものです。
> GPT-3（Generative Pretrained Transformer 3）を使う方法は、専用のAPIを使用する方法が一般的です。GPT-3のAPIを使用するには、まずOpenAIのウェブサイトでAPIキーを取得する必要があります。APIキーを取得したら、APIを使用するためのプログラムを作成することができます。GPT-3のAPIを使用すると、あらゆる言語理解タスクを処理することができます。例えば、テキストや文章を入力として受け取り、それを理解して新しい文章を生成することができます。また、GPT-3は、自然言語対話タスクを処理することもできます。

## GPT-3の使用料金
GPT-3の使用料金は1000トークン辺り$0.02です。
トークンとはGPTによる自然言語処理の単位で単語単位に近いそうです。[参照](https://beta.openai.com/tokenizer)
ちなみに↑の「GPT-3を使う方法」の回答は292トークンでした。これは$0.00588で大体0.8円です。(2022年12月7日時点)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/6cce490c-bece-19d4-de52-16f8be4d439a.png)
[参照](https://openai.com/api/pricing/)

## GPT-3の返答の正確性
GPT-3の返答は正確でないことが多いです。

例えば「この時期に鳥取に行ったら食べておくべきものを教えて。」という質問に対して以下が回答が返ってきました。
> 鳥取では、特産の「鳥取焼きそば」や「おおとりうどん」などのうどん類が有名です。また、「鳥取砂丘カレー」や「鳥取牛タン」など、特産の牛肉料理も人気があります。その他にも、「若宮のかき揚げ」や「若宮のおでん」など、鳥取名物料理も楽しむことができます。

自分がググった限りでは「鳥取焼きそば」や「若宮のかき揚げ」などは鳥取県の名物料理としては認知されていないようでした。(鳥取住みの方はコメント頂けると嬉しいです。)

# 作成手順
## 1. GPT-3を使用するための準備をする
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を設定する
    - 以下の3つを設定しました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/a7c431b4-cdbc-4334-2346-41e7a4f71e41.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アプリのバックエンドを作る
### 工夫した点
始めは以下の流れで処理をしていました。
1. SlackからのメンションイベントのPOSTリクエストでGASを発火する
1. GPT-3が返答を生成するのを待つ
1. Slackに返答を投げる
1. SlackからのメンションイベントのPOSTリクエストにレスポンスを返す

しかし、これだとSlackからのメンションイベントのPOSTリクエストにレスポンスするまでが長すぎてその間にメンションイベントのPOSTリクエストが複数回発行されました。
このままだと重複して返答を行ってしまうためよくありません。そこで[こちらのページを参考](https://stackoverflow.com/questions/54809366/how-to-send-delayed-response-slack-api-with-google-apps-script-webapp)に工夫して以下の様に処理を行うようにしました
1. SlackからのメンションイベントのPOSTリクエストでGASを発火する
1. Googleフォームにメンションイベントの情報を送信する
1. SlackからのメンションイベントのPOSTリクエストにレスポンスを返す
1. Googleフォームへの送信イベントでGASを発火する
1. GPT-3が返答を生成するのを待つ
1. Slackに返答を投げる

### 手順
1. Google Driveを開く
1. Googleフォームを作成する
1. Googleフォームの質問を以下の通りに設定する
    - 各質問の種類は「記述式」にする
    - 質問のタイトルはプログラム内で参照されるため正確に設定する
    - 質問のタイトルは以下の通り
      - channelId
      - userId
      - userLabel
      - ts
      - prompt
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/f3a350f9-8b9d-f857-f5bf-06b12966f53b.png)
1. ログインなしで回答を送信できる設定になっていることを確認する
    - フォームの「設定」タブの「回答を1回に制限する」をオフにする
        - Google Workspaceアカウントの場合はこの上に「組織内のアカウントだけ許可する」みたいな設定があるのでそれもオフにする
    - 回答フォームをシークレットタブで開いてログインを求められなければ設定OK
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/6cc2b627-2e63-936c-2a77-673104cf0e9a.png)
1. Googleフォーム編集ページの右上からスクリプトエディタを開く
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/05590ca3-204e-49b7-e3ce-49afe9667f57.png)
1. Slack APIを使うためのライブラリを追加する
    - スクリプトIDは`1on93YOYfSmV92R5q59NpKmsyWIQD8qnoLYk-gkQBI92C58SPyA2x1-bq` 
    - これを使わなくてもSlack APIは使えるが使うと楽になる
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/ef4d8793-8101-e485-11f4-c2fd108c9eb2.png)
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. コードを書く
```java
// Slackからの呼び出しへはすぐにレスポンスを返す。すぐに返さないと複数回呼び出されるため
// 回答はフォーム書き込み時イベント(onForm)経由で発火する
// 参考: https://stackoverflow.com/questions/54809366/how-to-send-delayed-response-slack-api-with-google-apps-script-webapp
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("仮応答開始");
  
  console.log("Slackからのリクエスト内容↓");
  console.log(reqObj);

  const channelId = reqObj.event.channel;
  const userId = reqObj.event.user;
  const userLabel = FetchUserLabel(userId);
  const prompt = TrimMentionText(reqObj.event.text);
  const ts = reqObj.event.ts;

  // すぐに応答が無いと不安になるのでとりあえず何か返す
  const tempMessage = "回答を生成中です...。しばらくお待ち下さい";
  slackPostMessage(channelId, tempMessage, { thread_ts: ts });

  const formUrl = // 要指定;
  // 要キー変更
  const formPostPayload = {
    // チャンネルID
    "entry.2061506925": channelId,
    // ユーザーID
    "entry.1241028754": userId,
    // ユーザー名
    "entry.1637660758": userLabel,
    // 質問内容
    "entry.691277": prompt,
    // タイムスタンプ
    "entry.926641428": ts,
  };
  const formPostOption = {
    method:'POST',
    payload: formPostPayload
  };
  UrlFetchApp.fetch(formUrl, formPostOption);

  console.log("仮応答終了");
  
  return ContentService.createTextOutput('OK');
}

function onForm(e) {
  console.log("本応答開始");

  const req = {};
  const itemResponses = e.response.getItemResponses();
  for(let i = 0; i < itemResponses.length; i++) {
    const itemResponse = itemResponses[i];
    const question = itemResponse.getItem().getTitle();
    const answer = itemResponse.getResponse();

    req[question] = answer;
  }

  console.log("フォームからのリクエスト内容↓");
  console.log(JSON.stringify(req));

  // リクエスト情報
  const channelId = req.channelId;
  const userId = req.userId;
  const userLabel = req.userLabel;
  const prompt = req.prompt;
  const ts = req.ts;

  try {
    // 入力をChatGPTに送信し、応答を受け取る
    const answerText = FetchAIAnswerText(prompt);
    // デバッグ用にオウム返し
    // const answerText = prompt;

    // 応答をSlack上でユーザーに表示する
    const answerTextWithMention = `<@${userId}> ${answerText}`;
    slackPostMessage(channelId, answerTextWithMention, { thread_ts: ts });

    // ログ検索用
    console.log(`[INFO] ユーザー名: ${userLabel}, プロンプト: ${prompt}. 返答: ${answerText}`);

    console.log("本応答正常終了");
    return ContentService.createTextOutput('OK');
  } catch (e) {
    console.error(e);

    const errorMessage = "予期せぬエラーが発生しました"
    slackPostMessage(channelId, errorMessage, { thread_ts: ts });

    console.log("本応答異常終了");
    return ContentService.createTextOutput('NG');
  }
}

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

function TrimMentionTextTest() {
  const source = "<@U04E13PHF5X> やっほー"
  const result = TrimMentionText(source);
  console.log(result);
}

function FetchUserLabel(userId) {
  const token = PropertiesService.getScriptProperties().getProperty('SLACK_TOKEN');
  const slack = SlackApp.create(token);
  const userInfo = slack.usersInfo(userId);
  return userInfo.user.real_name;
}

function FetchUserLabelTest() {
  const userId = "UUBMPDATU";
  const userLabel = FetchUserLabel(userId);
  console.log(userLabel);
}

function FetchAIAnswerText(prompt) {
  if(prompt == null || prompt === "") {
    return "問いかけが空なので返答出来ません";
  }

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

  // リクエストのボディを作成
  const requestBody = {
    // モデルを指定
    model: 'text-davinci-003',
    // クエリとなる文字列を指定
    prompt: prompt,
    // 生成される文章の最大トークン数を指定。単語数というような意味
    // 1000辺り$0.02なので少なくしておく
    max_tokens: 300,
      // 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がOpen AIのサーバーに読まれない
      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].text;
    // 先頭に改行文字が2つあるのは邪魔なので消す
    const trimedAnswerText = rawAnswerText.replace(/^\n+/, "");

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

function FetchAIAnswerTextTest() {
  var inputText = "ChatGPTと対話するためのSlackボットを作って下さい";
  var answerText = FetchAIAnswerText(inputText);
  console.log(answerText);
}

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

function slackPostMessageTest() {
  const channelId = "C01Q6TBGNBZ"; // slackボットテストチャンネル
  const message = "メッセージ送信テスト";
  slackPostMessage(channelId, message);
}
```

## 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」タブで「app_mention」を登録する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/89f0c368-2a70-2805-4e16-947c54226a8a.png)

作成手順はこれで以上です

# まとめ
とても便利で楽しいボットを簡単に作ることが出来ました。社内のSlackで紹介したところ同僚の方たちが使ってくださいました。嬉しかったです。
ChatGPTのAPIが公開されるとより高度な応答が可能になるので公開されないかなーと待っています。