📄

SlackのスレッドからCosense(旧Scrapbox)ページを作ってくれるBotを作った話

2024/07/25に公開

経緯

社内のナレッジはすべてCosense(旧Scrapbox)を使っていて、slackには残さないようにしている、基本的に何かの議論・質問をcosenseに残すようにしている、その御蔭で疑問に思ったことのほとんどがCosense上で見つかるという、特に新入社員にとってはとても嬉しい環境が備わっている。
https://scrapbox.io

ただ、場合によってslack上で話が色々と先に広げられてしまい、その内容をCosenseに残したいというときがある、その時はCosenseページを作り、議論をそっちに移すようにしているが、元の話がslack上に残ってしまい、後から見返すときに、一回Slackを経由しないと話の全体がわからないという不便さがある、それを改善したくSlack Bot作る決心に至った。

機能

会話が盛り上がっているスレッドに@make-cosense-pageとメンションすると、そのスレッドの内容を取得し、cosenseの構文に変換する、そしてトークン付きのURLを発行し、そのURLをメンションしたスレッドに返信する。

Image from Gyazo

技術スタック

Vercel serverless functions
verlceの使い勝手の良さからvercel functionsを選んだ。

Vercel KV
本来はそのままSlack上に返信すればいいのでは?と思っていたが、SlackのAPIの仕様上3000文字以上を送れないというのと、Slackに送信すると、そこからコピペしたときに場合によって書式が変わってしまうような気がして、データを一時的に保存するためのDBとしてvercel KVを使っている。

Bun
もう時代はBun、TSのセットアップをする必要はなく、vercelのrepositoryからテンプレートをクローンしてきて、そのままbunで実行して動作確認できたので、圧倒的に時間短縮ができた。VerelのCIも既にBunを対応しているので、個別に設定する必要がないのもすごい便利。

Jest
Bun Testでの良かったが、テスト実行した後のログ表示があまり好きじゃないなと思ったので、途中でやめて、安定・安心のJestを入れた。

Slack Nodejs SDK
今回は送信だけなので、@slack/web-apiだけで大丈夫だった。

React
Reactは慣れているのと、そのままAPIサーバーからHTMLとしてトランスパイルし、配信できるので、HTMLマークアップが楽になるとうい点から、テンプレートエンジンの代わりとして使っている、クライアントのhydrationなどは特にやっていない。

Slack Bot作成手順

developer-program にアクセスする

your appsを押下

image-8a713180f3e7bf20e4bd573c0ae1f607

sign in your slack accountをクリックし、Slackにログインする

image-4d7369e4777a54d5113b625a50debb73

image-c3a27879fda20834d02d02be5959cd4a

アプリ作成

Create New Appをクリックし、新しいアプリを作成する

image-abac0b54c61bcef7589adcf401b8b9ab

今回はFrom Scratchを選ぶ

image-821adf27b30bcfe408efb84e1256cafa

アプリ名とインストールしたいワークスペースを選択し、Create Appを押下

image-57347283e9cc68901d59c00c2f0728e0

そうすると、アプリのDashboard画面遷移される

image-d090f008ef1abe9b6ac9df9cc5a237a7

Event Subscriptionの有効化

アプリ作成できたらEvent Subscriptionを有効にし、エンドポイントのURLを入力する

入力したタイミングで、SlackからPOSTリクエストが送られるので、request bodyに含まれているchallengeコードをresponseとしてそのまま返してあげれば、承認が通る。

image-d9f6db95d19e70fa9d38f882dd5b5725

成功すると👇こんな感じ

image-5535a838eb669693310c3599ff360749

Botに権限を付与する

サイドバーからOAuth & Permissionsをクリックし、ちょっと下にスクロールし、Scopeセクションからbotに権限を付与する

image-42958ad682f6a623a4e5d762a8f251f0

まず、メンション関連のapp_mentions:readを付与する

image-b36c6bef46027527078fac19784064b1

そこから、スレッドを読み込むためにchannel:history権限、メッセージ送信するためにchat:write権限、スレッド内でユーザーがメンションされたときにそのユーザーの情報を読み込むためにusers:read権限を付与する

