SlackのスレッドからCosense(旧Scrapbox)ページを作ってくれるBotを作った話
経緯
社内のナレッジはすべてCosense(旧Scrapbox)を使っていて、slackには残さないようにしている、基本的に何かの議論・質問をcosenseに残すようにしている、その御蔭で疑問に思ったことのほとんどがCosense上で見つかるという、特に新入社員にとってはとても嬉しい環境が備わっている。
ただ、場合によってslack上で話が色々と先に広げられてしまい、その内容をCosenseに残したいというときがある、その時はCosenseページを作り、議論をそっちに移すようにしているが、元の話がslack上に残ってしまい、後から見返すときに、一回Slackを経由しないと話の全体がわからないという不便さがある、それを改善したくSlack Bot作る決心に至った。
機能
会話が盛り上がっているスレッドに@make-cosense-page
とメンションすると、そのスレッドの内容を取得し、cosenseの構文に変換する、そしてトークン付きのURLを発行し、そのURLをメンションしたスレッドに返信する。
技術スタック
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を押下
sign in your slack accountをクリックし、Slackにログインする
アプリ作成
Create New Appをクリックし、新しいアプリを作成する
今回はFrom Scratchを選ぶ
アプリ名とインストールしたいワークスペースを選択し、Create Appを押下
そうすると、アプリのDashboard画面遷移される
Event Subscriptionの有効化
アプリ作成できたらEvent Subscriptionを有効にし、エンドポイントのURLを入力する
入力したタイミングで、SlackからPOSTリクエストが送られるので、request bodyに含まれているchallengeコードをresponseとしてそのまま返してあげれば、承認が通る。
成功すると👇こんな感じ
Botに権限を付与する
サイドバーからOAuth & Permissionsをクリックし、ちょっと下にスクロールし、Scopeセクションからbotに権限を付与する
まず、メンション関連のapp_mentions:readを付与する
そこから、スレッドを読み込むためにchannel:history権限、メッセージ送信するためにchat:write権限、スレッド内でユーザーがメンションされたときにそのユーザーの情報を読み込むためにusers:read権限を付与する
workspaceにインストールする
OAuth Tokens for Your WorkspaceセクションにあるInstall To Your Workspaceボタンを押下
そうすると、承認画面に遷移される
許可すると、Dashboardに戻り、oauth tokenが表示される
このトークンを使いSlack SDKの認証を通す。
コード
中心的なところだけサクッと説明する
使ってみないとわからないバグがたくさん存在しそうなので、あくまで参考程度にみていただければ 🙇♂️
メンションされたらslack側からeventデータがJSONでなげられてくるので、その中からchannel
とthread_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の構文に変換していく
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} />);
};
以上。
Discussion