DifyバックエンドAPIと連携するSlack Botを作る

AI

はじめに

ホームサーバーで自前の AI 環境を整えていく中で、Slack から手軽に呼び出せる Bot が欲しくなりました。 既存の記事では OpenAI や Claude の API を直接叩くものが多いですが、今回は Dify のバックエンド API を経由する構成にしました。 Dify を挟むことで、自前で構築したナレッジベースの検索や MCP ツール連携をそのまま Bot から利用できます。

本記事では、Slack アプリの作成からソケットモードの設定、Dify API との連携、ホームサーバーでの起動まで、一通りの手順を紹介します。

ソケットモードとは

Slack Bot を実装する方法には大きく 2 種類あります。

方式 概要 外部公開 URL
HTTP (Webhook) Slack からのイベントを HTTP エンドポイントで受け取る 必要
ソケットモード Bot 側から WebSocket で Slack に接続しイベントを受け取る 不要

ソケットモードの最大のメリットは、外部から到達可能な URL を用意しなくてよい点です。 ホームサーバーや社内のオンプレサーバーで動かす場合、通常はポート開放やリバースプロキシの設定が必要になりますが、ソケットモードならその手間が一切かかりません。 Bot 側からアウトバウンド接続するだけなので、ファイアウォールの内側にいてもそのまま動作します。

使用環境

  • Node.js 22
  • TypeScript 5.x
  • @slack/bolt 4.x
  • tsx 4.x
  • Dify(セルフホスト)
  • ホームサーバー(Ubuntu 24.04 on Proxmox)

Slack アプリを作成する

1. アプリを新規作成する

https://api.slack.com/apps にアクセスし、Create New App をクリックします。 From scratch を選択し、アプリ名とインストール先のワークスペースを入力して Create App をクリックします。

2. ソケットモードを有効にする

左サイドバーの Settings → Socket Mode を開き、Enable Socket Mode をオンにします。

トークン名の入力を求められます(例: my-bot-app-token)。 任意の名前を入力して Generate をクリックすると、xapp- から始まる App-Level Token が発行されます。 このトークンは後で使うので控えておきます。

3. イベントの購読を設定する

左サイドバーの Features → Event Subscriptions を開きます。 Enable Events をオンにします(ソケットモードが有効な場合、Request URL の入力は不要です)。

Subscribe to bot events のセクションで Add Bot User Event をクリックし、以下のイベントを追加します。

  • message.channels — パブリックチャンネルのメッセージ
  • message.im — ダイレクトメッセージ

ページ下部の Save Changes をクリックします。

4. ボットのスコープを設定する

左サイドバーの Features → OAuth & Permissions を開きます。 Scopes → Bot Token Scopes のセクションで Add an OAuth Scope をクリックし、以下のスコープを追加します。

  • chat:write — メッセージの送信
  • channels:history — チャンネルのメッセージ履歴を読む
  • im:history — DM の履歴を読む
  • app_mentions:read — Bot へのメンションを受け取る

5. ワークスペースにインストールする

OAuth & Permissions ページの上部にある Install to Workspace ボタンをクリックします。 権限の確認画面で 許可する をクリックするとインストールが完了します。

インストール後に xoxb- から始まる Bot User OAuth Token が表示されます。こちらも控えておきます。

6. トークンの確認

ここまでで 2 種類のトークンが手元にそろっているはずです。

トークン プレフィックス 用途
App-Level Token xapp- ソケットモードの WebSocket 接続
Bot User OAuth Token xoxb- API の呼び出し(メッセージ送信など)

Dify 連携の準備

1. Dify でアプリを作成する

Dify のダッシュボードにアクセスし、スタジオ → アプリを作成する からチャットボット型のアプリを作成します。 ナレッジベースや MCP ツールをあらかじめ設定しておくと、Bot 経由でそのまま利用できます。 今回は Ollama で gemma3 を利用します。

Ollama を使用するフロー
Ollama を使用するフロー

Ollama を Dify で使用する設定はこちらの記事を参照してください。

2. API キーを取得する

作成したアプリの左側のメニューを開き、APIキー を発行します。

Dify アプリメニュー
Dify アプリメニュー
Dify API キーの発行
Dify API キーの発行

app- から始まるキーが発行されるので控えておきます。

エンドポイントの URL(例: https://your-dify-host/v1)も確認しておきます。 セルフホストの場合はホームサーバー上の Dify のアドレスになります。

Slack Bot を実装する

ディレクトリ構成

slack-dify-bot/
├── src/
│   └── index.ts
├── package.json
├── tsconfig.json
├── docker-compose.yml
└── .env

依存パッケージのインストール

npm install @slack/bolt
npm install -D typescript tsx @types/node

環境変数の設定

.env ファイルを作成し、取得したトークンを設定します。

SLACK_BOT_TOKEN=xoxb-xxxxxxxxxxxx
SLACK_APP_TOKEN=xapp-xxxxxxxxxxxx
DIFY_API_KEY=app-xxxxxxxxxxxx
DIFY_BASE_URL=https://your-dify-host/v1

tsconfig.json の作成

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

ボットの実装

src/index.ts を作成します。

import { App } from "@slack/bolt";

const app = new App({
  token: process.env.SLACK_BOT_TOKEN!,
  appToken: process.env.SLACK_APP_TOKEN!,
  socketMode: true, // ソケットモードを有効にする
});

interface DifyRetrieverResource {
  position: number;
  dataset_id: string;
  dataset_name: string;
  document_id: string;
  document_name: string;
  segment_id: string;
  score: number;
  content: string;
}

interface DifyUsage {
  prompt_tokens: number;
  completion_tokens: number;
  total_tokens: number;
  total_price: string;
  currency: string;
  latency: number;
}

interface DifyResponse {
  event: string;
  task_id: string;
  id: string;
  message_id: string;
  conversation_id: string;
  mode: string;
  answer: string;
  metadata: {
    usage: DifyUsage;
    retriever_resources?: DifyRetrieverResource[];
  };
  created_at: number;
}

async function callDify(
  userMessage: string,
  conversationId = ""
): Promise<{ answer: string; conversationId: string }> {
  console.log("Calling Dify with message:", userMessage);
  const resp = await fetch(`${process.env.DIFY_BASE_URL}/chat-messages`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.DIFY_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      inputs: {},
      query: userMessage,
      response_mode: "blocking", // 同期レスポンス
      conversation_id: conversationId,
      user: "slack-bot",
    }),
    signal: AbortSignal.timeout(60_000), // 60秒でタイムアウト
  });

  console.log("Dify response status:", resp.status, resp.headers.get("content-type"));

  if (!resp.ok) {
    const body = await resp.text();
    throw new Error(`Dify API error: ${resp.status} ${resp.statusText} - ${body}`);
  }

  const data = (await resp.json()) as DifyResponse;
  console.log("Dify API response:", JSON.stringify(data, null, 2));
  // レスポンスと会話IDを返す(次のターンで会話を継続するため)
  return { answer: data.answer, conversationId: data.conversation_id ?? "" };
}

