SlackAppをVercelにデプロイする
とある事情からSlackAppが欲しくなりました。
今回はSlackAppをVercelにデプロイしたのでそのやり方を説明します。
SlackAppとは
簡単に概念を説明するとSlackのAPIに叩かれたり叩いたりするやつのこと
チャットを送るためにSlackAPIを叩くし、Slackでコマンドを打てば逆にSlackAppが叩かれる。
つまりはSlackAppを簡単に言うとAPIサーバーです。
デプロイ先
どこにするか迷ってたところSlackがサーバーのホスティング先の選択肢を提示してるページを見つけました。
ざっと眺めながら「AWSやGCPは管理がダルそうだなー」とか思ってたところVercelを見つけました。
ホスティング先として頭の片隅に思い浮かべてはいましたが、まさかSlackがサンプルまで提供してるとは思いませんでした。
しかもSlackAppのサンプルリポジトリを見てみると、なんかファイルが異常に少ない。
なんとどうやら、nextjsを使わずにAPIだけホスティングしています。
本来サーバーレスをAWSなどで構成しようものなら設定ファイルだけでかなりの時間を取られるところが、Vercelならapiフォルダにリクエストハンドラーを置けばそれだけでデプロイできてしまいます。
これは使わない手はないと思い、今回はVercelを使うことにしました。
SlackAppを作成
設定・実装するべきものを列挙します。
- SlackAppの設定
- リクエストハンドラー
- リクエストを検証する関数
SlackAppの設定ではSlackにリクエストを送る先のURLを教えます。
リクエストハンドラーはリクエストハンドラーです。
リクエストを検証する関数について少し説明します。
「リクエストの検証する関数」とは何かというと「リクエストがSlackから送られてきたものか検証する関数」のことです。
SlackAppではサーバーを建てるので、当然不正なアクセスに対策を打つ必要があります。
Slackはその対策として、signatureと呼ばれるリクエストボディを鍵で暗号化したものをリクエストのヘッダーにつけて送ってくれます。
SlackApp側はそのsignatureを検証することでそのリクエストがSlackが送ったものかどうかが確かめれるという仕組みです。
その仕組みの実装を「リクエストの検証する関数」とここでは呼んでいます。
では実際にSlackAppを作成していきます。
SlackAppの設定
https://api.slack.com/appsにアクセスしてSlackAppを作成します。
「From scrach」か「From an app manifest」から選ぶモーダルが出ますが、「From scrach」にします。
名前や開発に使うワークスペースを適当に入力し、「Create App」を押すとSlackAppが作成できます。
今回はスラッシュコマンドを作ろうと思うので、画像の場所からスラッシュコマンドを作成します。
Command、Request URL、Short Descriptionを入力し、「Save」を押します。
まだサーバーを建ててないので一旦Request URLは適当に埋めます。
次にSlackAppをインストールします。
SlackAppがインストールできたら、Slackを確認してみてください、スラッシュコマンドが追加されているはずです。
次にリクエストハンドラーを作るのですが、リクエストハンドラーではSlackにメッセージを送りたいと思うので、SlackのAPIを叩くためのTokenとリクエストの検証に必要な鍵を用意したいと思います。
SlackのAPIを叩くためのTokenは画像の場所から取れる
リクエストの検証に必要な鍵はサイドバーの「Basic Information」を選択して出てくる画面の下の方にある「Signing Secret」から取れます。
またBotがSlackにチャットを送れるように権限も設定しておきます。
「OAuth & Permissions」から権限が設定できます。
この時画像のような警告が出るので、警告に従い「Install App」からSlackAppを再インストールします。
これでSlack側の設定は大体おしまいです。
リクエストハンドラー
言語はTypescriptを選び、パッケージマネージャーにbunを使いました。
githubのリンクを貼っておくので for-doc
ブランチを クローンしてください。
いくつかファイルを紹介します。
// lib/slack.ts
import pkg from "@slack/bolt";
const { App } = pkg;
export const slack = new App({
token: process.env.SLACK_BOT_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET,
});
lib/slack.tsではSlackAPIのクライアントを作成しています。
api/hello.tsでそれを使いSlackにチャットを送る処理をしています。
withSlackApiは後ほど説明しますが、リクエストの検証をする関数です。
// api/hello.ts
import type { VercelRequest, VercelResponse } from "@vercel/node";
import { slack } from "../lib/slack.js";
import { withSlackApi } from "../lib/api/withSlackApi.js";
export default withSlackApi(async function handler(
_: VercelRequest,
res: VercelResponse
) {
await slack.client.chat.postMessage({
channel: "C060MDTT9QX",
text: "test message",
});
res.json("success");
return;
});
ではこれをVercelにデプロイします。
Vercelで適当にアカウントを作ってDashboardを開きます。
アカウントを作る際はGithubの認証で作成しておくと、あとでGithub連携する手間が省けます。
「Add New」から「Project」を選び、その後デプロイしたいリポジトリを選択します。
すると次のような画面が現れるので、「Framework Preset」が「Other」であることに注意して、何も設定することなく「Deploy」を押します。
これでサーバーが建てられたので、画像のDomainsからURLを取得します。
ここで取得したURLは末尾に /api/hello
をつけてスラッシュコマンドに設定しておいてください。
次に環境変数を設定します。
「SlackのAPIを叩くためのToken」は「SLACK_BOT_TOKEN」、「Signing Secret」は「SLACK_SIGNING_SECRET」という名前で、Environmentはすべてにチェックをつけたまま入力して「Save」を押します。
環境変数はデプロイしなおさないとサーバーに反映されないので、画像の「Redeploy」から再デプロイします。
これでサーバーが建てられました。
リクエストを検証する関数
SlackAppが設定でき、サーバーも建てられたのですが、コマンドの実行の前に少しだけリクエストを検証する関数について説明します。
リクエストを検証する関数は次のようになっています。
// lib/slack.ts
import { VercelRequest } from "@vercel/node";
import { createHmac } from "crypto";
import tsscmp from "tsscmp";
// ------------------------------
// HTTP module independent methods
// ------------------------------
const verifyErrorPrefix = "Failed to verify authenticity";
/**
* Verifies the signature of an incoming request from Slack.
* If the request is invalid, this method throws an exception with the error details.
*/
export function verifySlackRequest(req: VercelRequest): void {
const requestTimestampSecHeader = req.headers["x-slack-request-timestamp"];
const signature = req.headers["x-slack-signature"];
if (Number.isNaN(requestTimestampSecHeader)) {
throw new Error(
`${verifyErrorPrefix}: header x-slack-request-timestamp did not have the expected type (${requestTimestampSecHeader})`
);
}
if (signature == null || Array.isArray(signature)) {
throw new Error(
`${verifyErrorPrefix}: header x-slack-signature did not have the expected type (${signature})`
);
}
const requestTimestampSec = Number(requestTimestampSecHeader);
// Calculate time-dependent values
const nowMs = Date.now();
const requestTimestampMaxDeltaMin = 5;
const fiveMinutesAgoSec =
Math.floor(nowMs / 1000) - 60 * requestTimestampMaxDeltaMin;
// Enforce verification rules
// Rule 1: Check staleness
if (requestTimestampSec < fiveMinutesAgoSec) {
throw new Error(
`${verifyErrorPrefix}: x-slack-request-timestamp must differ from system time by no more than ${requestTimestampMaxDeltaMin} minutes or request is stale`
);
}
// Rule 2: Check signature
// Separate parts of signature
const [signatureVersion, signatureHash] = signature.split("=");
// Only handle known versions
if (signatureVersion !== "v0") {
throw new Error(`${verifyErrorPrefix}: unknown signature version`);
}
// Compute our own signature hash
const hmac = createHmac("sha256", process.env.SLACK_SIGNING_SECRET ?? "");
// We should detect the body have "toString" because the body parsed by vercel is mede by Object.create(null) or regular object.
// the original shape of the body have "toString" is JSON.
// the original shape of the body not have "toString" is URLSearchParams.
// We should add the escape of slash because vercel remove the escape of slash when the body is JSON.
const body =
"toString" in req.body
? JSON.stringify(req.body).replaceAll("/", "\\/")
: new URLSearchParams(req.body as Record<string, string>).toString();
hmac.update(`${signatureVersion}:${requestTimestampSec}:${body}`);
const ourSignatureHash = hmac.digest("hex");
if (!signatureHash || !tsscmp(signatureHash, ourSignatureHash)) {
throw new Error(`${verifyErrorPrefix}: signature mismatch`);
}
}
何をしているのかというと、 「SIgning Secret」でリクエストボディをハッシュ化したものをSlackが送ってきたsignatureと比較しています。
少し工夫した場所があるので、紹介します。
// We should detect the body have "toString" because the body parsed by vercel is mede by Object.create(null) or regular object.
// the original shape of the body have "toString" is JSON.
// the original shape of the body not have "toString" is URLSearchParams.
// We should add the escape of slash because vercel remove the escape of slash when the body is JSON.
const body =
"toString" in req.body
? JSON.stringify(req.body).replaceAll("/", "\\/")
: new URLSearchParams(req.body as Record<string, string>).toString();
コメントで書いた通りですが、日本語で説明します。
slackはslash commandの時はURLSearchParamsの形式でrequest bodyを送信します。
また、時にはJSON形式で送信することもあります。
vvercelはそれらをすべてjsのobjectに変換しちゃいます。
幸い、objectの作り方に差があるみたいで、JSON形式じゃないときはObject.create(null)
形式でobjectを作るみたいなので、toString
があるかないかで、もともとURLSearchParamsだったのか、JSONだったのかを判断します。
そうして元の形式に戻して、文字列に変換します。
こうしないとrequestの検証に失敗します。
また、requestがJSONの時にはvercelは/
のエスケープを取っちゃうのでつけます。
このような処理を挟むことで、slash commandの時にもEvent SubscriptionのようにSlackがJSONでリクエストをするときにも、そのリクエストを検証することができます。
スラッシュコマンドを実行する
ではコマンドを実行していきます。
まずはBotをチェンネルに招待します。
そしてコマンドを実行します。
SlackAppの完成です。
まとめ
SlackもVercelもすごい