Use Cases (更新: 2026/6/3)

Claude CodeでSlack Botを作る: 問い合わせ・障害一次対応・日次レポートまで育てる実装手順

Claude CodeとBolt JSでSlack Botを作る手順。Socket Mode、Slash Command、セキュリティまで解説。

Claude CodeでSlack Botを作る: 問い合わせ・障害一次対応・日次レポートまで育てる実装手順

通知botで終わらせない

Slack Botは、Slack上のメッセージ、Slash Command、ボタン、モーダル入力に反応して業務を進めるアプリです。Bolt for JavaScriptは、その受け口をNode.jsで書くための公式フレームワークです。言い換えると、Boltは「Slackから来たイベントを、どの関数に渡すか」を整理する足場です。

Claude Codeでの失敗は、通知だけを投げる小さなコードで満足することです。本当に役に立つbotは、問い合わせ受付、障害の一次対応、日次レポート、公開前チェックのように、Slack上の判断を構造化します。Masaの運用でも、最初の通知botは便利でしたが、担当者、緊急度、完了状態が残らず、結局チャンネルを読み返していました。

この記事は、2026年6月3日時点のSlack公式ドキュメントであるBolt for JavaScriptBoltのコマンド受信Socket ModeSlash CommandEvents APIchat.postMessageリクエスト署名検証tokensを確認して書いています。周辺知識はWebhook実装API開発secrets管理ワークフロー自動化も参考になります。

先に決めるユースケース

Claude Codeにいきなり「Slack Botを作って」と頼むと、薄いサンプルになりがちです。先に入口、保存項目、返信、失敗時の扱いを決めます。

ユースケースSlack上の入口botが行うこと注意点
問い合わせトリアージ/triage add、モーダル件名、緊急度、依頼者をそろえて専用チャンネルに投稿する顧客名、秘密情報、未公開URLを貼らせない
障害一次対応@botメンション、ボタン初動チェックリストを返し、担当者とスレッドを残すbotが断定回答しすぎないよう人間へ渡す条件を決める
日次レポート/triage list、定時ジョブ未完了項目をまとめ、朝会や日報に貼る件数が多い場合はSlackの長文制限を避ける
記事・LP公開前チェックSlash CommandCTA、内部リンク、担当者、公開URLを確認する下書きURLと本番URLを混ぜない

概念図にすると、今回作るbotは次の流れです。

flowchart LR
  A["Slack user"] --> B["/triage or @mention"]
  B --> C["Bolt listener"]
  C --> D["Triage logic"]
  D --> E["chat.postMessage"]
  D --> F["Modal and button"]

この段階でClaude Codeへ渡す依頼文は、次のように具体化します。

Bolt for JavaScriptでSlack Botを実装してください。
目的は問い合わせトリアージです。
含めるもの:
- Socket ModeとRequest URLを環境変数で切り替える
- /triage add, /triage list, /triage modal
- モーダル入力とview_submission
- Mark doneボタン
- app_mentionへの案内返信
- scopes、secrets、署名検証の説明
- triage.tsの単体テスト
疑似APIは使わず、コピペで動くTypeScriptにしてください。

Socket ModeとRequest URLの選び方

Socket Modeは、公開HTTPSエンドポイントを用意せず、bot側からSlackへWebSocket接続してイベントを受け取る方式です。ローカル開発や社内PoCに向き、xapp-で始まるApp-Level Tokenを使います。

Request URLは、Slackから自分のHTTPSエンドポイントへPOSTしてもらう方式です。本番運用ではこちらを選ぶ場面が多く、HTTPで受ける場合はSigning Secretで署名を検証します。Boltが検証を肩代わりしても、古いverification tokenに頼らない方針は残します。

方式向いている場面必要なもの落とし穴
Socket Modeローカル開発、社内PoCSLACK_APP_TOKENconnections:writeプロセスが落ちると受信できない。Marketplace配布には向かない
Request URL本番HTTP運用HTTPS URL、SLACK_SIGNING_SECRETack()が遅いとSlack側でタイムアウトする

最初はSocket Modeで動作確認し、外部ユーザーや本番チャンネルに出す段階でRequest URLへ移すと安全です。以下のコードはSLACK_SOCKET_MODE=trueで切り替えます。

Slackアプリ設定とscopes

