Slack-Difyボットにチャンネル別のアプリルーティングを実装する

AI

はじめに

本シリーズでは、SlackとDifyを組み合わせてホームサーバー上で動かす自作Botを段階的に実装しています。SlackのチャンネルでBotにメンションするとDifyのチャットアプリAPIが呼ばれ、AIの応答がスレッドに返ってくる構成です。

前回の記事では、Slack-Dify Botにスレッド単位で会話コンテキストを引き継ぐ機能を追加しました。 しばらく使っていると、もう一つやりたいことが出てきました。チャンネルごとに問い合わせ先のDifyアプリを切り替えたいというものです。

筆者のホームサーバーのDifyには、用途別のチャットボットアプリを3つほど作っています。汎用アシスタント、開発関係のナレッジを参照するもの、運用ログを検索するMCP連携付きのものです。 ところが現状の実装ではDIFY_API_KEY.envに1つだけ書いており、Slackのどのチャンネルから話しかけても同じアプリに到達してしまいます。#devで開発質問をしているつもりが汎用アシスタントに渡って、ナレッジベースの結果が返ってこない、という体験になっていました。

本記事ではチャンネルIDからDifyアプリの設定を引くルーティングを追加し、YAMLでマッピングを宣言的に管理できるようにします。あわせて、前回追加したSQLiteテーブルにアプリIDを加えてconversation_idの名前空間を分離する手順も紹介します。前提となるBotの基本実装は初回記事を参照してください。

なぜチャンネル単位で切り替えるのか

Slackのチャンネルは、もともとトピックや関心領域で区切るための単位です。#devで議論される話題と#opsで議論される話題には、参照したいナレッジ・利用したいツール・期待する応答スタイルが大きく違います。

Difyではアプリ単位で以下が独立しています。

  • システムプロンプト(応答スタイル・口調・役割設定)
  • ナレッジベース(参照するドキュメント群)
  • 接続するMCPツール
  • モデルパラメータ(モデル選択・温度・最大出力長など)

つまりチャンネル↔︎Difyアプリの対応関係をルーティングで実現すると、Slack側の自然な区切りに沿ってBotの振る舞いを最適化できます。

代替案として、メンション時にコマンド引数で切り替える方法(例: @bot --app=dev こんにちは)も検討しました。ただ、毎回タイプするのは現実的ではないですし、スレッド途中で別のアプリに切り替わるのは前回実装したスレッド-conversation_idの紐付けと相性がよくありません。チャンネル単位なら、人間が「このチャンネルはこういう話をする場所」と認識している境界とも一致するので、運用上のオーバーヘッドが小さく済みます。

設定ファイルのスキーマ

設定はYAMLで持つことにします。.envでも書けますが、チャンネルIDとアプリ情報のマップはキーが10〜20個と増えていく可能性があり、フラットな環境変数では管理しづらくなります。YAMLならコメントが書けるため、どのチャンネルIDがどのチャンネルに対応しているかを明示的に残せるのもメリットです。

config/dify-bots.ymlを新規作成します。

default:
  name: "汎用アシスタント"
  api_key: app-xxxxxxxxxxxxxxxxxxxxxxxx
  base_url: https://your-dify-host/v1

channels:
  C0123456789:  # #dev
    name: "開発アシスタント"
    api_key: app-yyyyyyyyyyyyyyyyyyyyyyyy

  C0234567890:  # #ops
    name: "運用アシスタント"
    api_key: app-zzzzzzzzzzzzzzzzzzzzzzzz
    base_url: https://other-dify-host/v1   # 別ホストでも指定できる

設計の意図は以下の通りです。

  • defaultは未登録チャンネルへのフォールバック。これがないと、設定漏れ時にBotが沈黙してしまうので必ず置きます。
  • channelsのキーはSlackのチャンネルID(Cから始まる文字列)。チャンネル名は変更されうるためIDで持ちます。
  • base_urlは省略可能で、省略時はdefaultのものを継承します。複数ホストのDifyを使い分ける必要がなければ書かなくてかまいません。
  • APIキーはここから読み出すため、.envDIFY_API_KEY / DIFY_BASE_URLは撤去できます。代わりにこのYAMLファイル自体を秘匿対象として扱う点に注意してください。

