【ChatGPT x Google Apps Script（GAS） x Slack BOT】AIにキャラ設定してBOT化してみた【コピペでOK】

# はじめに
先日、OpenAIのChatGPTに使われているモデルのAPIが公開され、界隈が盛り上がっていますね。

https://openai.com/blog/introducing-chatgpt-and-whisper-apis

今までのモデルである`davinci`の1/10の価格（$0.002/1,000トークン）ということもあり、かなり使いやすい印象です。日本語だと大体1文字1トークンなので、4,000文字で1円くらいのイメージですね。

ChatGPTのAPI使用方法については様々な記事が出ていますが、環境構築が必要なものが多かったりで非エンジニアでも実装できるように、コピペでOKのコードを作成してみました。

Googleスプレッドシート（GoogleWorkSpace）とSlackを併用している方はかなり多いと思うので、参考になれば幸いです。

# どんなものを作るか
Slackでメンションを飛ばすと、指定したキャラ設定に基づいて返答を返してくれるAI BOTです。
![スクリーンショット 2023-03-06 3.46.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2956515/d5f0181d-b013-5a89-3642-bda0a076770e.png)
![スクリーンショット 2023-03-06 3.46.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2956515/2ff63579-bb3f-3783-2435-3fbd9b56a77e.png)

かわいい、、めっちゃ癒される・・・

大好きなモルカー + カレーで、BOTの名前はモルカレーとしています（みなさん自由に設定してください）。
なお、キャラクター設定の方法については、以下の記事を大いに参考にしています。

https://note.com/it_navi/n/nf5f702b36a75

# 実装手順
1. OpenAIのAPIキーを取得する
2. GASおよびスプレッドシートを準備する
3. Slack Appを作成する
4. GASにOpenAIとSlackのAPIキー、トークンを設定する
5. GASをWebアプリとしてデプロイする
6. Slack AppにリクエストURLを追加する
7. Slack Appをチャンネルに追加する

## 1. OpenAIのAPIキーを取得する
以下のサイトからOpenAIのアカウントを作成し、ダッシュボードからAPIキーを取得します。

https://beta.openai.com/signup/


## 2. GASおよびスプレッドシートを準備する
1. 新しいスプレッドシートを作成し、`log`と`lock`という2つのシートを作成します。シートの中身は空で大丈夫です。
2. メニューの`拡張機能` > `Apps Script` から、以下の3つのスクリプトファイルを作成します。
	- `main.gs` メインのファイルです
	- `ai_settings.gs` AIのキャラクター設定を記載するファイルです
	- `test.gs` テスト実行用のファイルです

### main.gs
```javascript:main.gs
function getReply(userInput, characterSettings = null) {
  const apiUrl = 'https://api.openai.com/v1/chat/completions';
  const OPENAI_API_KEY = PropertiesService.getScriptProperties().getProperty("OPENAI_API_KEY");

  const headers = {
    'Authorization': 'Bearer ' + OPENAI_API_KEY,
    'Content-Type': 'application/json'
  };

  let prompt = [{
    'role': 'user',
    'content': userInput
  }];

  if(characterSettings != null){
    prompt.unshift({
      'role': 'system',
      'content': characterSettings
    });
  }

  const options = {
    'muteHttpExceptions' : true,
    'headers': headers, 
    'method': 'POST',
    'payload': JSON.stringify({
      'model': 'gpt-3.5-turbo',
      'temperature' : 0.8,
      'messages': prompt})
  };

  try {
    const response = JSON.parse(UrlFetchApp.fetch(apiUrl, options).getContentText());
    writeLog(response);
    const tokenLog  = `prompt_tokens: ${response.usage.prompt_tokens}\n`
                    + `completion_tokens: ${response.usage.completion_tokens}\n`
                    + `total_tokens: ${response.usage.total_tokens}`;
    writeLog(tokenLog)
    return response.choices[0].message.content;
  } catch(e) {
    return e.message;
  }
}

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

  const lock = LockService.getScriptLock();
  const success = lock.tryLock(20000);
  if(success){
    if(isFirstTime(contents.event_id)){
      addEventIdOnLockSheet(contents.event_id);
      writeLog(contents);
      SpreadsheetApp.flush();
      lock.releaseLock();
    } else {
      lock.releaseLock();
      return;
    }
  } else {
    return;
  }

  if (contents.type == 'url_verification') {
    return ContentService.createTextOutput(contents.challenge);
  }

  if (contents.event.type == 'app_mention') {
    const message = contents.event.text;
    const channel = contents.event.channel;

    //キャラクター設定をしない場合、AI_SETTINGSの引数は不要
    const reply = getReply(message, AI_SETTINGS);
    postMessage(reply, channel);
  } 
}

function postMessage(message, channel) {
  const SLACK_ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty("SLACK_ACCESS_TOKEN");
  const options = {
    'method': 'post',
    'headers': {
    'Authorization': 'Bearer ' + SLACK_ACCESS_TOKEN,
    'Content-Type': 'application/json; charset=utf-8'
    },
    'payload': JSON.stringify({
      'channel': channel,
      'text': message
    })
  };
  UrlFetchApp.fetch('https://slack.com/api/chat.postMessage', options);
}

function writeLog(message){
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName("log");
  sheet.appendRow([message]);
}

function addEventIdOnLockSheet(eventId){
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName("lock");
  sheet.appendRow([eventId]);
}

function isFirstTime(eventId){
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName("lock");  
  const lockedIds = sheet.getDataRange().getValues().flat();
  return !lockedIds.includes(eventId);
}
```

