メインコンテンツへスキップ
見出し画像

いよいよ外の世界へ!Slack App の作成から、Hono と共存させるイベント基盤の構築まで

    こんにちは!Accel-Hack(アクセルハック) 開発チームです。

    社内AIアシスタント「Airi」の開発プロセスをお届けする開発記ブログ。
    第9回目の今回は、いよいよ Airi が 「Slack」 という外の世界と対話を始めるお話です。

    前回までの Vol.7・Vol.8 で、私たちは Cloudflare のサーバーレス基盤と、Drizzle ORM による型安全なデータアクセスレイヤーを固めてきました。

    土台とデータの足回りは万全です。
    しかし、どれだけ立派なエンジンを積んでも、ハンドルとアクセルがなければ車は動きません。
    Airi にとってのその操縦席こそが Slack です。

    今回は、Airi を Slack に迎え入れる最初の一歩――Slack App の作成、マニフェストによる設計、そして Hono と同居させるイベントハンドリング基盤の構築についてお届けします。


    1. なぜ「Slack」を Airi の玄関にしたのか

    Airi は勤怠打刻や有給申請、日報の集計といった、いわゆるバックオフィス業務を助けるために生まれました。
    ではその機能を、社員はどこから呼び出すのが一番自然でしょうか。

    答えははっきりしていました。
    私たちが一日中開いているツール、Slack です。

    わざわざ専用の管理画面を開いてログインし直す運用は、どれだけUIを作り込んでも「面倒だから使わない」の一言で形骸化しがちです。
    それなら、すでに全員が張り付いている場所に Airi を住まわせてしまえばいい。
    打刻はメッセージのボタン一つ、申請はモーダル一枚。
    「業務を止めずに、いつもの会話の延長で頼める」——この体験を実現するために、Airi の主戦場は Slack になりました。


    2. Slack App は「マニフェスト」から始まる

    Slack と連携するアプリを作るには、まず Slack API の管理画面で Slack App を作成します。

    ここは昔ながらに画面をポチポチ設定していくこともできますが、Airi では App Manifest(アプリマニフェスト) を使い、アプリの構成をコード(YAML/JSON)として一元管理しています。


    マニフェストの嬉しさは、Vol.8 で語った Drizzle の思想とよく似ています。
    「アプリの設定図が一箇所にまとまり、そのまま再現できる」のです。
    どんな権限を要求し、どんなイベントを受け取り、どんなコマンドを提供するのか。
    すべてが一枚のファイルに宣言的に書かれているので、レビューもしやすく、環境の作り直しも一瞬です。

    実際のマニフェストは、おおよそ次のような姿をしています(値はダミーに置き換えています)。

    display_information:
      name: Airi
    features:
      bot_user:
        display_name: airi
      slash_commands:
        - command: /airi
          url: https://airi.example.com/slack/events
          description: Airi にいろいろ頼む
    oauth_config:
      scopes:
        bot:
          - commands          # スラッシュコマンドを受け取る
          - app_mentions:read # メンションを検知する
          - chat:write        # メッセージを投稿する
          - reactions:write   # リアクションを付ける
    settings:
      event_subscriptions:
        request_url: https://airi.example.com/slack/events
        bot_events:
          - app_mention       # @airi 宛のメンション
      interactivity:
        is_enabled: true
        request_url: https://airi.example.com/slack/events

    ※ ここでの scopes は説明用に絞った最小構成です。実際には利用するAPI(ユーザー情報の取得やスレッド履歴の参照、ファイルのアップロードなど)に応じて、必要なスコープを追加しています。

    ポイントは、slash_commands・event_subscriptions・interactivity という3つの入口すべてのリクエスト送信先を、同じURL(/slack/events)に向けていることです。
    スラッシュコマンドも、メンションも、ボタンのクリックも、Slack はすべてこの1つのエンドポイントに投げてきます。
    つまり Airi 側は、この窓口さえきちんと構えれば、あらゆる操作を受け止められるというわけです。


    3. 立ちはだかる「3秒の壁」

    さて、いざ実装というところで、Slack 連携には有名な関門があります。

    それが 「3秒ルール」 です。

    Slack は、コマンドやイベントを送った後、3秒以内に「受け取りました(HTTP 200)」という応答が返ってこないと、ユーザーにタイムアウトエラーを表示してしまいます。
    さらにその手前には、「そのリクエストが本当に Slack から来たものか」を署名(Signing Secret)で検証するという、セキュリティ上の必須作業もあります。

    この「署名検証」と「3秒以内の即レス」を毎回自前で書くのは、地味に骨が折れます。
    そこで Airi が採用したのが、Cloudflare Workers に最適化されたライブラリ slack-cloudflare-workers です。

    このライブラリが、面倒な署名検証と受付応答(ack)、そしてイベントの振り分けをまるごと引き受けてくれます。
    私たちは「受け取った後に何をするか」という本質だけに集中できるのです。


    4. Hono と Slack、2つの受付を1つの Worker に同居させる

    ここで一つ、設計上の悩みが生まれます。
    Airi の Worker には、すでに Vol.6 で構築した Hono のルーター(Web管理画面やOAuthのコールバックなどを捌く受付)が動いています。

    そこへ Slack 専用の受付をどう同居させるか。

    Airi の答えはシンプルでした。
    Worker の入口で、URLを見て交通整理をするのです。

    export default {
      async fetch(request: Request, env: AppEnv, ctx: ExecutionContext) {
        appConfig.build(env);
        const url = new URL(request.url);
    
        // Slack からのリクエストは SlackApp に任せる
        if (url.pathname.startsWith("/slack")) {
          const slackApp = createSlackApp(env);
          return await slackApp.run(request, ctx);
        }
    
        // それ以外はいつもの Hono で処理する
        return app.fetch(request, env, ctx);
      },
    };

    /slack で始まるリクエストだけを Slack 専用アプリへ、それ以外は従来どおり Hono へ。
    たったこれだけで、1つの Worker が「Slackの窓口」と「Webの窓口」という二枚看板を、混線させることなく捌けるようになります。
    この関係を図にすると、次のようになります。

    画像

    5. 受け取ったイベントを、各機能へ振り分ける

    Slack からのリクエストを受け止める SlackApp の中身が createSlackApp です。
    ここでは、「どのイベントが来たら、どのハンドラを呼ぶか」を宣言的に登録していきます。

    export function createSlackApp(env: AppEnv): SlackApp<AppEnv> {
      const app = new SlackApp({ env });
    
      // ホーム画面やニュース配信など、各機能のハンドラを登録
      homeHandler.registerHomeHandlers(app);
      attendanceHandler.registerAttendanceHandlers(app);
      // ...(機能ごとに続く)
    
      // /airi というスラッシュコマンドの受付
      app.command("/airi", async () => {}, handleAiriCommand);
    
      // @airi 宛のメンションの受付
      app.event("app_mention", async (req) => {
        // メンション本文を解釈し、対応するコマンドへ橋渡しする
      });
    
      return app;
    }

    app.command(...) はスラッシュコマンドの、app.event(...) はメンションなどのイベントの受付口です。
    新しい機能を足したくなったら、対応する register...Handlers(app) を一行加えるだけ。
    Slack という玄関と、各機能という部屋が、ハンドラ登録というシンプルな廊下でつながっている——この見通しの良さが、AIと二人三脚で機能を高速に積み上げる私たちの開発スタイルを、しっかり支えてくれています。

    なお、Signing Secret や Bot Token といった秘密情報は、コードに直書きせず、Vol.7 で触れた仕組み(wrangler secret)で安全に注入しています。
    設定値は Zod のスキーマで検証してから使うため、「トークンの入れ忘れ」に本番で初めて気づく、といった事故も未然に防げます。


    6. おわりに

    今回は、Airi が Slack という外の世界とつながるための最初の一歩を辿ってきました。
    マニフェストで設計図を宣言し、slack-cloudflare-workers に3秒の壁と署名検証を任せ、URLによる交通整理で Hono と同居させる。
    派手さはありませんが、この土台がしっかりしているからこそ、この先のあらゆる機能が安心して Slack の上に乗っていけます。

    とはいえ、今の Airi はまだ「玄関に立った」だけ。
    次回は 「Vol.10: たった1つの /airi が、何でも屋になる。Slash Command のディスパッチ設計」 として、/airi という一つのコマンドを、勤怠・カレンダー・申請といった多彩な機能へどう振り分けているのか、そのルーティング設計の裏側に踏み込みます。
    お楽しみに!


    この記事は以下の体制で作成されました。

    • ドラフト作成・執筆: Airi (社内AIアシスタント)

    • アイデア出し・レビュー: CTO

    • デザイン: 事務スタッフ

    Accel-Hackでは、テクノロジーを駆使して世の中の課題を解決していく仲間を募集しています。
    私たちの取り組みに少しでも興味を持っていただけたら、ぜひ採用ページや会社サイトも覗いてみてくださいね。


    あなたへのおすすめ