image-e3f3672afbb913aaace23c00d216ba57

workspaceにインストールする

OAuth Tokens for Your WorkspaceセクションにあるInstall To Your Workspaceボタンを押下
image-6106ad49cf85ca949eeb7782d349828c

そうすると、承認画面に遷移される

image-962fecc4416ecf0c00ba982a30579895

許可すると、Dashboardに戻り、oauth tokenが表示される

image-02169e748551ce00cb0ccf8b6f3649ad

このトークンを使いSlack SDKの認証を通す。

コード

中心的なところだけサクッと説明する
使ってみないとわからないバグがたくさん存在しそうなので、あくまで参考程度にみていただければ 🙇‍♂️

メンションされたらslack側からeventデータがJSONでなげられてくるので、その中からchannelthread_tsを使い、conversationを読み取る

import { WebClient } from "@slack/web-api";

const token = process.env.SLACK_TOKEN!;
const slackClient = new WebClient(token, {
  retryConfig: {
    retries: 0,
  },
});

const { event } = req.body;
const replies = await slackClient.conversations.replies({
    token: token, // 👈 Slack Botをworkspaceにインストールしたら、表示されるトークン
    channel: event.channel,
    ts: event.thread_ts,
    inclusive: true, // 👈 ページネーションするときに便利なオプション、一応有効にしている
});

次はスレッド内のすべてのメッセージを集める

 import { WebClient } from "@slack/web-api";
 
 const token = process.env.SLACK_TOKEN!;
 const slackClient = new WebClient(token, {
   retryConfig: {
     retries: 0,
   },
 });

 const { event } = req.body;
+ let messages = "";
+ const botRes = await slackClient.auth.test();
+ const botId = botRes.user_id;
 const replies = await slackClient.conversations.replies({
     token: token, // 👈 Slack Botをworkspaceにインストールしたら、表示されるトーク
     channel: event.channel,
     ts: event.thread_ts,
     inclusive: true, // 👈 ページネーションするときに便利なオプション、一応有効にしている
 });

+ for (const index in replies.messages) {
+     const message = replies.messages[index as never];
+     const text = replies.messages[index as never].text;
+     // bot自身がメンションされたメッセージは省く 
+     if (text === `<@${botId}>` || message.user === botId) {
+       continue;
+     }
+     messages += `\n<@${message.user}>\n${text}\n`;
+ }

次は、集めたメッセージをparseして、Cosenseの構文に変換していく

parser.ts
import { UsersInfoResponse } from "@slack/web-api";

const bold = /\*(.+)?\*/g;
const italic = /\_(.+)?\_/g;
const strike = /\~(.+)?\~/g;
// codeblockは必ず改行が入ると想定されるので、gフラグを付けない
const codeBlockStart = /```(.+)?/;
const codeBlockEnd = /(.+)(```)$/;
const codeBlock = /```(.+)?```$/;
const list = /\* (.+)?/g;
const quote = /^\&\gt\; (.+)?/;
const listText = /^(||▪︎ ).+$/;

const mention = /\<\@(.+?)\>/g;
const externalLink = /<((?:http|https):\/\/[^>]+)>/g;
const gyazoLink = /https:\/\/nota.gyazo.com\/(.+)?/g;

const leadSpace = /^( *)/;

const patternList = [
  bold,
  italic,
  strike,
  codeBlockStart,
  list,
  mention,
  externalLink,
  gyazoLink,
];