Slackアプリのmanifestはリポジトリに置き、開発用と本番用で差分が見えるようにします。今回必要な権限は最小限です。commandsはSlash Commandを受けるため、chat:writeはメッセージ投稿のため、app_mentions:readはbotへのメンションを受けるために使います。

display_information:
  name: Claude Triage Bot
  description: Collect triage requests from Slack
  background_color: "#2E2A24"
features:
  bot_user:
    display_name: Claude Triage
    always_online: false
  slash_commands:
    - command: /triage
      description: Add or list triage items
      usage_hint: "add Fix login | list | modal"
      should_escape: true
oauth_config:
  scopes:
    bot:
      - app_mentions:read
      - chat:write
      - commands
settings:
  event_subscriptions:
    bot_events:
      - app_mention
  interactivity:
    is_enabled: true
  socket_mode_enabled: true
  org_deploy_enabled: false
  token_rotation_enabled: false

channels:historygroups:historyは最初から足しません。botが本当に履歴を読む設計になった時点で、プライバシーと監査を含めてレビューします。

ローカルプロジェクトを作る

Node.js 20以上を前提にします。以下はそのまま貼って試せる最小構成です。

mkdir claude-slack-triage-bot
cd claude-slack-triage-bot
npm init -y
npm install @slack/bolt @slack/types dotenv
npm install -D typescript tsx vitest @types/node
npm pkg set type=module
npm pkg set scripts.dev="tsx watch src/app.ts"
npm pkg set scripts.build="tsc"
npm pkg set scripts.start="node dist/app.js"
npm pkg set scripts.test="vitest run"
mkdir src tests
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src/**/*.ts"]
}

.env.exampleも置きます。実値は.envやホスティング側のsecret managerに入れ、Gitや記事本文には載せません。

SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_SIGNING_SECRET=your-signing-secret
SLACK_SOCKET_MODE=true
SLACK_APP_TOKEN=xapp-your-app-level-token
TRIAGE_CHANNEL_ID=C0123456789
PORT=3000

xoxb-はbot token、xapp-はSocket Modeなどで使うApp-Level Tokenです。Signing SecretはHTTPリクエストがSlack由来か確かめる鍵です。Claude Codeには実値ではなく、変数名、使う場所、失敗時のログ方針だけ渡します。

コピペで動くBolt実装

まずSlackに依存しないロジックをsrc/triage.tsへ分けます。ここを分けると、Claude Codeにテストを追加させやすくなります。

// src/triage.ts
import type { KnownBlock, View } from "@slack/types";

export type Severity = "low" | "normal" | "high";

export interface Ticket {
  id: string;
  channelId: string;
  title: string;
  createdBy: string;
  severity: Severity;
  status: "open" | "done";
  createdAt: string;
}

const tickets = new Map<string, Ticket>();

export function resetForTest() {
  tickets.clear();
}

export function parseTriageText(text: string) {
  const [actionRaw, ...rest] = text.trim().split(/\s+/);
  return { action: actionRaw || "help", title: rest.join(" ").trim() };
}