チャンネルIDは、Slackで対象チャンネル名を右クリック →「チャンネル詳細を開く」の下部に表示されているもの、もしくはチャンネルのリンクをコピーしたときの末尾セグメントから取得できます。

設定ローダーの実装

js-yamlを使って読み込むだけのシンプルな実装で十分です。

npm install js-yaml
npm install -D @types/js-yaml

src/config.tsを新規作成します。

import fs from "node:fs";
import path from "node:path";
import yaml from "js-yaml";

export interface DifyAppConfig {
  apiKey: string;
  baseUrl: string;
  name: string;
}

interface RawAppEntry {
  name?: string;
  api_key: string;
  base_url?: string;
}

interface RawConfig {
  default: RawAppEntry;
  channels?: Record<string, RawAppEntry>;
}

const CONFIG_PATH = process.env.BOTS_CONFIG ?? "./config/dify-bots.yml";

let cache: { default: DifyAppConfig; channels: Map<string, DifyAppConfig> } | null = null;

function load() {
  if (cache) return cache;

  const raw = yaml.load(fs.readFileSync(path.resolve(CONFIG_PATH), "utf-8")) as RawConfig;
  if (!raw?.default?.api_key || !raw.default.base_url) {
    throw new Error(`default.api_key と default.base_url は ${CONFIG_PATH} に必須です`);
  }

  const def: DifyAppConfig = {
    apiKey: raw.default.api_key,
    baseUrl: raw.default.base_url,
    name: raw.default.name ?? "default",
  };

  const channels = new Map<string, DifyAppConfig>();
  for (const [channelId, entry] of Object.entries(raw.channels ?? {})) {
    channels.set(channelId, {
      apiKey: entry.api_key,
      baseUrl: entry.base_url ?? def.baseUrl,
      name: entry.name ?? channelId,
    });
  }

  console.log(
    `Loaded ${channels.size} channel mapping(s):`,
    [...channels.entries()].map(([id, c]) => `${id}->${c.name}`).join(", "),
  );

  cache = { default: def, channels };
  return cache;
}

export function getAppForChannel(channelId: string): {
  appId: string;
  config: DifyAppConfig;
} {
  const { default: def, channels } = load();
  const channelCfg = channels.get(channelId);
  if (channelCfg) {
    return { appId: channelId, config: channelCfg };
  }
  return { appId: "__default__", config: def };
}

ポイントは2つあります。

  • appIdを返り値に含めること。これは後述のDBスキーマで「どのDifyアプリのconversation_idなのか」を識別するために使います。チャンネル別設定があるならチャンネルIDをそのまま使い、フォールバック時は__default__という固定文字列にします。
  • 起動時にdefaultの必須フィールドが揃っているかを検証すること。設定ミスでBotが起動するもののすべての発言で例外を吐く、という事故を未然に防げます。あわせて読み込んだチャンネルマッピングをログに出すようにしておくと、設定の食い違いに気付きやすくなります。

DBスキーマを更新する

前回作ったconversationsテーブルは(team_id, channel_id, thread_ts)を主キーにしていました。 ここにapp_idを追加して主キーに含めます。理由は、Difyのconversation_idアプリごとに名前空間が分かれている ためです。あるアプリで発番されたconversation_idを別アプリの/chat-messagesに渡しても、Conversation Not Existsでエラーになります。

たとえばチャンネルの設定を「汎用アシスタント」から「開発アシスタント」に張り替えた場合、同じスレッドであっても古いconversation_idは使えません。app_idを主キーに含めておくと、スレッド × アプリの組み合わせで自動的に新しい会話が立ち上がる挙動になります。

マイグレーションの方針

SQLiteで主キーの構成を変える場合、ALTER TABLEの追加列だけでは対応できないため、テーブルを作り直します。PRAGMA user_versionで簡易的にスキーマバージョンを管理します。