export function parseMessagesToCosenseSyntax(
  messages: string,
  users: Record<string, UsersInfoResponse>
) {
  const messagesArray = messages.split("\n");
  let cosenseMessage = "";
  for (let index = 0; index < messagesArray.length; index++) {
    const message = messagesArray[index];
    let parsedMessage = message;
    patternList.forEach(() => {
      switch (true) {
        case bold.test(parsedMessage):
          bold.lastIndex = 0;
          parsedMessage = parsedMessage.replaceAll(bold, "[* $1]");
          break;
        case italic.test(parsedMessage):
          italic.lastIndex = 0;
          parsedMessage = parsedMessage.replaceAll(italic, "[/ $1]");
          break;
        case strike.test(parsedMessage):
          strike.lastIndex = 0;
          parsedMessage = parsedMessage.replaceAll(strike, "[- $1]");
          break;
        case quote.test(parsedMessage):
          parsedMessage = parsedMessage.replace(quote, "> $1");
          break;
        case codeBlockStart.test(parsedMessage):
          {
            const matched = parsedMessage.match(codeBlock);
            if (matched?.[1]) {
              parsedMessage = "code:typescript\n" + "  " + matched[1] + "\n";
              break;
            }

            parsedMessage = parsedMessage.replace(
              codeBlockStart,
              "code:typescript\n $1\n"
            );

            index++;
            while (!codeBlockEnd.test(messagesArray[index])) {
              parsedMessage += " " + messagesArray[index] + "\n";
              index++;
              if (index >= messagesArray.length) {
                break;
              }
            }

            let msg = messagesArray[index].replace(codeBlockEnd, "$1\n");
            if (!msg.startsWith(" ")) {
              msg = " " + msg;
            }
            parsedMessage += msg;
          }
          break;
        case mention.test(parsedMessage): {
          mention.lastIndex = 0;

          const matchedItems = parsedMessage.match(mention)!;
          const arrayGroup = matchedItems.map((match) => {
            const userId = match.match(/\<\@(.+?)\>/)![1];
            return [match, users[userId]?.user?.name || userId];
          });

          arrayGroup.forEach(([sourceText, userId]) => {
            parsedMessage = parsedMessage.replace(
              sourceText,
              `[${userId}.icon]`
            );
          });

          break;
        }
        case externalLink.test(parsedMessage):
          {
            externalLink.lastIndex = 0;
            gyazoLink.lastIndex = 0;

            // codeblockの中にリンクがある場合は、そのままにする
            if (parsedMessage.startsWith("code:typescript")) {
              break;
            }

            const url = [...parsedMessage.matchAll(externalLink)][0][1];
            if (gyazoLink.test(url)) {
              parsedMessage = parsedMessage.replaceAll(externalLink, "[$1]");
              break;
            }

            const { pathname, hostname } = new URL(url);
            if (pathname === "/") {
              parsedMessage = parsedMessage.replaceAll(externalLink, url);
              break;
            }

            parsedMessage = parsedMessage.replaceAll(
              externalLink,
              `[${hostname} | ${pathname.replace(/^\//, "")} ${url}]`
            );
          }
          break;
        default:
          if (listText.test(parsedMessage.trim())) {
            const listItem = parsedMessage.replace(/(\• |\◦ |\▪)/, " ");
            const indentCount = leadSpace.exec(listItem)?.[1]?.length;
            if (indentCount) {
              parsedMessage =
                " ".repeat(Math.floor((indentCount - 1) / 4)) + listItem;
            }
            parsedMessage = listItem;
          }

          break;
      }
    });
    cosenseMessage += parsedMessage + "\n";
  }
  return cosenseMessage;
}

初めてこういうparserを書くので、正規表現まみれになってしまったが、もしもっとパフォーマンスのいい書き方ややり方があれば、ぜひコメントで教えてください🙇‍♂️

次はtokenの発行を行う、今回はaes-256-cbc方式の暗号化アルゴリズムを使う、理由はサードパーティライブラリーが不要で、復号が可能だからである、そしてセキュリティーを考慮して、tokenの有効期限を3分とする

import crypto from "node:crypto";

const secretKey = process.env.TOKEN_SECRET_KEY!;
export const TOKEN_VALID_DURATION = 60000 * 3; // 3 minutes

export function generateToken() {
  // タイムスタンプを含める
  const timestamp = Date.now();
  const payload = { timestamp };

  // JSON文字列に変換
  const payloadString = JSON.stringify(payload);

  // IVの生成 (16バイト)
  const iv = crypto.randomBytes(16);

  // 暗号化
  const cipher = crypto.createCipheriv("aes-256-cbc", secretKey, iv);
  let encrypted = cipher.update(payloadString, "utf8", "hex");
  encrypted += cipher.final("hex");

  // IVと暗号文を結合して返す
  return `${iv.toString("hex")}:${encrypted}`;
}

