見出し画像

Slack APIとGoogle Apps Scriptで完結するサーバーレスChatGPT対話アプリを作る

こんにちは。

研究室で「ChatGPTで何かやってみたい」という話になり、そのトライアルとしてChatGPTと対話できるSlackアプリ「ベイビィ・フェイス」を作りました。この記事では、Slack APIGoogle Apps Script(GAS)を中心に紹介します。

アイコンは同期が作成してくれました!

この対話アプリでは、以下の3点を重視しました。

  1. ChatGPTのトークンを不用意に消費しないため「ベイビィ・フェイス」をメンションした投稿をChatGPTに投げる。

  2. これまでのやりとりを踏まえてChatGPTと対話できるようにする。

  3. 特定のコマンドでこれまでのやりとりを削除し、新しい文脈で対話できるようにする。

これまでに、GASとSlackのWebhookでnote記事を投稿するBotを作成したことがありましたが、GASは日々の活動を快適にすることを目的としたちょっとしたシステムを構築するのにオススメです。

なぜ、「ベイビィ・フェイス」なのか。

ベイビィ・フェイスは「ジョジョの奇妙な冒険」第5部に登場するスタンドで育成次第で能力が変わるところから名付けられています。(なぜ、ジョジョが由来かは、、、笑)

ジョジョの奇妙な冒険 第5部 モノクロ版 5巻p. 66より

ちなみに、4月25日にOpenAIよりブランドガイドラインが公開されましたが、○○○GPTというサービス名・アプリ名はNGになったようです。

事前準備

  • ChatGPT APIをOpenAIの公式サイトより取得してください。
    (この記事では割愛しています。)

Slackアプリを作る

はじめに、https://api.slack.com/apps よりSlackアプリを作ります。

Create an App > From scratchを選択する。
次に、アプリ名とワークスペースを設定し、右下[Create App]を押す。

Scopes

次に、Scopesと呼ばれるアプリに付与する権限(例えば、チャンネルに投稿できる・メンションされた投稿を取得できるなど)を設定します。

「ベイビィ・フェイス」では、メンションされた投稿を取得できることChatGPTからの返答を投稿できることのための権限が必要なため、[Add an OAuth Scopes]からapp_mentions:readchat:writeを追加します。

OAuth & Permissions > Bot Token Scopes

Events API

次に、Events APIを設定します。このイベントとは、Slackでのアクティビティ(メッセージの投稿、削除、編集など)です。

Slackアプリでは、設定したイベントが起こったとき(= メンションされたとき)にHTTP POST リクエストが発生します。このリクエストで、GASで実装したウェブアプリを経由して、Slackに投稿されたChatGPTにメッセージを送信したいと思います。そのために、[Add Bot User Event]からapp_mentionを追加します。

Event Subscriptions > Subscribe to bot events

公式ドキュメントによると、投稿に関係する情報はJSON形式で取得できます。今回は、投稿を確実に識別するためにevent.event_ts(タイムスタンプ)をUnique IDとして用いました。

// 公式ドキュメントより引用
{
    "token": "ZZZZZZWSxiZZZ2yIvs3peJ",
    "team_id": "T061EG9R6",
    "api_app_id": "A0MDYCDME",
    "event": {
        "type": "app_mention",
        "user": "W021FGA1Z",
        "text": "You can count on <@U0LAN0Z89> for an honorable mention.",
        "ts": "1515449483.000108",
        "channel": "C0LAN2Q65",
        "event_ts": "1515449483000108"
    },
    "type": "event_callback",
    "event_id": "Ev0MDYHUEL",
    "event_time": 1515449483000108,
    "authed_users": [
        "U0LAN0Z89"
    ]
}

最後に、ワークスペースに追加してひとまず完了です。

[Install to Workspace]でワークスペースにアプリをインストールします。

GAS上でChatGPTを実行する

はじめに、https://script.google.com からプロジェクトを作成します。

ChatGPT API Keyをスクリプトプロパティに追加する

ChatGPT API Keyをスクリプトプロパティに追加します。GASコードは最終的にウェブアプリとしてデプロイして利用しますが、運用中に変更する可能性のある値をスクリプトプロパティに定義することで、デプロイし直す必要がなくなる利点があります。

プロジェクトを開いたあと、左側の歯車より設定を開きます。
[スクリプトプロパティを編集]より値を追加します。

GASコード上では、PropertiesServiceを用いて呼び出すことができます。