src/db.tsを以下のように書き換えます。

import Database from "better-sqlite3";
import fs from "node:fs";
import path from "node:path";

const DB_DIR = process.env.DB_DIR ?? "./data";
fs.mkdirSync(DB_DIR, { recursive: true });

const db = new Database(path.join(DB_DIR, "conversations.db"));
db.pragma("journal_mode = WAL");

migrate();

function migrate() {
  // v1: 前回記事時点のスキーマ
  if ((db.pragma("user_version", { simple: true }) as number) < 1) {
    db.exec(`
      CREATE TABLE IF NOT EXISTS conversations (
        team_id         TEXT NOT NULL,
        channel_id      TEXT NOT NULL,
        thread_ts       TEXT NOT NULL,
        conversation_id TEXT NOT NULL,
        created_at      INTEGER NOT NULL,
        updated_at      INTEGER NOT NULL,
        PRIMARY KEY (team_id, channel_id, thread_ts)
      )
    `);
    db.pragma("user_version = 1");
  }

  // v2: app_id 列を主キーに加えるためテーブルを作り直す
  if ((db.pragma("user_version", { simple: true }) as number) < 2) {
    db.exec(`
      BEGIN;
      CREATE TABLE conversations_new (
        team_id         TEXT NOT NULL,
        channel_id      TEXT NOT NULL,
        thread_ts       TEXT NOT NULL,
        app_id          TEXT NOT NULL,
        conversation_id TEXT NOT NULL,
        created_at      INTEGER NOT NULL,
        updated_at      INTEGER NOT NULL,
        PRIMARY KEY (team_id, channel_id, thread_ts, app_id)
      );
      INSERT INTO conversations_new
        (team_id, channel_id, thread_ts, app_id, conversation_id, created_at, updated_at)
      SELECT
        team_id, channel_id, thread_ts, '__default__', conversation_id, created_at, updated_at
      FROM conversations;
      DROP TABLE conversations;
      ALTER TABLE conversations_new RENAME TO conversations;
      COMMIT;
    `);
    db.pragma("user_version = 2");
  }
}

既存レコードはすべてapp_id = '__default__'として引き継ぎます。これは前回までの実装で唯一の問い合わせ先だったDifyアプリが、チャンネル設定のないフォールバック扱いに移行したことに対応します。設定ファイルのdefaultがそのアプリを指すように書いておけば、移行直後もスレッドの会話を継続できます。

アクセサの修正

export interface ThreadKey {
  teamId: string;
  channelId: string;
  threadTs: string;
  appId: string;
}

const selectStmt = db.prepare(`
  SELECT conversation_id FROM conversations
  WHERE team_id = ? AND channel_id = ? AND thread_ts = ? AND app_id = ?
`);

const upsertStmt = db.prepare(`
  INSERT INTO conversations
    (team_id, channel_id, thread_ts, app_id, conversation_id, created_at, updated_at)
  VALUES (?, ?, ?, ?, ?, ?, ?)
  ON CONFLICT(team_id, channel_id, thread_ts, app_id) DO UPDATE SET
    conversation_id = excluded.conversation_id,
    updated_at      = excluded.updated_at
`);

export function getConversationId(key: ThreadKey): string | null {
  const row = selectStmt.get(
    key.teamId, key.channelId, key.threadTs, key.appId,
  ) as { conversation_id: string } | undefined;
  return row?.conversation_id ?? null;
}

export function saveConversationId(key: ThreadKey, conversationId: string): void {
  const now = Date.now();
  upsertStmt.run(
    key.teamId, key.channelId, key.threadTs, key.appId,
    conversationId, now, now,
  );
}

ボット本体に組み込む

src/index.tsの差分は最小限です。callDifyDifyAppConfigを引数に取る形に変え、各ハンドラの先頭でgetAppForChannelを呼び出します。

import { App, GenericMessageEvent } from "@slack/bolt";
import { getConversationId, saveConversationId, ThreadKey } from "./db.js";
import { getAppForChannel, DifyAppConfig } from "./config.js";