// チャンネルへのメッセージを受け取る
app.message(async ({ message, say }) => {
  // Bot 自身の投稿は無視する
  if ("bot_id" in message) return;
  if (!("text" in message) || !message.text) return;

  try {
    const { answer } = await callDify(message.text);
    console.log("Posting answer to channel:", answer);
    await say(answer);
  } catch (err) {
    console.error("Error in message handler:", err);
  }
});

// Bot へのメンションを受け取る
app.event("app_mention", async ({ event, say }) => {
  // メンション部分(<@UXXXXXXXX>)を除去する
  const userText = event.text.split(">").slice(1).join(">").trim();
  if (!userText) return;

  try {
    const { answer } = await callDify(userText);
    console.log("Posting answer to mention:", answer);
    await say({ text: answer, thread_ts: event.ts });
  } catch (err) {
    console.error("Error in app_mention handler:", err);
  }
});

(async () => {
  await app.start();
  console.log("⚡️ Bolt app is running!");
})();

起動する

開発時は tsx で直接実行できます。--env-file オプションで .env を読み込むため、dotenv は不要です。

npx tsx --env-file .env src/index.ts

本番運用は後述の Docker Compose で実行します。

起動に成功すると以下のようなログが表示されます。

⚡️ Bolt app is running!

ソケットモードで Slack に接続が確立され、メッセージを受け取れる状態になります。 サーバーを再起動しても自動で起動するよう、Docker Compose でコンテナ化しておくと便利です。

実行例は以下のようになります。

Slack Bot の実行例
Slack Bot の実行例

Docker Compose での永続化

プロジェクトルートに Dockerfiledocker-compose.yml を作成します。

# Dockerfile
FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm install tsx
COPY src ./src
CMD ["npx", "tsx", "src/index.ts"]
# docker-compose.yml
services:
  slack-dify-bot:
    build: .
    env_file:
      - .env
    restart: always

以下のコマンドでバックグラウンド起動できます。

docker compose up -d

ホストの再起動後も restart: always によって自動的に起動します。 ログの確認は docker compose logs -f でリアルタイムに追えます。 実運用する場合はindex.tsをビルドして node dist/index.js で起動するようにするのがよいかと思います。

ソケットモードで動かすメリット

冒頭でも触れたようにソケットモードを使うと、ネットワーク的に同じセグメントにいる他のオンプレサービスとシームレスに連携できます。

たとえば以下のような構成が、追加のポート開放なしに実現できます。

  • Dify(セルフホスト):外部公開せずローカルネットワーク内でのみ通信
  • GitLab:コードリポジトリの情報を Dify のナレッジに取り込む
  • Grafana:メトリクスをクエリして Slack に通知する

Dify の MCP 連携を使えば、これらのサービスを Bot から自然言語で操作することも可能です。 外部公開なしに強力な AI アシスタントを社内・自宅環境に閉じて運用できるのはソケットモードならではのメリットです。

トラブルシュート

Dify 1.13.0 で blocking モードが機能しない

Dify 1.13.0 には、response_mode: "blocking" を指定しても text/event-stream が返ってくるバグがあります。

具体的には以下のような挙動になります。

  • レスポンスヘッダーが Content-Type: text/event-stream; charset=utf-8 になる
  • レスポンスが数十バイトで途切れ、resp.json() がパースエラーになる
  • curl では transfer closed with outstanding read data remaining のようなエラーが出る

この問題は GitHub Issue #32351 として報告されており、Dify 1.13.1 で修正済みです。 セルフホストで 1.13.0 を使っている場合は 1.13.1 以降へのアップデートで解消されます。

デバッグ時は console.log でステータスコードと Content-Type ヘッダーを確認しておくと原因を特定しやすいです。

console.log("Dify response status:", resp.status, resp.headers.get("content-type"));

Content-Typetext/event-stream になっていれば、このバグを踏んでいる可能性が高いです。

まとめ

ソケットモードを使うと外部公開 URL が不要になるため、ホームサーバーや社内オンプレ環境でも簡単に Slack Bot を動かせます。 Dify のバックエンド API を経由することで、自前のナレッジベースや MCP ツールを Bot からそのまま利用できます。 ホームサーバー上で完結する構成なので、他のオンプレサービスとの連携もネットワーク的に容易です。ぜひ試してみてください。

続きはSlackのスレッドでDifyとの会話を継続するをご覧ください。

コメント

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