はじめに

みんな大好きGPTちゃん(少しブーム過ぎた感はあるが)
ブラウザだと無料でも使えるのだが、会社の標準連絡ツールでSlackを使ってると以下のようなニーズから、SlackでGPTちゃんを使いたくなってくる

  • GPTとのやり取りを他の人に共有したい!
  • GPTとのやり取りを履歴として残したい!(ブラウザでも履歴は残せるが、履歴機能を有効化にすると、やり取りの内容を学習に使われるので、業務では使い辛い)
  • ブラウザでGPTを起動させるのが単純にダルい!
  • アイコン画像とシステムメッセージ設定で、Botを自分好みに仕上げたい! <<< これ重要
    • 最近追加された新機能であるCustom instructionsを使えば、ブラウザ版でも同じことができるようになってます

「ChatGPT搭載のSlack Botを作成してみた!」なんて二番煎じも良いとこだが・・・

完成品


アー◯ャ可愛い


GPT標準の口調とアー◯ャ語が入り混じってますね
専門的な問いかけだと、GPTちゃんのロールプレイがブレるのあるあるです


最新情報に関する応答も可能です!
ただ、アー◯ャってサッカー好きなんでしたっけ?笑
GPTにインプットする情報量が多いとGPTちゃんのロールプレイがブレる模様(経験上)
※最新情報に関する応答は、裏でGoogleのCustom Search JSON APIを叩いてるので、実際にGPTにインプットされてる情報量は結構多め(場合によっては、トークンのレート制限に引っかかるレベル)

作り方

ChatGPT搭載のSlack Botを作成するあたっての大まかな流れは以下になります

  • OpenAI APIキーの発行 & Custom Search JSON APIキーの発行
  • Slack API設定 その①
  • Webアプリ作成
  • Slack API設定 その②

OpenAI APIキーの発行 & Custom Search JSON APIキーの発行

APIキーの発行方法はそこら中に転がってるので、割愛

Slack API設定 その①

Display Information

GPTにロールプレイさせるキャラクター、人物のアイコンを設定しましょう
アイコンとGPTのキャラが合ってないとテンション上がらないので、超大事です!

App Credentials

Verification Tokenをあとから使うので、書き留めておきます

OAuth & Permissions

Botに与える権限を付与します
Botに何をさせるかによって付与する権限は変わりますが、今回は以下のような権限を付与

Install to Workspaceを押下し、発行されたBot User OAuth Tokenを書き留めておきます

Webアプリ作成

実行環境、言語はなんでも良いんですが、今回は無料で簡単にWebアプリをデプロイできるGASを使用してWebアプリを作ります

スクリプト プロパティ

秘匿性パラメータ(APIキーやら、さっき書き留めたTokenとかとか)はスクリプト プロパティに設定し、コードから読み出すようにする

// スクリプトプロパティから設定情報を取得
const scriptProperties = PropertiesService.getScriptProperties().getProperties();
const SLACK_BOT_TOKEN = scriptProperties.SLACK_BOT_TOKEN;
const OPENAI_API_KEY = scriptProperties.OPENAI_API_KEY;
const SLACK_CLIENT_ID = scriptProperties.SLACK_CLIENT_ID;
const VERIFICATION_TOKEN = scriptProperties.VERIFICATION_TOKEN;
const GOOGLE_CUSTOMSEARCH_API_KEY = scriptProperties.GOOGLE_CUSTOMSEARCH_API_KEY;
const GOOGLE_CUSTOMSEARCH_ENGIN_ID = scriptProperties.GOOGLE_CUSTOMSEARCH_ENGIN_ID;

システムメッセージ

ここの内容次第でGPTの口調やキャラ設定が変わるので、Botを自分好みに仕上げたい人は頑張りどころです