export function addTicket(input: {
  channelId: string;
  title: string;
  createdBy: string;
  severity?: Severity;
}) {
  const ticket: Ticket = {
    id: `triage_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
    channelId: input.channelId,
    title: input.title,
    createdBy: input.createdBy,
    severity: input.severity ?? "normal",
    status: "open",
    createdAt: new Date().toISOString(),
  };
  tickets.set(ticket.id, ticket);
  return ticket;
}

export function completeTicket(id: string) {
  const ticket = tickets.get(id);
  if (!ticket) return undefined;
  const updated: Ticket = { ...ticket, status: "done" };
  tickets.set(id, updated);
  return updated;
}

export function formatTicketList(channelId: string) {
  const open = [...tickets.values()].filter((ticket) => {
    return ticket.channelId === channelId && ticket.status === "open";
  });

  if (open.length === 0) return "No open triage items.";

  return open
    .map((ticket, index) => {
      return `${index + 1}. [${ticket.severity}] ${ticket.title} by <@${ticket.createdBy}>`;
    })
    .join("\n");
}

export function ticketBlocks(ticket: Ticket): KnownBlock[] {
  return [
    {
      type: "section",
      text: {
        type: "mrkdwn",
        text: `*${ticket.title}*\nSeverity: ${ticket.severity}\nOwner: <@${ticket.createdBy}>`,
      },
    },
    {
      type: "actions",
      elements: [
        {
          type: "button",
          text: { type: "plain_text", text: "Mark done" },
          action_id: "triage_done",
          value: ticket.id,
        },
      ],
    },
  ];
}

export function modalView(): View {
  return {
    type: "modal",
    callback_id: "triage_modal_submit",
    title: { type: "plain_text", text: "New triage" },
    submit: { type: "plain_text", text: "Create" },
    close: { type: "plain_text", text: "Cancel" },
    blocks: [
      {
        type: "input",
        block_id: "title_block",
        label: { type: "plain_text", text: "What needs attention?" },
        element: {
          type: "plain_text_input",
          action_id: "title_input",
          min_length: 3,
          max_length: 120,
        },
      },
      {
        type: "input",
        block_id: "severity_block",
        label: { type: "plain_text", text: "Severity" },
        element: {
          type: "static_select",
          action_id: "severity_input",
          initial_option: {
            text: { type: "plain_text", text: "Normal" },
            value: "normal",
          },
          options: [
            { text: { type: "plain_text", text: "High" }, value: "high" },
            { text: { type: "plain_text", text: "Normal" }, value: "normal" },
            { text: { type: "plain_text", text: "Low" }, value: "low" },
          ],
        },
      },
    ],
  };
}

次にBoltのlistenerをつなぎます。listenerは、Slash Command、モーダル送信、ボタン操作、メンションイベントを受ける関数です。

// src/app.ts
import "dotenv/config";
import { App, LogLevel } from "@slack/bolt";
import {
  addTicket,
  completeTicket,
  formatTicketList,
  modalView,
  parseTriageText,
  ticketBlocks,
  type Severity,
} from "./triage.js";

const socketMode = process.env.SLACK_SOCKET_MODE === "true";
const required = ["SLACK_BOT_TOKEN", socketMode ? "SLACK_APP_TOKEN" : "SLACK_SIGNING_SECRET"];

for (const key of required) {
  if (!process.env[key]) throw new Error(`Missing environment variable: ${key}`);
}

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  socketMode,
  appToken: process.env.SLACK_APP_TOKEN,
  logLevel: LogLevel.INFO,
});

app.command("/triage", async ({ ack, command, respond, client }) => {
  await ack();
  const parsed = parseTriageText(command.text);

  if (parsed.action === "add") {
    if (!parsed.title) {
      await respond("Usage: `/triage add Fix login redirect`");
      return;
    }

    const ticket = addTicket({
      channelId: command.channel_id,
      title: parsed.title,
      createdBy: command.user_id,
      severity: "normal",
    });

    await respond({
      response_type: "in_channel",
      text: `Triage item added: ${ticket.title}`,
      blocks: ticketBlocks(ticket),
    });
    return;
  }

  if (parsed.action === "list") {
    await respond({ response_type: "ephemeral", text: formatTicketList(command.channel_id) });
    return;
  }

  if (parsed.action === "modal") {
    await client.views.open({ trigger_id: command.trigger_id, view: modalView() });
    return;
  }

  await respond("Usage: `/triage add ...`, `/triage list`, or `/triage modal`");
});

app.view("triage_modal_submit", async ({ ack, view, body, client }) => {
  const titleState = view.state.values.title_block.title_input;
  const severityState = view.state.values.severity_block.severity_input;
  const title = titleState.type === "plain_text_input" ? titleState.value?.trim() : "";
  const severity =
    severityState.type === "static_select"
      ? severityState.selected_option?.value ?? "normal"
      : "normal";

  if (!title) {
    await ack({ response_action: "errors", errors: { title_block: "Please enter a title." } });
    return;
  }

  await ack();

  const channelId = process.env.TRIAGE_CHANNEL_ID ?? "modal-only";
  const ticket = addTicket({
    channelId,
    title,
    createdBy: body.user.id,
    severity: severity as Severity,
  });

  if (process.env.TRIAGE_CHANNEL_ID) {
    await client.chat.postMessage({
      channel: process.env.TRIAGE_CHANNEL_ID,
      text: `New triage item: ${ticket.title}`,
      blocks: ticketBlocks(ticket),
    });
  }
});

app.action("triage_done", async ({ ack, action, respond }) => {
  await ack();
  const value = action.type === "button" ? action.value : undefined;
  if (!value) return;

  const ticket = completeTicket(value);
  await respond(ticket ? `Closed: ${ticket.title}` : "Ticket not found.");
});

app.event("app_mention", async ({ event, say }) => {
  await say({
    thread_ts: event.ts,
    text: "Use `/triage add ...`, `/triage list`, or `/triage modal`.",
  });
});

const port = Number(process.env.PORT ?? 3000);
if (socketMode) {
  await app.start();
} else {
  await app.start(port);
}

app.logger.info(`Slack bot started in ${socketMode ? "Socket Mode" : `HTTP mode on ${port}`}`);

最後に、Slackへ接続しない単体テストを置きます。

// tests/triage.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import {
  addTicket,
  completeTicket,
  formatTicketList,
  parseTriageText,
  resetForTest,
} from "../src/triage";

describe("triage helpers", () => {
  beforeEach(() => resetForTest());

  it("parses slash command text", () => {
    expect(parseTriageText("add Fix login")).toEqual({
      action: "add",
      title: "Fix login",
    });
  });

  it("lists only open tickets", () => {
    const ticket = addTicket({
      channelId: "C123",
      title: "Review pricing CTA",
      createdBy: "U123",
      severity: "high",
    });

    expect(formatTicketList("C123")).toContain("[high] Review pricing CTA");
    completeTicket(ticket.id);
    expect(formatTicketList("C123")).toBe("No open triage items.");
  });
});

実行は次の順です。

npm run test
npm run build
npm run dev

Socket Modeならnpm run devのままSlackで/triage add Test from Slackを打ちます。Request URLなら、デプロイ先のhttps://example.com/slack/eventsをSlash Command、Interactivity、Event Subscriptionsに設定します。

よくある落とし穴とセキュリティ

  • ack()を遅らせない。Slash Command、ボタン、モーダル送信では、DB更新や外部APIより先に受信確認を返します。
  • trigger_idを長く保持しない。/triage modalを受けたら先にviews.openを呼び、詳細検証はview_submission側で行います。
  • 権限不足をコードだけで直そうとしない。chat:write不足、bot未招待、app_mention未購読はSlack側の設定です。
  • Socket ModeとRequest URLを混ぜない。起動ログに選択中のモードを出すだけでも切り分けが速くなります。
  • secretを露出しない。xoxb-xapp-、Signing Secretをプロンプト、スクリーンショット、ログ、fixtureに貼らず、漏れたら即ローテーションします。
  • botに判断させすぎない。障害や問い合わせでは、原因断定より「次に見る情報」と「人間へ渡す条件」を返します。

本番デプロイ前チェックリスト

  • manifestのscopesとコードで使うAPIが一致している
  • /triageの名前が既存アプリと衝突していない
  • Interactivityが有効で、モーダルとボタンのpayloadを受けられる
  • Botを投稿先チャンネルへ招待している
  • SLACK_BOT_TOKENSLACK_APP_TOKENSLACK_SIGNING_SECRETをsecret managerへ入れている
  • npm run testnpm run buildが通る
  • Request URL運用ではHTTPSとSlack署名検証が有効
  • Socket Mode運用ではプロセス監視と自動再起動がある
  • ログにtoken、未マスクの個人情報、顧客名、非公開URLが出ない
  • 障害時に誰へエスカレーションするかをチャンネル説明に書いている

Claude Codeに任せる範囲も分けます。manifest、Slack非依存ロジック、Bolt listener、単体テスト、デプロイチェックを別々にレビューさせると、設定ミスとコードバグを混ぜずに直せます。

ClaudeCodeLabでは、社内bot、Webhook、API、secrets管理を研修・相談で扱っています。自社向けのチェックリスト、CLAUDE.md、公開前レビューの型が必要なら教材・テンプレートも組み合わせると、botを運用改善や収益導線につなげやすくなります。

実際に試した結果

実際に試した結果、最短だったのは「大きなSlack Botを一気に作る」ことではありませんでした。先にmanifestとscopeを固定し、次にtriage.tsのような純粋なロジックを作り、最後にBolt listenerとSlack管理画面をつなぐ順番が安定しました。Claude Codeにはコード生成だけでなく、権限、secret、テスト、デプロイ前確認まで同じ作業単位で持たせるのが近道です。

#Claude Code #Slack Bot #Bolt JS #Slash Command #業務自動化
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。