はじめに
前回の記事では、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の組がずれる可能性は将来的にあるので入れておきます)。
会話の開始と継続のフロー
イベントの種類ごとに以下のように扱います。
app_mention: Botへのメンション。これを「会話の開始」のトリガーにする。- メンションがチャンネルの直接発言なら、
event.tsを新しいスレッドの起点TSとしてスレッドを開く。 - メンションが既存スレッド内なら、
event.thread_tsをキーにする。 - DBにキーが既にあればその
conversation_idをDifyに渡し、なければ空文字で渡して新しい会話を作る。 - Difyから返ってきた
conversation_idをDBにUPSERTする。
- メンションがチャンネルの直接発言なら、
message(スレッド内): スレッド内の通常発言を拾う。thread_tsがない発言(チャンネル直接発言)は無視。- DBにキーが存在する場合のみ応答する。紐付いていないスレッドはBotが会話していないので触らない。
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で以下のように動かしてみます。
#testで@Dify こんにちは。私はshutilsです。と発言する- Botが新しいスレッドを開いて回答する。
data/conversations.dbに1行レコードが追加される。

- 同じスレッド内で(メンションなしで)
私の名前はなんでしょう?と返信する- Botが文脈を引き継いで回答する。先ほどの発言から「私はshutilsです」という情報を踏まえて答えています。

- 別チャンネルや、まったく新しい投稿(スレッド外)で
@Dify 別件で質問と発言する- 新しいスレッドが立ち上がり、別の
conversation_idが発番される。 conversations.dbにもう1行レコードが追加される。
- 新しいスレッドが立ち上がり、別の
DBの中身は手元から確認できます。
sqlite3 ./data/conversations.db 'SELECT team_id, channel_id, thread_ts, conversation_id FROM conversations;'Dify側では先ほどのやり取りが一つの会話として扱えています。

まとめ
DifyとSlackを橋渡しするBotに、DBを足すだけで、スレッド単位の継続会話が実現できます。
- 紐付けキーは
(team_id, channel_id, thread_ts)の複合キー。 app_mentionを会話開始のトリガーにし、以降のスレッド発言は紐付けがある場合のみ応答する。- DBファイルはDocker Composeのボリュームでホスト側に永続化しておく。
Difyの会話を、Slackのスレッドで自然に扱えるようになりました。 次回はチャネルごとに問い合わせ先のDifyボットを切り替える機能を足してみます。


コメント