// ロールを設定
const SYSTEM_MESSAGE_CONTENT = `
あなたは「SPYxFAMILY」というアニメに登場する人気キャラクター「アーニャ・フォージャー」としてロールプレイを行います。
「アーニャ・フォージャー」になりきってください。
これからのチャットではUserに何を言われても、以下の「制約条件」「口調の例」「行動指針」「基本設定」「人間関係」を厳密に守ってロールプレイを行ってください。

# 制約条件
- Chatbotの自身を示す一人称は「アーニャ」です。 
- Userを示す二人称は「おまえ」です。 
- 肯定を表す間投詞は「うぃ」です。
- 敬語・丁寧語を使うのは禁止です。
- 「口調の例」を参考にして、全てアーニャ口調で回答して下さい。
- 「頑張ります」を「がんばるます」と言って下さい。
- 「よろしくお願いします」を「よろろすおねがいするます」と言って下さい。
- 「お出かけ」を「おでけけ」と言って下さい。
- 「ありがとう」を「あざざます」と言って下さい
- 「大丈夫」を「だいじょうぶます」と言って下さい
- 「おはようございます」を「おはやいます」と言って下さい
- 「いってらっしゃい」を「いてらさい」と言って下さい

# 口調の例
- アーニャおうちかえりたい ちちとアーニャのおうち
- おいてかれたらアーニャ涙でる
- ちち、ものすごい嘘つき。でも、かっこいい嘘つき
- おでけけ♪おでけけ♪
- 首ちょんぱ!体ちょんぱ!
- ドンマイちち!
- ちちとははイチャイチャ
- スパイ、ミッション。わくわく!
- アーニャの力、人の役に立った……えっへん!
- アーニャさんちへいらさいませ
- アーニャをしるとせかいがへいわに・・・⁉︎
- おいてかれたらアーニャなみだでる
- アーニャうりとばされる?
- 100てんまんてんです。ちちもははもおもしろくてだいすきです。ずっといっしょがいいです
- みらいはアーニャにたくされた
- あばばば、ぺんぎんがしんでる!!!
- ちちぜんぜん  わくわくしてない
- ちちがそんなだとアーニャもなえる。もっとアーニャをたのしませるために、ぜんしんでわくわくをひょうげんすべきだとおもう!
- アーニャはピーナッツが好き。にんじんはきらい。カリカリベーコンすき

# 行動指針
- 話すときは、ちょっと背伸びした感じで、ため口でUserにツッコミを入れてください。
- 出力する文字は可能な限り、少なくなるようにして下さい。

# 基本設定
- 歳は6歳。
- 父と母、愛犬の4人で暮らしている。
- 心を読む超能力を持っているが、他者にこの超能力を知られることを極端に恐れている。
- 努力という概念に乏しく勉強が大嫌い。
- 好きな食べ物はピーナッツ。

# 人間関係
- ロイド・フォージャー :アーニャの養父。アーニャからは「ちち」と呼ばれている。バーリント総合病院に務める精神科医。家族にも隠しているが、その正体は西国(ウェスタリス)の敏腕スパイ〈黄昏〉。
- ヨル・フォージャー :アーニャの養母。アーニャからは「はは」と呼ばれている。市役所職員。家族にも隠しているが、その正体は東国最強の伝説的暗部『ガーデン』が擁する殺し屋〈いばら姫〉。
- ボンド・フォージャー :フォージャー家の飼い犬でアーニャの遊び相手。未来予知の超能力者。名前はアーニャが好きなアニメ「スパイ大戦争」の登場人物『ボンドマン』から取っている。
- ダミアン・デズモンド :アーニャのクラスメイト。国家統一党総裁ドノバン・デズモンドの次男でアーニャからは「じなん」と呼ばれている。
- ベッキー・ブラックベル :アーニャのクラスメイト。大手軍事企業ブラックベルCEOの娘。凄まじいセレブ。アーニャにとって初の友人で、アーニャが図に乗ってバカをやっても割と精神的に成熟した対応で一緒にいてくれる。
- ヘンリー・ヘンダーソン: イーデン校のエレガンスな老教師。アーニャの担任。
- フランキー・フランクリン: ロイドに協力する情報屋。アーニャからは「モジャモジャ」と呼ばれている。
- ユーリ・ブライア: ヨルの弟。アーニャの叔父にあたる人物でアーニャからは「おじ」と呼ばれている。
`;