const app = new App({
  token: process.env.SLACK_BOT_TOKEN!,
  appToken: process.env.SLACK_APP_TOKEN!,
  socketMode: true,
});

async function callDify(
  cfg: DifyAppConfig,
  userMessage: string,
  conversationId = "",
): Promise<{ answer: string; conversationId: string }> {
  const resp = await fetch(`${cfg.baseUrl}/chat-messages`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${cfg.apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      inputs: {},
      query: userMessage,
      response_mode: "blocking",
      conversation_id: conversationId,
      user: "slack-bot",
    }),
    signal: AbortSignal.timeout(60_000),
  });
  if (!resp.ok) {
    throw new Error(`Dify API error: ${resp.status} ${resp.statusText}`);
  }
  const data = (await resp.json()) as { answer: string; conversation_id: string };
  return { answer: data.answer, conversationId: data.conversation_id ?? "" };
}

app.event("app_mention", async ({ event, say }) => {
  const userText = event.text.split(">").slice(1).join(">").trim();
  if (!userText || !event.team) return;

  const { appId, config } = getAppForChannel(event.channel);
  const threadTs = event.thread_ts ?? event.ts;
  const key: ThreadKey = {
    teamId: event.team,
    channelId: event.channel,
    threadTs,
    appId,
  };

  try {
    const existing = getConversationId(key) ?? "";
    const { answer, conversationId } = await callDify(config, userText, existing);
    saveConversationId(key, conversationId);
    console.log(`[${config.name}] reply to ${event.channel}/${threadTs}`);
    await say({ text: answer, thread_ts: threadTs });
  } catch (err) {
    console.error("Error in app_mention handler:", err);
  }
});

app.message(async ({ message, say }) => {
  if (message.subtype) return;
  const m = message as GenericMessageEvent;
  if (m.bot_id || !m.thread_ts || !m.team || !m.text) return;

  const { appId, config } = getAppForChannel(m.channel);
  const key: ThreadKey = {
    teamId: m.team,
    channelId: m.channel,
    threadTs: m.thread_ts,
    appId,
  };

  const existing = getConversationId(key);
  if (!existing) return;

  try {
    const { answer, conversationId } = await callDify(config, m.text, existing);
    saveConversationId(key, conversationId);
    await say({ text: answer, thread_ts: m.thread_ts });
  } catch (err) {
    console.error("Error in message handler:", err);
  }
});

(async () => {
  await app.start();
  console.log("Bolt app is running with multi-Dify routing");
})();

ログにconfig.nameを出しておくのは、複数アプリが混在する環境では「いまどのアプリに渡したか」が分からないと運用時のデバッグが辛いためです。api_keyはログに出さないように気を付けます。

Docker Composeを更新する

設定ファイルもホスト側にマウントしてコンテナ外から編集できるようにします。

services:
  slack-dify-bot:
    build: .
    env_file:
      - .env
    volumes:
      - ./data:/app/data
      - ./config:/app/config:ro    # 設定ファイルは読み取り専用でマウント
    environment:
      BOTS_CONFIG: /app/config/dify-bots.yml
    restart: always

:roを付けておくのは、コンテナ側から誤って書き換えられるのを防ぐための保険です。設定の編集はホスト側のエディタで行います。

Dockerfileは前回のままで問題ありません。ただしconfigディレクトリはマウントされる前提なのでイメージ内に焼き込まないようにします。.dockerignoreconfig/data/を入れておくと安心です。

config/dify-bots.ymlはAPIキーを含むため、.gitignoreに必ず追加してください。あわせてサンプル用のconfig/dify-bots.example.yml(APIキーをダミー値にしたもの)をコミットしておくと、新メンバーが設定ファイルを作る際の雛形になります。

# シークレットを含むボット設定
config/dify-bots.yml
# DBとランタイムデータ
data/

動作確認

ビルドして起動します。

docker compose up -d --build
docker compose logs -f

