💬

5分で作るGPT-4oと会話できるSlackBOT

2024/06/17に公開
1

GPT-4o を社内の Slack でアシスタント的に使いたく BOT を実装したので作り方を解説します
ソースコードとかわからなくてもいいから作りたいという方からどう実装したらいいのか知りたい方まで読んでいただけるよう「爆速構築」パートと「解説」パートに分かれています

この SlackBOT の仕様

  • メンションされるとそのスレッドに返答してくれます
  • メンションを受け取った時点で 👀 のリアクションを付けます
  • スレッドの内容はコンテキストとして認識します
  • スレッド内の添付された画像もすべてコンテキストとして認識します

スレッドの途中でメンションしても反応してくれるようにしました

爆速構築

1. Glitch で Remix

以下を踏むだけ
https://glitch.com/edit/#!/remix/peridot-somber-galliform

2. Slack アプリを作る

こちらにアクセスして
https://api.slack.com/apps?new_app=1

マニフェストで一発作成

{Glitchプロジェクト名}は Remix したプロジェクト名に書き換えてください

display_information:
  name: GPT-4o
  description: GPT-4oと会話してみよう
  background_color: "#2c2d30"
features:
  bot_user:
    display_name: GPT-4o
    always_online: false
oauth_config:
  scopes:
    bot:
      - app_mentions:read
      - channels:history
      - chat:write
      - files:read
      - reactions:write
settings:
  event_subscriptions:
    request_url: https://{Glitchプロジェクト名}.glitch.me/slack/events
    bot_events:
      - app_mention
  org_deploy_enabled: false
  socket_mode_enabled: false
  token_rotation_enabled: false

3. Slack アプリをインストール

作成直後の画面にインストールと促されるのでいわれるがままにインストールしてください
見失ったら Settings セクションの Install App から実行できます

4. Glitch の環境変数を設定

4 つあります

変数名 値の取り方
SLACK_SIGNING_SECRET Settings の Basic Information の App Credentials の Signing Secret
SLACK_BOT_TOKEN Features の OAuth & Permissions の Bot User OAuth Token
SLACK_BOT_USER_ID Slack のアプリページの詳細のメンバー ID(画像参照)
OPENAI_API_KEY OpenAI API コンソールで発行

5. API の検証

Slack アプリの Features の Event Subscriptions で検証を実施

完成。

解説

使用しているライブラリ

ライブラリ 用途
@slack/bolt Slack のイベントを受け取るため
@tryfabric/mack Markdown を Slack Message Blocks に変換するため
axios OpenAI API を叩くため
express ファイルを OpenAI API に提供するため

Bolt の基本構造

アプリインスタンスを生成して、そのインスタンスにイベントを登録していくスタイルです

const { App, ExpressReceiver } = require("@slack/bolt");

const app = new App({
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  token: process.env.SLACK_BOT_TOKEN,
  receiver: new ExpressReceiver({
    signingSecret: process.env.SLACK_SIGNING_SECRET,
    customPropertiesExtractor: ({ headers }) => ({ headers }),
  }),
});

app.use(async ({ payload, context, next }) => {
  // Slackからのリクエストのバリデーション
});

app.event("app_mention", async ({ event, client }) => {
  // 生成した回答をSlackのスレに返す
});

(async () => {
  await app.start(process.env.PORT || 3000);
  console.log("⚡️ Bolt app is running!");
})();

リクエストのバリデーション

Slack からのリクエストのうち編集時に飛んでくるリクエストとリトライ時に飛んでくるリクエストは無視するようにします

 app.use(async ({ payload, context, next }) => {
+  if (payload.edited) return;
+  if (context.headers["x-slack-retry-num"]) return;
+  await next();
 });

とりあえずリアクションを返す

 app.event("app_mention", async ({ event, client }) => {
+  // とりあえずリアクションを返す
+  client.reactions.add({
+    channel: event.channel,
+    timestamp: event.ts,
+    name: "eyes",
+  });
 });

Bolt は簡単ですね

スレッドの内容を取得する

スレッドの内容を取得するためにはconversations.repliesを使います

 app.event("app_mention", async ({ event, client }) => {
   client.reactions.add({});

+  // スレの投稿を取得する
+  const messages = [];
+  let next_cursor;
+  if (event.thread_ts) {
+    do {
+      const replies = await client.conversations.replies({
+        channel: event.channel,
+        ts: event.thread_ts,
+        cursor: next_cursor,
+      });
+      messages.push(...replies.messages.filter((e) => e.ts < event.ts));
+      next_cursor = replies.response_metadata.next_cursor;
+    } while (next_cursor);
+  }
 });

Open API 形式のリクエストへのコンバーターを実装

OpenAI API にリクエストを送るためには、リクエストの形式を変換する必要があります
メンション部分は OpenAI からの返答でUxxxxさんとなる時があるので削除しています
await ないですが画像を処理する時のために async にしています