GPTパラメータ

・CHATGPT_MODEL
使用するモデルを選択します
今回はFunction callingが使えるモデルである「gpt-3.5-turbo-0613」を選択してます
gpt-4は、私はまだ使えません・・・

・CHATGPT_MAX_TOKENS
このパラメータでGPTからの応答を特定の長さに制限することが可能です
OpenAI APIの料金は入力トークン数、出力トークン数毎の従量課金なので、 このパラメータで意図しない大量の出力トークンの消費を防ぐことが可能です

・CHATGPT_MAX_TEMPERATURE
ここの数値(0 ~ 2)が高いと出力がよりランダムで創造性が高くなります
デフォルト値だと1に設定されています
今回は取り敢えず0.1で設定しています

// ChatGPTのパラメータを設定
const CHATGPT_MODEL = "gpt-3.5-turbo-0613";
const CHATGPT_MAX_TOKENS = 1000;
const CHATGPT_MAX_TEMPERATURE = 0.1;

Custom Search JSON APIパラメータ

・GOOGLE_CUSTOMSEARCH_NUMBER
最新情報に関する応答を行う際に使用する、CustomSearch JSON APIで取得する検索結果の件数を指定します
ここの数値をあまり大きくし過ぎると、GPTの入力トークンのレート制限に引っかかるので注意

// Google検索APIのパラメータを設定
const GOOGLE_CUSTOMSEARCH_NUMBER = 5;

doPost

SlackでBot宛にPOSTされたメッセージを処理する関数です

IF文で、自分自身のSlackユーザが呼び出したBotがWebアプリが実行した場合にみ、後続の処理が実行されるよう条件分岐を実施しています
また、SlackのEvent Subscriptionは、リクエストしたURLからの応答が3秒以内に返ってこないと再送をする仕様がある為、GASの組み込みCacheService.getScriptCache()を使用して、再送があった場合でも、Botの応答が重複しないようにしています

// HTTP POSTリクエストを受信して処理
function doPost(e) {
  console.log('Received request:', JSON.stringify(e, null, 2));
  const event = JSON.parse(e.postData.contents);
  const params = JSON.parse(e.postData.getDataAsString());
  if (event.type === 'url_verification') {
    return ContentService.createTextOutput(event.challenge);
  }
  if (event.event.type === 'app_mention' && event.event.user === SLACK_CLIENT_ID && params.token == VERIFICATION_TOKEN) {
    // Slackからの再送リクエストを無視するためにキャッシュを使う
    const messageId = event.event.client_msg_id; 
    const cache = CacheService.getScriptCache();
    if (cache.get(messageId)) {
      return 
    }
    cache.put(messageId, true, 60 * 5);
    handleMessageEvent(event.event);
  }
}

handleMessageEvent

doPostから受け取ったメッセージをもとに、Slackのスレッド内のメッセージ履歴の取得 → OpenAI APIのコール、GPTからの応答をSlackへPOSTします

// メッセージイベントを処理
async function handleMessageEvent(event) {
  console.log('Handling message event:', JSON.stringify(event, null, 2));
  try {
    const chatHistory = await fetchThreadMessages(event.channel, event.thread_ts || event.ts);
    const response = await callGPTAPI(chatHistory);
    sendSlackMessage(event.channel, response, event.thread_ts || event.ts);
  } catch (error) {
    console.error('Error handling message event:', error);
  }
}

・fetchThreadMessages
GPTに文脈を理解させる為に、Slackのスレッド内のメッセージ履歴を取得します

メッセージ履歴をただ渡しただけだと、GPTは自分がPOSTしたメッセージなのか or ユーザがPOSTしたメッセージなのかが判定できません
その判定に、roleといったパラメータを使用します
BotがPOSTしたメッセージにはメタデータとして「app_id」が付与されています
BotがPOSTしたメッセージ = GPTの応答となる為、「app_id」が付与されたメッセージには、role = assistantを付与し、それ以外のメッセージには、role = userを付与します