const properties = PropertiesService.getScriptProperties().getProperties();
properties.OPEN_AI_KEY // プロパティで定めた変数で呼び出せる。
properties.SLACK_CHANNEL_ID

ChatGPTを呼び出す

それでは、ChatGPTと対話する関数を実装してみたいと思います。OpenAI公式のレスポンスフォーマットを参考にオプションを設定します。そして、UrlFetchApp.fetchを用いてAPIを呼び出します。
参考:GASでChatGPTを利用する

// message: ChatGPTに渡したいテキスト
function requestChatGPT(message) {
  var openai_url = "https://api.openai.com/v1/chat/completions";
  var headers = {
    'Content-Type': 'application/json; charset=UTF-8',
    'Authorization': 'Bearer ' + properties.OPEN_AI_KEY,
  };

  var options = {
    'method' : 'post',
    'headers' : headers,
    'payload' : JSON.stringify({
      'model': 'gpt-3.5-turbo',
      'messages': {'role': 'user', 'content': message}
      }),
    'muteHttpExceptions':true
  };

  const response = UrlFetchApp.fetch(openai_url, options);  
  var json = JSON.parse(response.getContentText('UTF-8'));

  return json["choices"][0]["message"]["content"];
}

GAS上でtest()を作成し、試しに実行してみました。

function test() {
  var result = requestChatGPT('自己紹介してください。');
  Logger.log(result);
}
この内容であれば、15秒で返答がありました。

SlackアプリとGASコードの関係性

SlackアプリにデプロイしたウェブアプリのURLを追加するとき、以下のメッセージのようにURL認証が必要となります。

We’ll send HTTP POST requests to this URL when events occur. As soon as you enter a URL, we’ll send a request with a challenge parameter, and your endpoint must respond with the challenge value.

Slack API

url_verificationを確認するとタイプで判別できることが分かったので、type == "url_verification"のとき、challenge valueを返すGASコードを実装します。

function doPost(e) {
  var data = JSON.parse(e.postData.getDataAsString());

  if (data.type == "url_verification") {
    return ContentService.createTextOutput(data.challenge);
  }
}

ウェブアプリとしてデプロイする

ここまでURL認証できるか確認するためにデプロイしてみます。

[デプロイ] より、「新しいデプロイ」を選択する。
種類「ウェブアプリ」、アクセスできるユーザー「全員」であることを確認し、
[デプロイ]を押す。
ウェブアプリのURLが表れるのでコピーしておいておく。

ウェブアプリのURLをEnable Events > Request URLに追加し、"Verified"となることを確認します。

url_verificationを実装しているので、"Verified✔️"と出ると思います。

Slackリクエストのタイムアウトに対応する

また、SlackアプリのHTTP POSTリクエストは3秒以内にレスポンスを返す必要があり、3秒経つとタイムアウトして同じリクエストを再送します。

しかし、ChatGPTの返答は3秒以内に得られるとは限らず、またChatGPTのトークンを2度消費するのはもったいないため、同じリクエストはレスポンスしないようにします。

今回は1回目に送信されたidを保持し、同じidを持つリクエストを判別する関数 isExistCache() を用いました。こちらは、ChatGPT APIを使って何にでも応答するSlackボットをGASで作る方法を参考にさせていただきました。

// 5分間idを保持し、同じidを持つリクエストを判別する。
function isExistCache(id) {
  const cache = CacheService.getScriptCache();
  const isCached = cache.get(id);
  if (isCached) return true;

  cache.put(id, true, 300);
  return false;
}

ここまで、Slackアプリがリクエストを送信してからChatGPTを呼ぶ関数までのコードは以下の通りです。メンション(@~)は、メッセージに関係ないため、replace(/^<.+> /, "")で取り除きます。

// ここまでのdoPost()関数
function doPost(e) {
  var data = JSON.parse(e.postData.getDataAsString());

  if (data.type == "url_verification") {
    return ContentService.createTextOutput(data.challenge);
  }

  const ts = data.event.ts;
  const userId = data.event.user;
  const text = data.event.text;

  // 再送していないか判別する。
  if (isCachedId(ts)) {
    return ContentService.createTextOutput('OK');
  }

  // メンションを取り除く。
  var questionMsg = text.replace(/^<.+> /, "").trim();
  
  try {
    // ChatGPTにメッセージを投げ、返答を得る。
    const replyMsg = requestChatGPT(questionMsg);
    if(!replyMsg) {
      return ContentService.createTextOutput('OK');
    }

    return ContentService.createTextOutput('OK');
  } catch(e) {
    return ContentService.createTextOutput('NG');
  }
}

