SlackAppをVercelにデプロイする

 
0
このエントリーをはてなブックマークに追加
Hakucho
Hakucho (白鳥)

とある事情からSlackAppが欲しくなりました。

今回はSlackAppをVercelにデプロイしたのでそのやり方を説明します。

SlackAppとは

簡単に概念を説明するとSlackのAPIに叩かれたり叩いたりするやつのこと

チャットを送るためにSlackAPIを叩くし、Slackでコマンドを打てば逆にSlackAppが叩かれる。

つまりはSlackAppを簡単に言うとAPIサーバーです。

デプロイ先

どこにするか迷ってたところSlackがサーバーのホスティング先の選択肢を提示してるページを見つけました。

https://api.slack.com/docs/hosting

ざっと眺めながら「AWSやGCPは管理がダルそうだなー」とか思ってたところVercelを見つけました。

ホスティング先として頭の片隅に思い浮かべてはいましたが、まさかSlackがサンプルまで提供してるとは思いませんでした。

しかもSlackAppのサンプルリポジトリを見てみると、なんかファイルが異常に少ない。

https://github.com/vercel/examples/tree/main/solutions/slackbot

なんとどうやら、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を作成します。

%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%82%B7%E3%83%A7%E3%83%83%E3%83%88_2023-10-14_053201.png

「From scrach」か「From an app manifest」から選ぶモーダルが出ますが、「From scrach」にします。

名前や開発に使うワークスペースを適当に入力し、「Create App」を押すとSlackAppが作成できます。

今回はスラッシュコマンドを作ろうと思うので、画像の場所からスラッシュコマンドを作成します。

Untitled.png

Command、Request URL、Short Descriptionを入力し、「Save」を押します。

まだサーバーを建ててないので一旦Request URLは適当に埋めます。

Untitled.png

次にSlackAppをインストールします。

Untitled.png

SlackAppがインストールできたら、Slackを確認してみてください、スラッシュコマンドが追加されているはずです。

Untitled.png

次にリクエストハンドラーを作るのですが、リクエストハンドラーではSlackにメッセージを送りたいと思うので、SlackのAPIを叩くためのTokenとリクエストの検証に必要な鍵を用意したいと思います。

SlackのAPIを叩くためのTokenは画像の場所から取れる

Untitled.png

リクエストの検証に必要な鍵はサイドバーの「Basic Information」を選択して出てくる画面の下の方にある「Signing Secret」から取れます。

Untitled.png

またBotがSlackにチャットを送れるように権限も設定しておきます。

「OAuth & Permissions」から権限が設定できます。

Untitled.png

この時画像のような警告が出るので、警告に従い「Install App」からSlackAppを再インストールします。

Untitled.png

これでSlack側の設定は大体おしまいです。

リクエストハンドラー

言語はTypescriptを選び、パッケージマネージャーにbunを使いました。

githubのリンクを貼っておくので for-doc ブランチを クローンしてください。

https://github.com/tentaShiratori/shiratori-slack-app/tree/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連携する手間が省けます。

Untitled.png

「Add New」から「Project」を選び、その後デプロイしたいリポジトリを選択します。

すると次のような画面が現れるので、「Framework Preset」が「Other」であることに注意して、何も設定することなく「Deploy」を押します。

Untitled.png

これでサーバーが建てられたので、画像のDomainsからURLを取得します。

Untitled.png

ここで取得したURLは末尾に /api/hello をつけてスラッシュコマンドに設定しておいてください。

Untitled.png

次に環境変数を設定します。

「SlackのAPIを叩くためのToken」は「SLACK_BOT_TOKEN」、「Signing Secret」は「SLACK_SIGNING_SECRET」という名前で、Environmentはすべてにチェックをつけたまま入力して「Save」を押します。

Untitled.png

環境変数はデプロイしなおさないとサーバーに反映されないので、画像の「Redeploy」から再デプロイします。

Untitled.png

これでサーバーが建てられました。

リクエストを検証する関数

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をチェンネルに招待します。

Untitled.png

Untitled.png

そしてコマンドを実行します。

Untitled.png

Untitled.png

SlackAppの完成です。

まとめ

SlackもVercelもすごい

info-outline

お知らせ

K.DEVは株式会社KDOTにより運営されています。記事の内容や会社でのITに関わる一般的なご相談に専門の社員がお答えしております。ぜひお気軽にご連絡ください。