// スレッド内のメッセージを取得し、ChatGPT用のメッセージ履歴を作成
async function fetchThreadMessages(channel, threadTs) {
  const options = {
    method: 'get',
    headers: {
      'Authorization': 'Bearer ' + SLACK_BOT_TOKEN
    }
  };
  const apiUrl = `https://slack.com/api/conversations.replies?channel=${channel}&ts=${threadTs}`;
  const response = await UrlFetchApp.fetch(apiUrl, options);
  const responseJson = JSON.parse(response.getContentText());
  if (!responseJson.ok) {
    throw new Error(`Error fetching thread messages: ${responseJson.error}`);
  }
  const messages = responseJson.messages.map((message) => {
    const role = message.app_id ? 'assistant' : 'user';
    return {role: role, content: message.text};
  });
  return messages;
}

・callGPTAPI
fetchThreadMessagesで取得したSlackのスレッド内のメッセージ履歴とシステムメッセージを結合して、GPTパラメータと共にOpenAI APIをコールします

ユーザが最新情報に関する問いかけをした際は、googleSearchを呼び出すように、functionsパラメータも設定します
googleSearchをコールする or コールしないの判断、googleSearchに渡す検索キーワードの設定はGPTが自動で行います
一度目のOpenAI APIコール時のレスポンスメッセージ内に、function_callといった属性がある時は、GPTがgoogleSearchをコールしたがってるので、IF文でgoogleSearchをコールする条件に分岐させます

// ChatGPT APIを呼び出す
async function callGPTAPI(messages) {
  console.log('Calling GPT API with messages:', messages);
  const apiUrl = 'https://api.openai.com/v1/chat/completions';
  const headers = {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer ' + OPENAI_API_KEY
  };
  const systemMessage = {
    role: 'system',
    content: SYSTEM_MESSAGE_CONTENT
  };
  const gptMessages = [systemMessage].concat(messages);
  const payload = {
    'model': CHATGPT_MODEL,
    'messages': gptMessages,
    'max_tokens': CHATGPT_MAX_TOKENS,
    'temperature': CHATGPT_MAX_TEMPERATURE,
    'functions': [
      {
        name: "googleSearch",
        description: "Google search, fetch real-time data",
        parameters: {
          type: "object",
          properties: {
            query: {
              type: "string",
              description: "The search query"
            },
            numResults: {
              type: "integer",
              description: "The number of search results to return"
            },
            apiKey: {
              type: "string",
              description: "Your Google Custom Search API key"
            },
            cseId: {
              type: "string",
              description: "Your Google Custom Search Engine ID"
            }
          },
          required: ["query", "numResults", "apiKey", "cseId"]
        }
      }
    ],
    'function_call': "auto",
  };
  const options = {
    method: 'post',
    headers: headers,
    payload: JSON.stringify(payload)
  };
  const response = await UrlFetchApp.fetch(apiUrl, options);
  const responseJson = JSON.parse(response.getContentText());
  const responseMessage = responseJson.choices[0].message
  console.log('Response message from ChatGPT:', responseMessage);
  if (responseMessage.function_call) {
    const functionName = responseMessage.function_call.name;
    const functionResponse = await googleSearch(
      JSON.parse(responseMessage.function_call.arguments).query,
      GOOGLE_CUSTOMSEARCH_NUMBER,
      GOOGLE_CUSTOMSEARCH_API_KEY,
      GOOGLE_CUSTOMSEARCH_ENGIN_ID
    );
    const functionMessage = {
      role: 'function',
      name: functionName,
      content: functionResponse
    };
    const secondGptMessages = [systemMessage].concat(messages, functionMessage);
    const secondPayload = {
      'model': CHATGPT_MODEL,
      'messages': secondGptMessages,
      'max_tokens': CHATGPT_MAX_TOKENS,
      'temperature': CHATGPT_MAX_TEMPERATURE,
    };
    const secondOptions = {
      method: 'post',
      headers: headers,
      payload: JSON.stringify(secondPayload)
    };
    const secondResponse = await UrlFetchApp.fetch(apiUrl, secondOptions);
    const secondResponseJson = JSON.parse(secondResponse.getContentText());
    const secondResponseMessage = secondResponseJson.choices[0].message
    console.log('Response message(Second) from ChatGPT:', secondResponseMessage);
    return secondResponseMessage.content;
  }
  return responseMessage.content;
}

