🤖

Cloudflare Workers で Slack アプリを動かす方法

2023/07/15に公開

最近、週末の趣味プロジェクトとして Cloudflare Workers(と Vercel Edge Functions)向けの Slack アプリ開発フレームワークを作りました。

私は普段 Slack の Developer Relations Engineer として Qiita の Slack チームの公式な記事を書いているのですが、この Cloudflare Workers 向けのものは業務で開発した公式ツールではなく、完全に個人プロジェクトなので、Qiita の Org ではなく Zenn に個人的な記事として書くことにします。

・・・そして、書き終わってみると、随分と長い記事になってしまいました。興味のあるところだけでもぜひ読んでみてください。

この記事で説明するもの

この記事では、Slack アプリ開発の基本と、以下のライブラリの使い方について解説していきます。

https://github.com/seratch/slack-cloudflare-workers
https://github.com/seratch/slack-edge

「Slack アプリ開発はもう慣れているので、すぐに試してみたい」という方は README やそこからのリンクを辿ってドキュメントを読んでもらう方が早いかもしれません。

Slack アプリ開発の前提知識

少し前提知識について解説しておきます。

「Slack アプリ」というのは、Slack ワークスペースに会話可能なボット、Slack 内で発生したイベント(例:誰かがチャンネルに参加した)に反応するワークフロー、スラッシュコマンド、ショートカットなどの機能を追加するための仕組みです。

この「Slack アプリ」と Slack の API サーバーとの通信には二つのやり方があります:

  1. Request URL: 公開されたエンドポイントをあらかじめ https://api.slack.com/apps の管理画面上で設定しておき、Slack からの通知を受け取ってそれに対して HTTP レスポンスや Web API コールで応答する
  2. Socket Mode: WebSocket コネクション経由で Slack からの通知を受け取ってそれに WebSocket のメッセージや Web API コールで応答する

後者の Socket Mode(ソケットモード)は公開 URL を持つことが難しい場合やローカル開発に便利です。Qiita で解説記事を書きましたので、ご興味があればそちらもお読みください。

この記事では Cloudflare Workers にデプロイして発行された URL を Request URL として設定して Slack アプリを開発する方法を説明していきます。

余談:ワークフロービルダーのための次世代プラットフォーム

ちなみに、今年(2023 年)に入って、Slack のプラットフォームには「次世代プラットフォーム」と呼ばれているワークフロービルダーを拡張するためのアプリ開発機構が追加されました。

ワークフロービルダーでは「トリガー」と呼ばれる起点が一つあってそこに「ステップ」と呼ばれる小さな処理が直列に連なる形で「ワークフロー」を構成します。この「ステップ」を自作できるのが次世代プラットフォームです。

より快適に Slack 内のオートメーションをできるようになった一方で、既存のアプリ開発でできたことができない場合もあります。例えば、ワークスペース全体に対してスラッシュコマンドを追加する、ホームタブを設定する、といったことは(少なくとも 2023 年時点では)できません。また、この次世代プラットフォーム機能はこれまでの Slack プラットフォーム機能とは異なり、有料プランの Slack ワークスペースでのみ利用可能で、無料枠を超過したワークフロー実行分は従量課金となる点にもご注意ください。詳細はこちらの記事に書きましたので、ご興味あればお読みください。

セクションタイトルに「余談」と書いた通り、この次世代プラットフォームとこの記事は無関係です。この記事では Request URL を使って通信する「Slack アプリ」を Cloudflware Workers で実装するための方法を紹介していきます。

ライブラリとか使わずにシンプルに実装しちゃダメなの?

Cloudflare Workers の fetch 関数でやることが「リクエストデータを受け取ってから、せいぜい Slack の Web API や他のサービスを呼び出して HTTP レスポンスを返す」程度なのであれば、わざわざライブラリとか使わなくても実装できるんじゃないの?と思われるかもしれません。

もちろん力技で全部を自前で実装してもよいのです。ですが、以下の点を考慮すると毎回自前実装するのは結構面倒だと言わざるを得ません。

リクエスト署名の検証

Slack アプリの Request URL は、インターネットに公開された URL であり、Slack のリクエスト元の IP アドレスは常に固定のものであることが保証されているわけでもありません。そのため、悪意のある第三者が Slack から送信されるペイロードを模してリクエストをしてくるリスクがあります。

これに対する対策として Slack からの通知の HTTP リクエストには、必ず x-slack-signature と x-slack-request-timestamp というヘッダーが含まれます。Slack アプリ側では x-slack-signature を Slack とだけ共有している Signing Secret を使って検証し、かつ x-slack-request-timestamp が古い日時でないことも確認することが推奨されています。詳しくは公式のドキュメント(英語)をお読みください。