ChatGPTからの返答をSlackに投稿する

最後に、ChatGPTからの返答をSlackに投稿するところです。ChatGPTにメッセージを投げるときと同じように、UrlFetchApp.fetch()を使います。

// SlackのトークンやチャンネルIDもスクリプトプロパティで定義しておくと良い。
function postToSlack(text) {
  var url = "https://slack.com/api/chat.postMessage";

  var options = {
    "method": "post",
    "headers": {
      "Authorization": "Bearer " + properties.SLACK_BOT_TOKEN,
      "Content-type": "application/json; charset=utf-8"
    },
    "payload": JSON.stringify({
      "channel": properties.SLACK_CHANNEL_ID,
      "text": text
    })
  };

  var response = UrlFetchApp.fetch(url, options);
}

また、以下のフォーマットでメンション付き投稿ができるようになります。

// userId: 投稿者のID, replyMsg: ChatGPTの返答
postToSlack(`<@${userId}> \n ${replyMsg}`);

ChatGPTとのこれまでのやりとりを保持する

ここまででChatGPTと対話するアプリは作れました。しかし、研究室で調べてみたところ、これではこれまでのやりとりを踏まえておらず、全てのやりとりを渡す必要があることがわかりました。

そこで、GAS上でCache Serviceを用いてこれまでのやりとりを一定時間保持し、これまでのやりとりも含めてChatGPTと対話できるようにします。Cache Serviceは、本来計算など時間のかかるものを一時的にキャッシュするためのサービスで、最大21600秒(6時間)保持することができます

Cache Serviceでは、{'role': 'XXX', 'content': 'YYY'} の形で保持することにします。これは、複数やりとりを含む場合にChatGPT APIでは以下のような形でmessagesに代入するからです。

// userは投稿者、assistantはChatGPTです。
[{'role': 'user', 'content': '質問A'},
 {'role': 'assistant', 'content': '返答A'},
 {'role': 'user', 'content': '質問B'},
 {'role': 'assistant', 'content': '返答B'},
 {'role': 'user', 'content': '質問C'},
 {'role': 'assistant', 'content': '返答C'}]

GASコードは以下の通りです。conversationsにこれまでのやりとりをまとめキャッシュに保存しています。

// idがuserIdであるキャッシュを取得する。 キャッシュがない時は、nullが返ってくる。
var userMsgCache = JSON.parse(msgCache.get(userId));

// 保持時間は指定がなければ10分
var expiredTime = (properties.EXPIRED_TIME === undefined) ? 600 : properties.EXPIRED_TIME;
  
var conversations = [];

// キャッシュがある時は、これまでのやりとりを取得する。
if (userMsgCache != null) {
  conversations = userMsgCache.conversations;
}

// 今回のメッセージを追加する。
conversations.push({'role': 'user', 'content': questionMsg});

const replyMsg = requestChatGPT(conversations);
if(!replyMsg) return ContentService.createTextOutput('OK');

// 今回の返答を追加する。
conversations.push({'role': 'assistant', 'content': replyMsg});

// 再度、キャッシュに保存する。
msgCache.put(userId, JSON.stringify({conversations: conversations}), expiredTime);

これまでのやりとりを保持することに合わせて、requestChatGPT()の引数をメッセージのみからpayload.messagesに変更します。

// message: ChatGPTに渡したいテキスト
function requestChatGPT(messages) {
// 略 //

  var options = {
    'method' : 'post',
    'headers' : headers,
    'payload' : JSON.stringify({
      'model': 'gpt-3.5-turbo',
      'messages': messages
      }),
    'muteHttpExceptions':true
  };

// 略 //
}

これで、これまでのやりとりを踏まえて対話できるようになりました。

試しにしりとりしてみました。

これまでのやりとりをやめて新しい文脈でやりとりしたい場合は、.remove()でキャッシュを削除します。このとき、メンション + removeというコマンドで削除できるようにしました。

if (questionMsg == 'remove') {
  msgCache.remove(userId);
  postToSlack(`<@${userId}> remove conversations cache b/w you and ChatGPT.`);
  return ContentService.createTextOutput('OK');
}

さいごに

ここまでが「ベイビィ・フェイス」の全容になります。GASコードはGitHubにおきました。何かの参考になれば幸いです。

長くなりましたが、このあとどのように発展していくかご期待ください!
お読みいただきありがとうございました。

参考にさせていただきました。

この記事が気に入ったらサポートをしてみませんか?