SlackのスレッドでDifyとの会話を継続する

AI

はじめに

前回の記事では、Slackからのメッセージを受け取ってDifyのバックエンドAPIに投げ、応答を返すだけの最小構成のBotを作りました。 ただ、あの実装には大きな弱点があります。毎回のメッセージが独立した問い合わせになってしまうことです。

Difyの/chat-messagesエンドポイントは、リクエストにconversation_idを渡すと前のやり取りを踏まえた会話として継続できます。 逆に毎回空文字を渡してしまうと、Bot側からは「先ほどの件ですが」のような追従質問ができません。 今回はこのconversation_idをBot内に永続化して、Slackのスレッドと1対1で紐付けることで、スレッド単位でコンテキストを引き継いだ会話を実現します。

合わせて、トリガーも整理します。前回は通常のチャンネル発言にも反応する実装でしたが、これだと普段のチャットにまでBotが割り込んで邪魔になります。 今回はBotへのメンションを起点にスレッドを開始し、そのスレッド内で行われた発言だけを拾って応答する形に変更します。

実装方針

紐付けるキーの設計

DifyのAPIから見ると会話の状態はconversation_idで管理されます。 これをSlack側の何と紐付けるかが今回の肝です。

Slackで「ひとまとまりの会話」を表現する単位は スレッド で、スレッドは以下の3要素で一意に決まります。

要素 内容 Slack APIでの取り出し方
ワークスペースID チームを表すID(Tから始まる) event.team
チャンネルID チャンネルを表すID(Cから始まる) event.channel
スレッドの起点TS スレッドの最初の投稿のタイムスタンプ event.thread_ts または event.ts

これらを複合キーとしてconversation_idを保存します。 ワークスペースIDも入れておくのは、同じBotを複数のワークスペースに導入したときにキーが衝突しないようにするためです(個人利用の範囲では不要ですが、チャンネルIDとワークスペースIDの組がずれる可能性は将来的にあるので入れておきます)。

会話の開始と継続のフロー

イベントの種類ごとに以下のように扱います。

  1. app_mention: Botへのメンション。これを「会話の開始」のトリガーにする。
    • メンションがチャンネルの直接発言なら、event.tsを新しいスレッドの起点TSとしてスレッドを開く。
    • メンションが既存スレッド内なら、event.thread_tsをキーにする。
    • DBにキーが既にあればそのconversation_idをDifyに渡し、なければ空文字で渡して新しい会話を作る。
    • Difyから返ってきたconversation_idをDBにUPSERTする。
  2. message(スレッド内): スレッド内の通常発言を拾う。
    • thread_tsがない発言(チャンネル直接発言)は無視。
    • DBにキーが存在する場合のみ応答する。紐付いていないスレッドはBotが会話していないので触らない。
  3. message(チャンネル直接): 完全に無視する。

これで、メンションされない限りBotは反応せず、一度メンションして始まったスレッドではメンションなしで継続会話できる、という挙動になります。

データベースを追加する

使用するライブラリ

埋め込み用途で扱いが楽なbetter-sqlite3を使います。 同期APIなのでコードがすっきり書け、ホームサーバーで単一プロセスのBotを動かす分には十分です。

npm install better-sqlite3
npm install -D @types/better-sqlite3

スキーマとアクセサ

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");

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)
  )