これを Cloudflare Workers で動作するコードとして実装するには、まずリクエストボディをそのままのテキストとして読み込んだ上で、所定の検証のロジックを実装する必要があります。以下がその実装例です:

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    // Cloudflare 上で Request#text() を呼び出すと warning メッセージが出力されるので一旦 Blob にしてからテキストを取り出す
    const blobRequestBody = await request.blob();
    const rawBody: string = await blobRequestBody.text();
    if (await verifySlackRequest(env.SLACK_SIGNING_SECRET, request.headers, rawBody)) {
      // Slack からのリクエストだったので、このパターンでは処理を継続できる
    } else {
      // 不正なリクエストだったのでクライアントエラーとして応答する
      return new Response("invalid signature", { status: 401 });
    }
  },
};
  
async function verifySlackRequest(signingSecret: string, requsetHeaders: Headers, requestBody: string) {
  const timestampHeader = requsetHeaders.get("x-slack-request-timestamp");
  if (!timestampHeader) {
    return false;
  }
  const fiveMinutesAgoSeconds = Math.floor(Date.now() / 1000) - 60 * 5;
  if (Number.parseInt(timestampHeader) < fiveMinutesAgoSeconds) {
    return false;
  }
  const signatureHeader = requsetHeaders.get("x-slack-signature");
  if (!signatureHeader) {
    return false;
  }
  const textEncoder = new TextEncoder();
  return await crypto.subtle.verify(
    "HMAC",
    await crypto.subtle.importKey("raw", textEncoder.encode(signingSecret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]),
    fromHexStringToBytes(signatureHeader.substring(3)), textEncoder.encode(`v0:${timestampHeader}:${requestBody}`)
  );
}
  
function fromHexStringToBytes(hexString: string) {
  const bytes = new Uint8Array(hexString.length / 2);
  for (let idx = 0; idx < hexString.length; idx += 2) {
    bytes[idx / 2] = parseInt(hexString.substring(idx, idx + 2), 16);
  }
  return bytes;
}

リクエストペイロードの形式が様々

歴史的経緯から Slack から送られてくるリクエストボディの形式は、機能によって異なります。Events API だと Content-Type: application/json ですが、他の機能だと application/x-www-form-urlencoded であり、かつスラッシュコマンドと他の機能の場合でその中身の形式がまた異なります。全てのパターンに対応するためには、以下のような実装をする必要があります。

https://github.com/seratch/slack-edge/blob/main/src/request/request-parser.ts

ペイロードの中身が様々

Slack の開発者向けプラットフォームは、実に様々なイベントの通知に対応しています。そして、それぞれのイベントについても詳細に踏み込んでいくとそれなりにエッジケースがあり、データパターンも多様な場合があります。一例を挙げるなら、最も基本的なデータであるチャンネルのメッセージも text のみの場合もあれば Block Kit(Block Kit についてはこちらの記事も参照)の blocks を使ったリッチテキストも含む場合もあります。ファイル、メールなどが添付されているとき、それらは attachments として付加されていたりもします。

また、これは Slack の開発者向けサポートの改善ポイントなのですが、ペイロードについての網羅的なドキュメントが不足しています(すみません・・)。

今回私が開発したようなライブラリを利用して TypeScript の型による支援を受けることでこの辺も随分と楽になるはずです。

Cloudflare Workers では Bolt を使えないの?

Slack アプリを開発するためのフレームワークとして Bolt for JavaScript という npm パッケージがあることをご存知の方も多いかもしれません。Slack の Developer Relations チーム(私もこのチームのメンバーです)が、上で挙げた厄介ごとから Slack アプリ開発者の方々を解放するために開発した公式のフルスタック Slack アプリ開発フレームワークです。

ただ、残念ながら Bolt for JS は axios やその他 Node.js の API に依存する部分が多くあり、そのままでは Cloudflare Workers で使うことはできません。名前は for JavaScript ですが、どちらかというと for Node.js といった趣のフレームワークなのです。

また、Bolt for JS は Receiver というインターフェースで拡張する仕様になっているのですが、この点が Cloudflware Workers の fetch 関数としてリクエストを処理する仕様との相性が良くないという問題も存在しています。この点は Next.jsNest.js のようなモダンなフレームワークを使った Web アプリの一部として動かしたいという場合にも制約となります。これもよく質問を受けていたので、以前に週末プロジェクトで検証を行い、成果物として Receiver の制約を回避するためのライブラリをつくりました。今のところ、この Bolt HTTP Runner を本家に取り込む予定はありません。ただ、個人としてできる限りはメンテナンスを続けるつもりでいますので、どうしても Next.js などで動かしてみたいという方はこのライブラリを使うか fork して試してみてください。

少し話が脱線しました。このように、正しく Slack アプリを Cloudflare Workers 上で作るのは意外と大変です(でした)。例えば、この ChatGPT とのインテグレーションを実装しているアプリは、上記で書かれている点を全て自前で頑張って対応していたりします。