+const convertMessageToOpenAIFormat = async (message) => {
+  const text = message.text.replace(/<@U.{10}>/g, "").trim();
+  return {
+    role: message.user === process.env.SLACK_BOT_USER_ID ? "assistant" : "user",
+    content: [
+      ...(text ? [{ type: "text", text }] : []),
+    ],
+  };
+};

OpenAI API にリクエストを送る

system の role に Slack の BOT であることを示すメッセージを追加しています
ここを変えると癖のある BOT になるかもしれません

+const axios = require("axios");

 app.event("app_mention", async ({ event, client }) => {
   client.reactions.add({});

   const messages = [];
   let next_cursor;
   if (event.thread_ts) {}

+  // OpenAI APIで回答を生成する
+  const response = await axios.request({
+    method: "post",
+    url: "https://api.openai.com/v1/chat/completions",
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
+    },
+    data: {
+      model: "gpt-4o",
+      messages: [
+        {
+          role: "system",
+          content: [
+            {
+              type: "text",
+              text: "あなたはSlackのチャットボットです。",
+            },
+          ],
+        },
+        ...(
+          await Promise.all(
+            [...messages, event].map(convertMessageToOpenAIFormat)
+          )
+        ).filter((e) => e.content.length),
+      ],
+    },
+  });
 });

Slack に回答を返す

 app.event("app_mention", async ({ event, client }) => {
   client.reactions.add({});

   const messages = [];
   let next_cursor;
   if (event.thread_ts) {}

   const response = await axios.request({});

+  // 生成した回答をSlackのスレに返す
+  const text = response.data.choices[0].message.content;
+  await client.chat.postMessage({
+    channel: event.channel,
+    thread_ts: event.ts,
+    text,
+  });
 });

ほんと Bolt は簡単ですね
一応ここまででテキストだけのやり取りはできるようになっています

Slack に BlockKit を使ったメッセージを返す

GPT-4o からしばしば Markdown 形式で回答が来るので BlockKit に変換して返すようにします
Slack は mrkdwn なので、そのまま返すと一部見にくくなってしまいます

+const { markdownToBlocks } = require("@tryfabric/mack");
 app.event("app_mention", async ({ event, client }) => {
   client.reactions.add({});

   const messages = [];
   let next_cursor;
   if (event.thread_ts) {}

   const response = await axios.request({});

   // 生成した回答をSlackのスレに返す
   const text = response.data.choices[0].message.content;
   await client.chat.postMessage({
     channel: event.channel,
     thread_ts: event.ts,
     text,
+    blocks: await markdownToBlocks(text),
   });
 });

ファイルサーバーを立てる

Slack Bolt ではファイルサーバーにはできないので、Express を使って画像を提供します
一時的に保存するディレクトリを作成して、そのディレクトリに画像を保存して提供します

+const fs = require("fs");
+const path = require("path");

 const { App, ExpressReceiver } = require("@slack/bolt");
 const axios = require("axios");
+const express = require("express");

+const fileDirectory = "/tmp/files";

 const convertMessageToOpenAIFormat = async (message) => {};

 const app = new App({});

+const expressApp = express();
+fs.mkdir(fileDirectory, { recursive: true }, (err) => {
+  if (err) throw err;
+});
+expressApp.get("/files/:filename", (req, res) => {
+  res.sendFile(path.join(fileDirectory, req.params.filename), (err) => {
+    if (err) res.status(404).send("File not found");
+  });
+});
+app.receiver.app.use(expressApp);

 app.use(async ({ payload, context, next }) => {});

 app.event("app_mention", async ({ event, client }) => {});

 (async () => {})();

Slack からファイルをダウンロードする

適当なファイル名に変換してダウンロード可能な URL にします

 const convertMessageToOpenAIFormat = async (message) => {
   const text = message.text.replace(/<@U.{10}>/g, "").trim();
   return {
     role: message.user === process.env.SLACK_BOT_USER_ID ? "assistant" : "user",
     content: [
       ...(text ? [{ type: "text", text }] : []),
+      ...(await Promise.all(
+        (message.files ?? []).map(async (file) => {
+          const filename = `${Date.now()}-${file.url_private_download
+            .split("/")
+            .at(-1)}`;
+          const response = await axios.get(file.url_private_download, {
+            headers: {
+              Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`,
+            },
+            responseType: "stream",
+          });
+          response.data.pipe(
+            fs.createWriteStream(path.join(fileDirectory, filename))
+          );
+          return {
+            type: "image_url",
+            image_url: {
+              url: `https://${process.env.PROJECT_NAME}.glitch.me/files/${filename}`,
+            },
+          };
+        })
+      )),
     ],
   };
 };

OpenAI API が受け付けているファイル形式かチェックしていないのでダメなやつが添付されるとエラーになりそうですが、ヨシ!
(OpenAI の仕様変更を気にしたくなかっただけです)

株式会社find | 落とし物クラウド

Discussion

井上大樹井上大樹

GPT-4oからの返答をBlockKitに変換する処理を追加しました