export function verifyToken(token: string) {
  try {
    // IVと暗号文を分割
    const [ivHex, encrypted] = token.split(":");
    const iv = Buffer.from(ivHex, "hex");

    // 暗号化を解除
    const decipher = crypto.createDecipheriv("aes-256-cbc", secretKey, iv);
    let decrypted = decipher.update(encrypted, "hex", "utf8");
    decrypted += decipher.final("utf8");

    // JSON文字列をオブジェクトに変換
    const payload = JSON.parse(decrypted);

    // タイムスタンプをチェック
    const currentTime = Date.now();
    if (currentTime - payload.timestamp > TOKEN_VALID_DURATION) {
      return {
        valid: false,
        tokenExpired: true,
        message: "Token is expired",
      };
    }

    return { valid: true, message: "Token is valid" };
  } catch (error) {
    return { valid: false, message: "Invalid token" };
  }
}

ivとは何やぞという方に 👇 (自分もわからなかったので)

createDecipheriv関数の iv 引数は、暗号化アルゴリズムに使用される初期化ベクトル(Initialization Vector、IV)を指定するためのものです。初期化ベクトルは、暗号化と復号化の過程でデータのランダム性を増加させ、同じ平文データが暗号化されるたびに異なる暗号文を生成するのに役立ちます。

by chatGPT

次はtokenをキーとしてKVにデータを保存するので、先にKVインスタンスを定義しておく👇

import { createClient } from "@vercel/kv";

const kvClient = createClient({
  url: process.env.KV_REST_API_URL,
  token: process.env.KV_REST_API_TOKEN,
});

export async function savePage({ key, value }: { key: string; value: string }) {
  await kvClient.set(key, value);
}

KVに保存し、スレッドにトークン付きURLを返信する。

 import { WebClient } from "@slack/web-api";
 
 const token = process.env.SLACK_TOKEN!;
 const slackClient = new WebClient(token, {
   retryConfig: {
     retries: 0,
   },
 });
 
 const { event } = req.body;
 let messages = "";
 const botRes = await slackClient.auth.test();
 const botId = botRes.user_id;
 const replies = await slackClient.conversations.replies({
     token: token, // 👈 Slack Botをworkspaceにインストールしたら、表示されるトーク
     channel: event.channel,
     ts: event.thread_ts,
     inclusive: true, // 👈 ページネーションするときに便利なオプション、一応有効にしている
 });
 
 for (const index in replies.messages) {
     const message = replies.messages[index as never];
     const text = replies.messages[index as never].text;
     // bot自身がメンションされたメッセージは省く 
     if (text === `<@${botId}>` || message.user === botId) {
       continue;
     }
     messages += `\n<@${message.user}>\n${text}\n`;
 }

+ const parsedMessages = parseMessagesToCosenseSyntax(messages, users);
+ const token = generateToken();
+ await savePage({ key: token, value: parsedMessages });
+ const pageUrl = `${req.protocol}://${req.hostname}/api/page/${token}`;
+ await sendChatMessage({
+     pageUrl,
+     channel: event.channel,
+     thread_ts: event.thread_ts,
+ });


最後にスレッドに返信されたトークン付きのURLを踏んだときに表示されるルート定義し、トークンの有効性を確認し、有効であれば、KVから読み取り、画面に表現する。


router.get("/page/:token", async (req, res) => {
  const { token } = req.params;
  const validateResult = verifyToken(token);

  if (!validateResult.valid) {
    res
      .status(401)
      .send("Invalid token" + validateResult.tokenExpired ? " (expired)" : "");
    return;
  }

  const page = await readPageFromToken(token);
  if (page == null) {
    res.status(404).send("Page not found");
    return;
  }

  res.send(renderView(page)); 
});

renderView 👇 のようになっている。

import { renderToString } from "react-dom/server";

export const renderView = (content: string) => {
  return renderToString(<Page content={content} />);
};

以上。

Helpfeel Tech Blog

Discussion