週末に Cloudflare Workers で遊んでいたときに「もっと Slack アプリを簡単に作れるようにしたいなぁ」と思い立ち、時間を見つけて作ってみたのが slack-cloudflare-workersslack-edge というライブラリなのです。

当初は slack-cloudflare-workers に全ての実装を入れていましたが、途中からほとんどの部分は Vercel Edge Functions でも使えることに気づき(おそらく他の類似のランタイムで使えるはずです)、その部分を slack-edge として切り出し、slack-cloudflare-workers には KV を使った OAuth フロー関連の実装(インストール情報の管理など)だけを残すようにしました。

そして、このライブラリは元々はエッジファンクションで動かすことだけを目的に作った趣味プロジェクトでしたが、実装を進めていくうちに最終的には Bolt for JS にこれまで存在していた課題を解決したものとして完成させることができました。

  • Slack Web API クライアントの進化
    • ポータビリティの改善: axios 他の 3rd party モジュールだけでなく Node.js への依存も全て排除、Fetch API にだけ依存するよう実装
    • API レスポンスデータについてさらに精度の高い TypeScript での型付けを実装(特に Block Kit のコンポーネントの型はかなり使いやすくなっています)
    • Slack 次世代プラットフォームでのみ利用可能な Web API にも対応
    • deprecated になった API のサポートをゴッソリ省いてコードを簡潔化
  • 3 秒タイムアウトへのネイティブ対応
  • Bolt for JS での TypeScript サポートの課題を解決
    • app.message リスナーの挙動とペイロードの型をより直感に近いものに変更
    • すでに非推奨となっている旧式のダイアログや attachments 内のボタンクリックイベントなどのサポートを削除することで app.action のリスナー引数の型がより明確に
    • 複数のリスナーにマッチして複数回 ack() を呼び出してしまうようなことがそもそもできないよう設計
    • リスナーのタイプ毎に応答時に指定可能なレスポンスデータの型をより厳密化
  • Bolt for JS が提供していなかった機能を追加
    • authorize 関数より前に実行されるミドルウェア機構を追加
    • OpenID Connect 互換の Sign in with Slack に標準対応
  • ローカル開発向けに、他の依存ライブラリなしでソケットモードにも対応
  • エッジファンクションだけでなく DenoBun などの新興の JavaScript/TypeScript ランタイムにも対応

Slack アプリ開発に馴染みのない方は、なんだかよくわからないことが色々と書いてある印象だと思いますが、要は「単に Cloudflare Workers で動くというだけでなく、Bolt for JS よりも色々と改善されていますよ」ということです。2023 年に TypeScript で Slack アプリを作るなら、最も使いやすいライブラリと言っても過言ではないと思います。上で少し触れた通り、Deno でも Bun でも使えるようにしてありますので、エッジファンクションではなくコンテナサービスで動く Bun アプリとしたい場合などにも利用できます。

なお、できればこの開発で得た知見を Bolt for JS に取り込んでいきたいところなのですが、ほとんどが破壊的変更になってしまうのと、チームとしてはほとんどのメンバーが次世代プラットフォームに注力しているということもあり、本家への改善で取り込めているものはまだまだ限定的です。少しずつでもやっていければとは思っています。

早速始めてみよう

前置きが随分と長くなりました。それではいよいよ Slack アプリを作ってみましょう。基本的にはライブラリ側の README に英語で書いてあるのですが、改めて日本語で順を追って説明していきます。

ツールのインストールとプロジェクト雛形作成

Cloudflare Workers アプリを開発するための CLI である wrangler をインストールして、プロジェクトをつくります。

npm install -g wrangler@latest
npx wrangler generate my-slack-app

デフォルトの指定に Yes のままで進めていきます。コンソールは以下のような出力になるはずです。

$ npx wrangler generate my-slack-app
 ⛅️ wrangler 3.2.0
------------------
Using npm as package manager.
▲ [WARNING] The `init` command is no longer supported. Please use `npm create cloudflare@2 -- my-slack-app` instead.

  The `init` command will be removed in a future version.


✨ Created my-slack-app/wrangler.toml
✔ Would you like to use git to manage this Worker? … yes
✨ Initialized git repository at my-slack-app
✔ No package.json found. Would you like to create one? … yes
✨ Created my-slack-app/package.json
✔ Would you like to use TypeScript? … yes
✨ Created my-slack-app/tsconfig.json
✔ Would you like to create a Worker at my-slack-app/src/index.ts? › Fetch handler
✨ Created my-slack-app/src/index.ts
✔ Would you like us to write your first test with Vitest? … yes
✨ Created my-slack-app/src/index.test.ts
npm WARN deprecated rollup-plugin-inject@3.0.2: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.
npm WARN deprecated sourcemap-codec@1.4.8: Please use @jridgewell/sourcemap-codec instead