3つのチャンネルで質問を投げ、応答の差を確認します。

  1. #dev@Dify Reactのhooksの命名ルールを教えて
    • ナレッジベースに開発ガイドラインを登録した「開発アシスタント」が応答する。
  2. #ops@Dify 直近のNginxエラーログから多いものを3つ教えて
    • MCP連携でログ検索ができる「運用アシスタント」が応答する。
  3. 設定に書いていない#random@Dify 今日の気温は?
    • フォールバックの「汎用アシスタント」が応答する。

ログにはチャンネルごとに別のアプリ名が出ます。

Loaded 2 channel mapping(s): C0123456789->開発アシスタント, C0234567890->運用アシスタント
[開発アシスタント] reply to C0123456789/1736422345.123456
[運用アシスタント] reply to C0234567890/1736422401.654321
[汎用アシスタント] reply to C0345678901/1736422478.987654
チャンネルごとに異なるDifyアプリのアシスタントが応答している様子

DBの中身を覗くと、app_id列にそれぞれのチャンネルIDまたは__default__が格納されていることが確認できます。

sqlite3 ./data/conversations.db \
  'SELECT channel_id, thread_ts, app_id, conversation_id FROM conversations LIMIT 5;'

ハマったポイント

実装中に踏んだ落とし穴を3つ書いておきます。

conversation_idの使い回しでエラーになる

設計時にはapp_idを主キーに入れる必要性をあまり意識していませんでした。最初は前回のスキーマのままチャンネルの設定だけ張り替えて動作確認したところ、DifyからConversation Not Existsが返ってきました。 Difyのconversation_idはアプリ単位のスコープを持つため、別アプリに対して投げると即座に弾かれます。これに気付いたあとでapp_idをスキーマに追加した、というのが本記事の構成です。マイグレーションパス(v1 → v2)も同じ理由で必要になりました。

defaultだけ書いてチャンネル登録を忘れる

YAMLのchannelsセクションを書き忘れていると、すべてのチャンネルがdefaultに流れます。挙動としてはエラーにならない代わりに「割り当てたつもりが効いていない」状態になり、Slack上で気付くまで時間がかかりました。 対策として、起動時に登録済みチャンネル名を一覧でログ出力するようにしてあります(前述のLoaded N channel mapping(s):の行)。設定の食い違いはログを見れば一発で気付けるようになりました。

YAMLのインデントずれ

これはどんなYAML設定でもありがちですが、channelsの下にぶら下げるべきapi_keydefaultの下に余分に書いてしまい、default.api_keyが二重定義になっていることに気付かないケースがありました。js-yamlは同名キーをエラーにせず後勝ちで上書きするため、デバッグ時の手がかりが少なめです。 本格運用する場合はajvなどでJSON Schemaを当てて検証するか、yamlパッケージ(eemeli/yaml)のstrictオプションを使う形に切り替える価値があります。

まとめ

Slack-Dify Botに、チャンネル単位でDifyアプリを切り替えるルーティングを足しました。

  • 設定はconfig/dify-bots.ymlに集約し、defaultフォールバックとchannelsマップで宣言する。
  • DBはapp_idを主キーに加え、conversation_idのアプリ間衝突を回避する。PRAGMA user_versionを使ったテーブル再構築でマイグレーションを行う。
  • ボット本体はgetAppForChannelで設定を引き、callDifyDifyAppConfigを渡すだけの差分で済む。

実際に運用してみると、アプリ単位で接続できるMCPツールやナレッジベースを分けられるため、「このチャンネルではファイルシステムへのアクセスは許可しない」といったガバナンスを設定レベルで効かせやすくなったと感じています。一方で、設定YAMLが秘匿情報を含むようになるため、.gitignoreへの追加とバックアップ運用は前回より少し気を使う必要があります。

次のステップとして、チャンネルごとの応答品質をSlack側で確認しやすくする仕組み(例: どのアプリに何回問い合わせたかのログ集計)や、Dify側からの次のアクションの提案をSlackで受け取る機能などを考えています。

コメント

タイトルとURLをコピーしました