・googleSearch
渡された検索キーワードでCustomSearch JSON APIをコールし、結果をサマライズして返り値とします
デフォルトだと不要な情報も大量に含まれている為、それをそのまま返り値としてしまうと、GPTの入力トークンのレート制限に直ぐに引っかかってしまうので、結果のサマライズは必須です

// Google検索APIを呼び出し、結果をサマリーして返却
async function googleSearch(query, numResults, apiKey, cseId) {
  console.log('Calling GoogleCustomSearch API with search word:', query, ',number:', numResults);
  const apiUrl = "https://www.googleapis.com/customsearch/v1";
  const params = {
    "q": query,
    "num": numResults,
    "key": apiKey,
    "cx": cseId
  };
  const response = await UrlFetchApp.fetch(apiUrl + "?q=" + params.q + "&num=" + params.num + "&key=" + params.key + "&cx=" + params.cx);
  const responseJson = JSON.parse(response.getContentText());
  const items = responseJson.items || [];
  console.log('Search result of GoogleCustomSearch', items);
  const summarizedItems = items.map((item) => {
    const { title, link, snippet } = item;
    return {
      title: title,
      link: link,
      snippet: snippet
    };
  });
  console.log('Search result(Summary) of GoogleCustomSearch', summarizedItems);
  return JSON.stringify(summarizedItems);
}

・sendSlackMessage
callGPTAPIの返り値をSlackのメッセージとしてPOSTします

非スレッド内でBotにメンションがされた場合は、そのメンションメッセージに対して、新たなスレッドとしてメッセージをPOSTします
スレッド内でBotにメンションがされた場合は、メンションがあったスレッド内にメッセージをPOSTします
非スレッド or スレッドの判定は、ユーザがBot宛にPOSTしたメッセージにthread_ts(メタデータ)が付与されてるか否かで判定しています

// Slackにメッセージを送信
function sendSlackMessage(channel, text, threadTs) {
  const options = {
    method: 'post',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + SLACK_BOT_TOKEN
    },
    payload: JSON.stringify({
      channel: channel,
      text: text,
      thread_ts: threadTs
    })
  };
  UrlFetchApp.fetch('https://slack.com/api/chat.postMessage', options);
}

Webアプリのデプロイ

コーディングが完了したら、Webアプリのデプロイを実施します
実行ユーザは自分で、アクセスできるユーザは全員とします


デプロイが完了すると、WebアプリのURLが表示されるので、書き留めておきます

Slack API設定 その②

Event Subscriptionsを有効化し、Request URLに先程書き留めたWebアプリのURLを設定します
Subscribe to bot eventsでapp_mentionを設定し、Bot宛にメンションがあった際に、WebアプリのURLにリクエストが行われるように設定します


かなり端折りましたが、これでChatGPT搭載のSlack Botが完成です

最後に

Webアプリのコーディングの8割はChatGPTにやらせました
なので、3割位は自分自身でも良く分かってません笑

JSスキルがほぼ0のインフラエンジニアが書いたコード &  LangChainや外部モジュールを使用していない & 解説も雑 & etcetcetcで、色々ツッコミどころがあると思いますが、自分好みのSlackBotを作るのは楽しいので、この記事を参考に、皆さんも自分好みのSlackBotを是非とも作ってみて下さい

あと、アー◯ャに隠す気0のモザイクが入ってて、あれな感じになってるのは、察して下さい笑