added 160 packages, and audited 161 packages in 48s

29 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
✨ Installed @cloudflare/workers-types, typescript, and vitest into devDependencies

To start developing your Worker, run `cd my-slack-app && npm start`
To start testing your Worker, run `npm test`
To publish your Worker to the Internet, run `npm run deploy`

そして、今回は slack-cloudflare-workers という私が開発したライブラリを追加しておきます。

cd my-slack-app
npm i slack-cloudflare-workers@latest

まだデフォルトのサンプルコードのままですが npm start を実行すると以下のようなコンソール出力になり、

$ npm start

> my-slack-app@0.0.0 start
> wrangler dev

 ⛅️ wrangler 3.2.0
------------------
wrangler dev now uses local mode by default, powered by 🔥 Miniflare and 👷 workerd.
To run an edge preview session for your Worker, use wrangler dev --remote
⎔ Starting local server...
[mf:wrn] The latest compatibility date supported by the installed Cloudflare Workers Runtime is "2023-07-10",
but you've requested "2023-07-15". Falling back to "2023-07-10"...
[mf:inf] Ready on http://127.0.0.1:8787/
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ [b] open a browser, [d] open Devtools, [l] turn off local mode, [c] clear console, [x] to exit                                           │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

http://127.0.0.1:8787/ にブラウザーからアクセスすると Hello World! と表示されているはずです。

Slack アプリ設定

https://api.slack.com/apps にウェブブラウザーでアクセスします。今から開発に使うつもりの Slack ワークスペースにまだそのブラウザーでログインしていない場合、まずログインをしておきます。

右上に「Create New App」というボタンがあるので、それをクリックして「From an app manifest」を選択して、以下の動画の後にある YAML 形式の設定情報を使ってアプリを作成してください(とりあえずそのまま設定してください、後で変更します)。

display_information:
  name: cf-worker-test-app
features:
  bot_user:
    display_name: cf-worker-test-app
    always_online: true
  shortcuts:
    - name: Hey Cloudflare Wokers!
      type: global
      callback_id: hey-cf-workers
      description: Say hi to CF Workers
  slash_commands:
    - command: /hey-cf-workers
      url: https://XXX.trycloudflare.com/
      description: Say hi to CF Workers
      usage_hint: Say hi to CF Workers
oauth_config:
  scopes:
    bot:
      - app_mentions:read
      - chat:write
      - chat:write.public
      - commands
settings:
  event_subscriptions:
    request_url: https://XXX.trycloudflare.com/
    bot_events:
      - app_mention
  interactivity:
    is_enabled: true
    request_url: https://XXX.trycloudflare.com/
    message_menu_options_url: https://XXX.trycloudflare.com/

アプリ設定が作成されると「Install to Workspace」というボタンが表示されていると思います。それをクリックし、そのまま Slack ワークスペースへのインストールを完了してください。

必要な環境変数を設定

プロジェクトのトップディレクトリに .dev.vars という名前でファイルを作り、以下の内容で設定します。

# https://api.slack.com/apps/{your App ID}/general
# Settings > Basic Information > App Credentials > Signing Secret
SLACK_SIGNING_SECRET=....
# https://api.slack.com/apps/{your App ID}/install-on-team
# Settings > Install App > Bot User OAuth Token
SLACK_BOT_TOKEN=xoxb-...
SLACK_LOGGING_LEVEL=DEBUG

コメントにも書いている通り、

  • SLACK_SIGNING_SECRET は、アプリ管理画面の Settings > Basic Information > App Credentials > Signing Secret にあります
  • SLACK_BOT_TOKEN は、アプリ管理画面の Settings > Install App > Bot User OAuth Token にあります

これらを正しい値にした上で npm start し直してください。正しく設定できているかは次の手順で確認していきます。

ここまでで最低限の準備が整いました。次は Slack との疎通確認をしていきます。

Slack アプリの疎通確認

コードを書き始める前に、これから開発するアプリと Slack が正しく疎通できているかを確認します。

src/index.ts を変更

後からロジックは肉付けしていきますが、まずは以下の最低限のコードを src/index.ts に貼り付けてください。src/index.test.ts とは整合性が取れなくなりますが、テストをメンテするのは後にしましょう(なお、この記事ではテストの書き方は紹介していません・・いつか書ければとは思いますが・・)。

import { SlackApp, SlackEdgeAppEnv } from "slack-cloudflare-workers";

export default {
  async fetch(
    request: Request,
    env: SlackEdgeAppEnv,
    ctx: ExecutionContext
  ): Promise<Response> {
    const app = new SlackApp({ env });
    
    return await app.run(request, ctx);
  },
};

Cloudflare Tunnel を設定