`);

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

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

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

export function getConversationId(key: ThreadKey): string | null {
  const row = selectStmt.get(key.teamId, key.channelId, key.threadTs) 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,
    conversationId,
    now,
    now,
  );
}

ポイントは以下の通りです。

  • 複合主キー(team_id, channel_id, thread_ts)で一意性を担保している。
  • ON CONFLICT ... DO UPDATEによるUPSERTで、同じスレッドでDifyから返ってきたconversation_idを毎回上書きする。Difyは基本的に同じIDを返してきますが、念のため最新値で更新しておく。
  • journal_mode = WALにしておくと、コンテナ再起動時のロック競合などに強くなる。
  • DBファイルは./data/conversations.dbに置く。後述の通りDocker Composeでこのディレクトリをホスト側にマウントして永続化する。

マイグレーションの考え方

今回はCREATE TABLE IF NOT EXISTSだけで済ませています。 将来カラムを追加したくなった場合は、PRAGMA user_versionを使って簡易的にスキーマバージョンを管理するか、本格的に必要になったらdrizzle-ormなどのマイグレーション機構を導入する想定です。 今回のテーブル1つ・カラムも固定の規模では、まだ素のSQLで十分です。

ボットの実装を変更する

ハンドラの再構成

src/index.tsを以下のように書き換えます。callDify関数は前回記事と同じ実装を流用します。Dify の /chat-messages エンドポイントを呼び出し、answer(応答テキスト)と conversationId(Difyが管理する会話を一意に識別するID)を返す非同期関数です。

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

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

// callDify は前回記事と同じ実装を流用する
async function callDify(
  userMessage: string,
  conversationId = "",
): Promise<{ answer: string; conversationId: string }> {
  // ...省略...
}

// メンションを起点にスレッドで会話を開始する
app.event("app_mention", async ({ event, say }) => {
  const userText = event.text.split(">").slice(1).join(">").trim();
  if (!userText) return;
  if (!event.team) return;

  // メンションが新規発言なら event.ts をスレッドの起点として使う
  // 既存スレッド内のメンションなら thread_ts を使う
  const threadTs = event.thread_ts ?? event.ts;
  const key: ThreadKey = {
    teamId: event.team,
    channelId: event.channel,
    threadTs,
  };

  try {
    const existing = getConversationId(key) ?? "";
    const { answer, conversationId } = await callDify(userText, existing);
    saveConversationId(key, conversationId);
    await say({ text: answer, thread_ts: threadTs });
  } catch (err) {
    console.error("Error in app_mention handler:", err);
  }
});

// 紐付け済みスレッド内の通常発言にだけ反応する
app.message(async ({ message, say }) => {
  // 編集・削除・Botメッセージなどのサブタイプは無視
  if (message.subtype) return;
  const m = message as GenericMessageEvent;
  if (m.bot_id) return;
  if (!m.thread_ts) return; // スレッド外は対象外
  if (!m.team || !m.text) return;

  const key: ThreadKey = {
    teamId: m.team,
    channelId: m.channel,
    threadTs: m.thread_ts,
  };
  const existing = getConversationId(key);
  if (!existing) return; // Bot が始めていないスレッドは無視

  try {
    const { answer, conversationId } = await callDify(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!");
})();

挙動の差分を整理すると次の通りです。

  • app_mentionが会話の入口になり、必ずスレッド内(thread_ts指定)で返信する。
  • messageハンドラはチャンネル直接発言を完全に無視し、スレッド内かつDBに紐付けがある場合のみ応答する。
  • Difyから返ってきたconversation_idを毎ターンDBに保存することで、Botが再起動してもスレッドの会話を続けられる。

app_mentionイベントだけでも会話できるようにしておく

スレッド内で改めて@botをつけて呼び直すと、app_mentionが再度発火します。 このときも同じthread_tsからconversation_idを引き当てるので、メンションありでもなしでも同じ会話の続きとして処理できます。

逆に「明示的に新しい会話を始めたい」場合は、Slackで新しい投稿(スレッドではない)でメンションすれば別のthread_tsが振られて新しい会話が立ち上がります。 チャンネルのスレッド粒度がDifyの会話粒度に対応する、というシンプルなモデルです。

Docker Composeの更新

better-sqlite3はネイティブモジュールをビルドするため、node:22-slimそのままだとnode-gypの依存ツールが足りずに失敗します。 ビルド用の最小セットを入れて、ビルドステージとランタイムステージを分けておくのが扱いやすいです。

# Dockerfile
FROM node:22-slim AS builder
WORKDIR /app
RUN apt-get update \
 && apt-get install -y --no-install-recommends python3 make g++ \
 && rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm ci

FROM node:22-slim
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
COPY src ./src
RUN mkdir -p /app/data
ENV DB_DIR=/app/data
CMD ["npx", "tsx", "src/index.ts"]

docker-compose.ymlにはDB永続化用のボリュームマウントを追加します。

services:
  slack-dify-bot:
    build: .
    env_file:
      - .env
    volumes:
      - ./data:/app/data
    restart: always

ホスト側の./data/conversations.dbにDBが作られるので、コンテナを作り直してもスレッドの会話履歴は保持されます。 バックアップも単に./data/をコピーするだけで取れるのが手軽です。

動作確認

ビルドして起動します。

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

起動後、Slackで以下のように動かしてみます。

  1. #test@Dify こんにちは。私はshutilsです。 と発言する
    • Botが新しいスレッドを開いて回答する。
    • data/conversations.dbに1行レコードが追加される。
メンションしてボットに話しかける例
メンションしてボットに話しかける例
  1. 同じスレッド内で(メンションなしで)私の名前はなんでしょう? と返信する
    • Botが文脈を引き継いで回答する。先ほどの発言から「私はshutilsです」という情報を踏まえて答えています。
スレッド内でメンションなしで続けて質問する例
スレッド内でメンションなしで続けて質問する例
  1. 別チャンネルや、まったく新しい投稿(スレッド外)で@Dify 別件で質問 と発言する
    • 新しいスレッドが立ち上がり、別のconversation_idが発番される。
    • conversations.dbにもう1行レコードが追加される。

DBの中身は手元から確認できます。

sqlite3 ./data/conversations.db 'SELECT team_id, channel_id, thread_ts, conversation_id FROM conversations;'

Dify側では先ほどのやり取りが一つの会話として扱えています。

Difyでスレッドごとの会話がひとまとまりで表示されている様子
Difyのダッシュボードでスレッドごとの独立した会話が表示されている様子

まとめ

DifyとSlackを橋渡しするBotに、DBを足すだけで、スレッド単位の継続会話が実現できます。

  • 紐付けキーは(team_id, channel_id, thread_ts)の複合キー。
  • app_mentionを会話開始のトリガーにし、以降のスレッド発言は紐付けがある場合のみ応答する。
  • DBファイルはDocker Composeのボリュームでホスト側に永続化しておく。

Difyの会話を、Slackのスレッドで自然に扱えるようになりました。 次回はチャネルごとに問い合わせ先のDifyボットを切り替える機能を足してみます。

コメント

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