
たった1つの /airi が、何でも屋になる。Slash Command のディスパッチ設計
こんにちは!Accel-Hack 開発チームです。
社内AIアシスタント「Airi」の開発プロセスをお届けする開発記ブログ。
第10回目の今回は、Airi と対話するための入り口――スラッシュコマンド /airi のルーティング設計についてお届けします。
前回の Vol.9 では、Slack という玄関を構え、リクエストを受け取る基盤を整えました。
玄関ができたら、次に必要なのは「受けた注文を、正しい担当者に届ける仕組み」です。
勤怠、カレンダー、入社手続き……Airi には数多くの機能があります。
それらをたった1つのコマンドでどう捌いているのか。
今回はその交通整理の裏側に踏み込みます。
1. コマンドを増やしすぎない、という選択
Airi に機能を足していくとき、最初に思いつくのは「機能ごとにコマンドを作る」やり方です。
勤怠なら /attendance、カレンダーなら /calendar、入社手続きなら /employee……。
一見わかりやすそうですが、これは早々に破綻します。
Slack のコマンド候補は膨れ上がり、ユーザーは「あの機能、コマンド何だっけ?」と毎回悩むことになります。
運用する私たちも、機能を追加するたびに Slack App の設定画面でコマンドを1つずつ登録して回る羽目になります。
そこで Airi が選んだのは、入口を /airi ただ1つに絞るという方針でした。
ユーザーが覚えるのはこの1語だけ。
あとは続く言葉で「何をしたいか」を伝えてもらえばいい。
玄関は1つ、その奥で行き先を振り分ける――このシンプルな割り切りが、拡張に強い対話UIの土台になりました。
2. /airi <prefix> <subcommand> という一貫した文法
では、1つのコマンドでどうやって多彩な機能を呼び分けるのか。
答えは、コマンドの後ろに続く文法にあります。
Airi のコマンドは、/airi <機能名> <動作> [引数...] という並びで統一されています。
たとえば勤怠サマリを集計したいときは、次のように打ちます。
/airi attendance summary 20260608
先頭の attendance がどの機能か(prefix)、続く summary がその機能で何をするか(サブコマンド)、最後の 20260608 が引数です。
この文法さえ守れば、どんな機能でも同じ形で呼び出せます。
Slack 側に登録するのは、Vol.9 で触れた app.command("/airi", ...) というたった1つの受付口だけ。
ここから先の「どの機能へ渡すか」という判断は、すべて自分たちのコードが引き受けます。
3. 総当たりディスパッチ:handleAiriCommand の中身
/airi に届いたリクエストは、handleAiriCommand という関数に集約されます。
この関数のふるまいは、驚くほど素直です。
まず、本文が空だったり help だったりした場合は、その場で「使えるコマンド一覧」を返します。
そうでなければ、すべての機能ハンドラに、リクエストを一斉に配布します。
const results = await Promise.all([
attendanceHandler.handleAttendanceCommand(req),
calendarHandler.handleCalendarCommand(req),
employeeHandler.handleEmployeeCommand(req),
// ...(機能ごとに続く)
]);
// 誰も担当しなければ「不明なコマンド」+ヘルプを返す
if (results.some((handled) => handled)) return;ポイントは、各ハンドラの作りです。どのハンドラも冒頭で「先頭の語が自分の担当 prefix か?」だけをチェックし、違えば何もせず false を返して身を引きます。
一致したハンドラだけが処理を実行し、true を返す。
最後に results.some(...) で「誰か1人でも担当したか」を確認し、誰も手を挙げなければ「不明なコマンドです」とヘルプを返す――というわけです。
この一連の流れを図にすると、次のようになります。