最初に説明した通り Request URL は、インターネットに公開された URL である必要があります。先ほど設定した App Manifest の YAML 設定では一律 https://XXX.trycloudflare.com/ というプレースホルダーになっていました。これを正しい値に変更します。

Cloudflare Tunnel を利用するために以下の手順でコマンドをインストールしてください。macOS 以外の手順は公式のガイドに従ってください。

brew install cloudflare/cloudflare/cloudflared
cloudflared tunnel --url http://localhost:8787

正常に起動すると、以下のような内容がコンソールに出力されるはずです。

$ cloudflared tunnel --url http://localhost:8787
2023-07-15T07:13:03Z INF Thank you for trying Cloudflare Tunnel. Doing so, without a Cloudflare account, is a quick way to experiment and try it out. However, be aware that these account-less Tunnels have no uptime guarantee. If you intend to use Tunnels in production you should use a pre-created named tunnel by following: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps
2023-07-15T07:13:03Z INF Requesting new quick Tunnel on trycloudflare.com...
2023-07-15T07:13:04Z INF +--------------------------------------------------------------------------------------------+
2023-07-15T07:13:04Z INF |  Your quick Tunnel has been created! Visit it at (it may take some time to be reachable):  |
2023-07-15T07:13:04Z INF |  https://system-guam-mpg-adequate.trycloudflare.com                                        |
2023-07-15T07:13:04Z INF +--------------------------------------------------------------------------------------------+
2023-07-15T07:13:04Z INF Cannot determine default configuration path. No file [config.yml config.yaml] in [~/.cloudflared ~/.cloudflare-warp ~/cloudflare-warp /etc/cloudflared /usr/local/etc/cloudflared]
2023-07-15T07:13:04Z INF Version 2023.7.0
2023-07-15T07:13:04Z INF GOOS: darwin, GOVersion: go1.19.3, GoArch: amd64
2023-07-15T07:13:04Z INF Settings: map[ha-connections:1 protocol:quic url:http://localhost:8787]
2023-07-15T07:13:04Z INF cloudflared will not automatically update when run from the shell. To enable auto-updates, run cloudflared as a service: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/run-tunnel/as-a-service/
2023-07-15T07:13:04Z INF Generated Connector ID: 663b9432-4d49-44ef-a1c6-c4d923206a17
2023-07-15T07:13:04Z INF Initial protocol quic
2023-07-15T07:13:04Z INF ICMP proxy will use 192.168.68.112 as source for IPv4
2023-07-15T07:13:04Z INF ICMP proxy will use fe80::1424:857f:f8a9:7f56 in zone en0 as source for IPv6
2023-07-15T07:13:04Z INF Created ICMP proxy listening on 192.168.68.112:0
2023-07-15T07:13:04Z INF Created ICMP proxy listening on [fe80::1424:857f:f8a9:7f56%en0]:0
2023-07-15T07:13:04Z INF Starting metrics server on 127.0.0.1:61426/metrics
2023-07-15T07:13:05Z INF Registered tunnel connection connIndex=0 connection=68158100-10f4-40f6-891f-d5deaa033092 event=0 ip=198.41.192.227 location=NRT protocol=quic

この場合だと https://system-guam-mpg-adequate.trycloudflare.com が利用可能となった公開 URL です。

この URL をアプリ管理画面の Features > Event Subscriptions > Request URL に入力してみてください。正しく .dev.vars が設定できているなら、以下のように Verified と表示されるはずです。

設定を保存するには、右下の「Save Changes」ボタンを忘れずに押してください。この同じ URL を以下の画面で設定してください。

  • Features > Interactivity & Shortcuts > Request URL
  • Features > Interactivity & Shortcuts > Select Menus > Options Load URL
  • Features > Slash Commands > /hey-cf-workers の鉛筆マーク > Request URL

各画面で保存のボタンを押すのを忘れないように気をつけてください。

機能を追加していく

最低限の準備が整いましたが、このアプリにはまだ何も実装がありません。

試しに /hey-cf-workers という追加されたスラッシュコマンドを実行してみてください。以下のようにエラーとなり、

npm start でアプリを起動しているコンソールの方を見ると、以下のような出力になっていると思います。

*** Received request body ***
 {
  "token": "zzz",
  "team_id": "T03E94MJU",
  "team_domain": "seratch",
  "channel_id": "CHE2DUW5V",
  "channel_name": "dev",
  "user_id": "U03E94MK0",
  "user_name": "seratch",
  "command": "/hey-cf-workers",
  "text": "",
  "api_app_id": "A05H6MVTXA6",
  "is_enterprise_install": "false",
  "response_url": "https://hooks.slack.com/commands/T03E94MJU/xxx/yyy",
  "trigger_id": "111.222.xxx"
}
*** No listener found ***
{ .... }
[mf:inf] POST / 404 Not Found (263ms)
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ [b] open a browser, [d] open Devtools, [l] turn off local mode, [c] clear console, [x] to exit                                           │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

/hey-cf-workers に応答するごくシンプルなコードは以下のようになります。

const app = new SlackApp({ env });
app.command("/hey-cf-workers",
  // "ack" 関数は 3 秒以内に完了する必要があります
  async (_req) => {
    // このテキストはアプリからのエフェメラルメッセージとして送信されます
    return "What's up?";
  },
  // "lazy" 関数では 3 秒の制約はなく、非同期で実行したい処理を何でもできます
  async (req) => {
    await req.context.respond({
      text: "Hey! This is an async response!"
    });
  }
);
return await app.run(request, ctx);

うまくいっていれば、今度はエラーにはならず、以下のように二つのエフェメラルメッセージが表示されるはずです。

ちなみにこの一つ目の "What's up?" と二つ目の "Hey! This is an async response!" というメッセージがどのように異なるかは、こちらの記事に詳しく書きました。まだ読んでいないという方はぜひ読んでみてください。

https://zenn.dev/slack/articles/256c916f71b343

もしかすると「なぜここで app.command() に二つ関数を渡しているか?」を疑問に思われたかもしれませんね。

この ack/lazy と二つの関数を実行する仕組みは、元々 AWS Lambda などの Function-as-a-Service の環境のために私が考案したものでした。Slack からの通知には 3 秒以内に応答する必要があるのですが、AWS Lambda では HTTP リクエストをトリガーに実行されたファンクションは HTTP レスポンスを返すとランタイムが終了してしまうため、応答を返した後に非同期で時間のかかる処理を行うことができません。そこで Bolt for Python の Lazy Listeners は非同期処理を同じ入力を持ったまま別の AWS Lambda 実行で処理するようにしました。詳しくはこちらの記事を読んでみてください。

しかし、Cloudflare Workers では、あらかじめ Fetch イベントの waitUntil コマンドという仕組みが用意されているので、そのようなワークアラウンドは必要なく、シンプルな実装で同じことを実現できました。素晴らしい!!!

なお、明らかに lazy の方の処理が必要ないという場合は二つ目の関数の指定は省略できます。ただし、この実装でいく場合は必ず全ての処理が 3 秒以内に終わるようにしてください。それが難しい場合は、時間がかかる処理を二つ目の非同期実行の関数に移してください。

app.command("/hey-cf-workers", async () => {
  return "What's up?";
});

スラッシュコマンドと Block Kit のインタラクション例

上のスラッシュコマンドの例からもう少し発展させて Block Kit のボタンを置いたメッセージにして、かつそれが押されたときの処理も追加してみましょう。

// スラッシュコマンドからボタン付きのメッセージを投稿、クリックイベントを処理
app.command("/hey-cf-workers", async () => {
  return {
    response_type: "ephemeral",
    text: "このボタンをクリックしてみてください!",
    blocks: [
      {
        "type": "section",
        "block_id": "button",
        "text": { "type": "mrkdwn", "text": "このボタンをクリックしてみてください!" },
        "accessory": {
          "type": "button",
          "action_id": "button-action", // app.action はこの文字列にマッチ
          "text": { "type": "plain_text", "text": "ボタン" },
          "value": "hidden value",
        }
      }
    ],
  }
});
app.action("button-action",
  async () => { }, // ack するだけ
  async ({ context, payload }) => {
    if (context.respond) {
      // context.respond は response_url を使ってメッセージ送信する
      await context.respond({ text: "クリックしましたね!" });
    } else {
      await context.client.views.open({
        trigger_id: payload.trigger_id,
        view: {
          type: "modal",
          callback_id: "test-modal",
          title: { type: "plain_text", text: "ボタンクリック" },
          close: { type: "plain_text", text: "閉じる" },
          blocks: [
            {
              "type": "section",
              "text": { "type": "plain_text", "text": "クリックしましたね!" }
            }
          ],
        },
      });
    }
  },
);

このコードの挙動は以下のようになります。response_url を使って元々ボタンが置かれていたメッセージを書き換えています。

"in_channel" の response_type にすればチャンネルの他の人にも見える通常のメッセージとして投稿できます。context.respondresponse_url を使ってメッセージを送信するユーティリティです。詳細はこちらの記事を参考にしてください。代わりに同じテキストを伝えるだけのモーダルを開く例も上のコードの else 節の方に置いてありますので、よかったらそちらも試してみてください。

ショートカットとモーダルでのデータ送信

次はグローバルショートカットを実行したらモーダルを開き、データ送信したら入力バリデーションが実行される例を紹介します。また、外部データを使った検索機能の例も盛り込んであります。

// グローバルショートカットを実行したらモーダルが開いてデータ送信まで
app.shortcut("hey-cf-workers",
  async () => { }, // // ack するだけで何もしない
  async ({ context, body }) => {
    await context.client.views.open({
      trigger_id: body.trigger_id,
      view: {
        type: "modal",
        callback_id: "test-modal", // app.view リスナーがマッチする文字列
        title: { type: "plain_text", text: "テストモーダル" },
        submit: { type: "plain_text", text: "送信" },
        close: { type: "plain_text", text: "キャンセル" },
        blocks: [
          {
            "type": "input",
            "block_id": "memo",
            "label": { "type": "plain_text", "text": "メモ" },
            "element": { "type": "plain_text_input", "multiline": true, "action_id": "input" },
          },
          {
            "type": "input",
            "block_id": "category",
            "label": { "type": "plain_text", "text": "カテゴリ" },
            "element": {
              "type": "external_select",
              "action_id": "category-search", // app.options がマッチする文字列
              'min_query_length': 1,
            },
          }
        ],
      },
    });
  }
);
// external_select のキーワード検索への応答
// この処理は 3 秒以内に同期的に検索結果を返す必要があるので lazy 関数は渡せない
app.options("category-search", async ({ payload }) => {
  console.log(`Query: ${payload.value}`);
  // 本当はここの応答内容は payload.value の文字列にマッチするようにフィルターする
  return {
    options: [
      {
        "text": { "type": "plain_text", "text": "仕事" },
        "value": "work"
      },
      {
        "text": { "type": "plain_text", "text": "家族" },
        "value": "family"
      },
      {
        "text": { "type": "plain_text", "text": "ランニング" },
        "value": "running"
      },
      {
        "text": { "type": "plain_text", "text": "雑感" },
        "value": "random-thought"
      },
    ],
  };
});
// モーダルからデータ送信されたとき
app.view("test-modal",
  async ({ payload }) => {
    // モーダルの操作、エラーの表示などはここで 3 秒以内にやる
    const stateValues = payload.view.state.values;
    console.log(JSON.stringify(stateValues, null, 2));
    const memo = payload.view.state.values.memo.input.value!;
    if (memo.length < 10) {
      // 入力エラー表示
      return {
        response_action: "errors",
        errors: { "memo": "メモは 10 文字以上で記述してください" }
      }
    }
    // 完了画面にモーダルを書き換える
    return {
      response_action: "update",
      view: {
        type: "modal",
        callback_id: "test-modal",
        title: { type: "plain_text", text: "テストモーダル" },
        close: { type: "plain_text", text: "閉じる" },
        blocks: [
          {
            "type": "section",
            "text": { "type": "plain_text", "text": "受け付けました!" }
          }
        ],
      },
    };
    // 単にこのモーダルを閉じたい場合は何も返さない
  },
  async (req) => {
    // 非同期処理を追加でやりたければここに記述する
  }
);

こちらのコードの挙動は以下の通りです。

なお、Slack のモーダルを扱う方法は、完全ガイドというタイトルでしっかりとした記事を書きましたので、そちらを参考にしてみてください。

ボットをメンションしたときのイベント

最後に Events API の例です。 "app_mention" というアプリのボットユーザーがメンションされたときに通知されるイベントです。

// Events API では 3 秒以内に同期的に応答しないと実現できない要件がないので
// デフォルトで lazy 関数だけを渡せるようにしている
app.event("app_mention", async ({ context }) => {
  await context.say({
    text: `<@${context.userId}> さん、何かご用ですか?`
  });
});

こちらのコードの挙動は以下のようになります。

これ以外のイベントを受信して動作するアプリにしたい場合は、こちらの一覧にあるものをアプリの管理画面で指定した上でワークスペースに再インストールすると受信できるようになります。

リスナー関数の payload 引数は、指定したイベントのペイロードのデータ型に解決されるようになっています:

もしも不足や誤りがあった場合は、お手数ですがこちらの issue tracker に報告いただければ修正します!

Cloudflare にデプロイする

ということで、一通り機能が実装できました。それでは、このアプリを Cloudflare Workers の環境に反映して動作させてみましょう。

wrangler deploy

デプロイが成功すると以下のようにコンソールに出力されます。

$ wrangler deploy
 ⛅️ wrangler 3.2.0
------------------
Total Upload: 131.16 KiB / gzip: 19.86 KiB
Uploaded my-slack-app (1.42 sec)
Published my-slack-app (3.77 sec)
  https://my-slack-app.YOURS.workers.dev
Current Deployment ID: f0d7708e-edde-4b29-9bc4-ac4db11fcdbd

もし今ローカルで動作確認したアプリをそのまま本番動作に切り替えたいという場合は、少し手間ですが、先ほど trycloudflare.com の URL で設定した以下の 4 つを全て https://my-slack-app.YOURS.workers.dev に切り替えます。

  • Features > Event Subscriptions > Request URL
  • Features > Interactivity & Shortcuts > Request URL
  • Features > Interactivity & Shortcuts > Select Menus > Options Load URL
  • Features > Slash Commands > /hey-cf-workers の鉛筆マーク > Request URL

もし、本番アプリは新しく別の設定で作るという場合は App Manifest の YAML 形式の設定ファイルの https://XXX.trycloudflare.com/ の部分を発行された本番 URL に差し替えた上でアプリを作ると楽でしょう。

最後に .dev.vars に設定されている Signing Secret と Bot User OAuth Token を本番稼働の secret に反映します。こちらの手順についても、本番アプリを別で新しく作る場合は、異なる値になりますので、ワークスペースにインストール後、Slack アプリ管理画面から取得してそちらを指定してください。

wrangler secret put SLACK_SIGNING_SECRET
wrangler secret put SLACK_BOT_TOKEN

コンソール上は以下のようになります。

$ wrangler secret put SLACK_SIGNING_SECRET
 ⛅️ wrangler 3.2.0
------------------
✔ Enter a secret value: … ********************************
🌀 Creating the secret for the Worker "my-slack-app"
✨ Success! Uploaded secret SLACK_SIGNING_SECRET

$ wrangler secret put SLACK_BOT_TOKEN
 ⛅️ wrangler 3.2.0
------------------
✔ Enter a secret value: … ******************************************************
🌀 Creating the secret for the Worker "my-slack-app"
✨ Success! Uploaded secret SLACK_BOT_TOKEN

設定が全て終わったら、切り替えても同じように動作しているかを確認してみてください。おそらく本番環境で動いているアプリの方が、サクサク動作するんじゃないかなと思います。

さらに高度なトピック

また別の機会に詳しく解説したいと思いますが、少し高度な話題にも軽く触れておきます。

複数の Slack ワークスペースで動作するアプリをつくる

上の手順は最もシンプルな「ある一つのワークスペースでだけ動作するアプリ」の作り方でした。

この記事もちょっと長くなりすぎたので詳細はまた別の記事で解説しようと思いますが、同じアプリを他のワークスペースでも動作させたいという場合、インストールのための OAuth フローを自分で提供して(これまでは Slack のアプリ管理画面が提供しているインストールフローでインストールしていましたね?これを自前のものに切り替えるということです)、複数ワークスペースでのインストール情報を適切に管理することで、アプリを他のワークスペースにも配布することができるようになります。

また、OAuth フローを実装するとこれまでのようにボットの権限だけではなく、ワークスペースのユーザーの一人一人から何らかの情報閲覧や操作の権限を受け取って、ユーザーの代わりにステータスを変更したり、メッセージを投稿したり、検索結果にアクセスしたりといったこともできるようになります(OAuth フローを実装せず管理画面からインストールする場合は、開発者である自分自身のユーザートークンしか払い出すことができません)。

slack-cloudflare-workers は Cloudflare の KV を使って Slack の OAuth フローを実装する機能をすでに提供しています。詳しくはこちらのドキュメントサンプルコード例を参考にしてください。

Sign in with Slack を実装する

Slack アカウントで外部のサイトにログインするという機能(OpenID Connect 互換)にも対応しています。こちらはまだ全くドキュメントもないので、まずはドキュメントを充実させた上で、何らかの形で日本語でも実装方法を紹介できればと思っています。

エッジファンクション以外で使う

この記事の途中でも少し触れましたが、この slack-edge ライブラリは Deno と Bun にも対応しています。また、この記事投稿時点ではまだ実験段階ですが、ソケットモードにも一応対応しています。

このライブラリを気に入ってもらえて、エッジファンクションだけでなくコンテナサービスで動かすアプリの開発にも使いたいという場合は Deno/Bun で動かすのも良いでしょう。

こちらにいくつかサンプルコードがありますので、参考にしてみてください。

終わりに

「最後まで読んでくれる人はいるのだろうか・・・」というくらい長い記事になってしまいました。ここまで読んでくださったあなた、本当にありがとうございます!

Cloudflare Workers は、本当に便利ですね。特に Fetch イベントの waitUntil コマンドは本当に素晴らしい!Slack アプリのためにある機能かと思ったくらいですw また、開発体験も非常にスムーズで Slack アプリに限らず、色んな用途に使えそうだなと思います。

slack-edge と slack-cloudflare-workers については、完全に私個人の趣味プロジェクトです。なので、どれくらい時間を割けるか分かりませんが、ゆるゆると続けながら、より良いものにしていければと思っています。現状は KV にしか対応していないので、他のデータストアにも対応したりとかもやりたいですし。質問やフィードバックはぜひお気軽にお知らせください。また、これを使って何か面白い Slack アプリを作ったらぜひ教えてください!

それでは!

Discussion