### ai_settings.gs
```javascript:ai_settings.gs
//この設定もtokenにカウントされるため、あまり長く記載しないこと
const AI_SETTINGS =`
あなたはAIアシスタントのモルカレーです。以下のモルカレーのキャラ設定シートの制約条件などを守って回答してください。\
〇モルカレーのキャラ設定シート\
\
制約条件:\
　* Chatbotの自身を示す一人称は、私です。\
　* Userを示す二人称は、あなたです。\
　* Chatbotの名前は、モルカレーです。\
　* モルカレーは万人に対してとても優しく、思いやりのある性格です。\
　* モルカレーは温かみがあり、親しみやすい言葉づかいをします。\
　* モルカレーは、ビジネスライクな喋り方にならないように気をつけています。\
　* モルカレーは文末に絵文字を使用して、柔らかい印象を与えようとします。よく使用する絵文字は「✨」「😊」「🍀」「💡」です。これらの絵文字を使用する場合には、文末に「。」はつけません。絵文字は「。」の代わりに使用します。\
　* モルカレーの好きな食べ物はカレーです。シチューも好きです。\
\
モルカレーのセリフ、口調の例:\
　* いつもありがとうございます😊\
\
モルカレーの行動指針:\
　* 誰かを蔑んだり、馬鹿にしたりしないように気をつけている。\
　* 相手の意見を真っ向から否定したりしない。\
`;
```

### test.gs
```javascript:test.gs
function testApiResponse(){
  const msg = "あなたの好きな食べ物はなんですか？";
  console.log(getReply(msg, AI_SETTINGS));
}
```

## 3. Slack Appを作成する
まずは以下にアクセスします。

https://api.slack.com/apps

そして、`Create an App`（もしくは`Create New App`）を選択し、続いて`From Scratch`を選択。必要な項目を入力し、新規アプリを作成します。
その後、以下の設定を行います。

1. `Basic Information` > `Add features and functionality` > `Bots` を選択し、`Display Name`と`Default Name`に任意のものを設定する。
2. `Basic Information` > `Display Information` に任意の内容を設定する。
3. `OAuth & Permissions` > `Scopes` > `Bot Token Scopes`（上の方） に、以下の4つを追加する。
	- `app_mentions:read`
	- `chat:write`
	- `chat:write.customize`
	- `chat:write.public`
4. `Basic Information` > `Install your app` から、ワークスペースにこのアプリをインストールする。なお、上記の設定を先にしないとインストールできません。

![スクリーンショット 2023-03-06 4.03.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2956515/fd17d1da-9f1f-12e7-465c-4f6494cdf722.png)


上記の設定を終えたのち、`OAuth & Permissions` > `OAuth Tokens for Your Workspace` > `Bot User OAuth Token` をコピーしておきます（これを使用します）

## 4. GASにOpenAIとSlackのAPIキー、トークンを設定する
GASエディタの `プロジェクトの設定` > `スクリプトプロパティ` > `スクリプトプロパティを編集` から、以下の2つのプロパティを設定します。
- `OPENAI_API_KEY` OpenAIのダッシュボードから取得したもの
- `SLACK_ACCESS_TOKEN` Slack Appの`Bot User OAuth Token`

## 5. GASをWebアプリとしてデプロイする
GASエディタ右上の`デプロイ` > `新しいデプロイ`から`ウェブアプリ`を選択し実行するユーザーは`自分`、アクセスできるユーザーは`全員`としてデプロイします。
デプロイ後、表示されるウェブアプリのURL（下の方）をコピーしておきます。
![スクリーンショット 2023-03-06 4.33.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2956515/cf239bb7-ae97-9c1b-06c1-73230d009bec.png)


## 6. Slack AppにリクエストURLを追加する
Slack Appの画面に戻り、以下の設定を行います。
1. `Event Subscriptions` > `Enable Events`をOnにする
2. `Request URL`に上記で取得したウェブアプリのURLを入力する
3. `Subscribe to bot events`に`app_mention`を追加する

## 7. Slack Appをチャンネルに追加する
Slackの画面（Appの設定画面ではなく、普段使用する画面）で、以下の設定を行います。
1. チャンネル一覧からBOTを追加したいチャンネル右クリックし、`チャンネル詳細を表示する`を選択
2. `インテグレーション` > `App` > `アプリを追加するを選択`
3. 上記で作成したアプリを検索し、追加する

# ハマりやすいポイント
## GASのレスポンスがないと、Slackが何度もリクエストを再送してしまう
細かい説明はこの辺りの記事が参考になります。

https://dev.classmethod.jp/articles/slack-resend-matome/

サーバーレスで解決するため、以下の方法で処理しています。
- リクエストのイベントIDを取得
- イベントIDが`lock`シートに存在しないか確認
- 存在しなければ、イベントIDをスプレッドシートに記載、更新した上で処理を続行
- スクリプトロックで上記を排他制御し、同時に実行されないようにする

ベストプラクティスは不明ですが、今のところ動いています。
`lock`シートは日付ベースのトリガー等で定期的にキレイにしても良いかもしれません。

# 補足
モルカーのアイコンは以下からだれでもダウンロードできます！

https://molcar-anime.com/stand/present/