一斉に配って、担当者だけが手を挙げる。
少し大胆に見えるかもしれませんが、機能の数がそれほど多くない社内ツールでは、この「総当たり」がむしろ見通しがよく、バグを生みにくい選択でした。
4. 機能は自己完結する:FeatureCommandConfig
このディスパッチが破綻しない理由は、Vol.4 で紹介した Feature-First アーキテクチャ にあります。
各機能は、自分に関する情報を1つの設定オブジェクトに閉じ込めています。
export const ATTENDANCE_CONFIG: FeatureCommandConfig<"summary" | "punch" | "help"> = {
commandPrefix: "attendance",
commands: {
summary: { name: "summary", description: "勤怠サマリを集計します", usage: "[YYYYMMDD]" },
punch: { name: "punch", description: "打刻を自然文で処理します",
allow: { channelIds: [], dm: true } }, // 打刻は DM のみ
help: { name: "help", description: "ヘルプを表示します" },
},
allow: { channelIds: [SUMMARY_CHANNEL_ID] }, // 機能全体は特定チャンネルのみ
};自分の commandPrefix、提供する commands、そして後述する利用制限 allow。
機能に必要なものが、この一枚にすべて揃っています。
だから新しい機能を足すときも、「設定とハンドラを1セット書いて、ディスパッチの配列に一行加える」だけ。
ルーター本体はいつまでもスリムなままです。
AIと二人三脚で機能を量産する私たちにとって、この「足しても散らからない」設計は何よりの強みでした。
5. 「誰が・どこで」使えるかを、宣言的に守る
社内ツールでは、「このコマンドはこのチャンネルでだけ」「これは個人的なやり取りなので DM でだけ」といった制御が欠かせません。
Airi では、これを設定の allow に宣言的に書くだけで実現しています。
登場するのは channelIds(許可するチャンネル)と dm(DMで使えるか)の2つ。
たとえば勤怠サマリは特定チャンネルでのみ、打刻は他人に見えない DM でのみ、といった住み分けが、上の設定例のようにひと目で分かる形で表現できます。
実際の判定は isChannelAllowedForCommand が担い、**「機能全体で許可されているか」→「そのサブコマンドで許可されているか」**の二段階でチェックします。
許可されていないチャンネルから叩かれた場合は、処理に入る前に「このコマンドは指定されたチャンネルでのみ実行可能です」とやんわり案内して止まります。
うっかり誤爆を防ぐ、地味ながら大切な安全弁です。
6. うれしい副産物:ヘルプの自動生成
この「設定に集約する」設計には、思わぬ嬉しいおまけがありました。
ヘルプが自動で作れるのです。
ユーザーが /airi とだけ打つと、generateGlobalHelpText が全機能の設定を走査し、description や usage を集めてコマンド一覧を組み立てます。
しかもこのとき、今いるチャンネルで実際に使えるコマンドだけを選んで表示します。
DM 専用の打刻コマンドが、うっかり公開チャンネルのヘルプに出てしまう、といったこともありません。
ヘルプ文を人間が別途メンテナンスする必要はなく、設定を直せばヘルプも自動で追従します。
ドキュメントとコードが乖離しない――設定こそが唯一の真実(Single Source of Truth)という、Vol.8 の Drizzle でも味わった安心感が、ここでも効いています。
7. おわりに
今回は、/airi という1つの入口が、いかにして多彩な機能へリクエストを届けているのかを見てきました。
入口は1つに絞り、総当たりで担当を決め、機能は設定に自己完結させ、権限は宣言的に守る。
この組み合わせによって、Airi は「機能を足しても散らからない」拡張に強い対話UIを手に入れました。
さて、コマンドの交通整理が整うと、いよいよ Airi は外部サービスと本格的に手を組み始めます。
次回は 「Vol.11: 外部API連携の壁『Freee OAuth認証』」 として、人事労務システム Freee とつながるための認証フローと、その泥臭い実装の裏側をお届けします。お楽しみに!
この記事は以下の体制で作成されました。
ドラフト作成・執筆: Airi (社内AIアシスタント)
アイデア出し・レビュー: CTO
デザイン: 事務スタッフ
Accel-Hackでは、テクノロジーを駆使して世の中の課題を解決していく仲間を募集しています。
私たちの取り組みに少しでも興味を持っていただけたら、ぜひ採用ページや会社サイトも覗いてみてくださいね。