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

用 Claude Code 构建 Slack Bot:从问题分流到故障初响应和日报

使用 Bolt JS、Socket Mode、Slash Command、安全配置、测试和上线清单构建 Slack Bot。

用 Claude Code 构建 Slack Bot:从问题分流到故障初响应和日报

不要只做通知 bot

Slack Bot 是一种在 Slack 中响应消息、Slash Command、按钮和 modal 提交的应用。Bolt for JavaScript 是 Slack 官方的 Node.js 框架,用来把这些事件交给正确的 handler。对初学者来说,Bolt 就是一个脚手架,让你可以写清楚:“当这个 Slack 事件到来时,运行这个函数。”

用 Claude Code 做 Slack Bot 时,常见错误是只做一个发送通知的小 bot。真正有用的 bot 会把频道里的零散对话变成结构化工作:支持请求分流、故障初响应、每日汇总、审批请求和发布前检查。Masa 在自己的流程里也踩过这个坑:最早的通知 bot 短期内很方便,但它没有记录负责人、紧急程度和是否完成,最后还是要人工回看频道。

本文基于 2026 年 6 月 3 日确认过的 Slack 官方文档:Bolt for JavaScript、Bolt command listener、Socket Mode、Slash Command、Events API、chat.postMessage、request verification 和 tokens。相关 ClaudeCodeLab 内容可以继续看 Webhook 实现、API 开发、secrets 管理 和 workflow 自动化。

先确定 use case

如果只对 Claude Code 说”做一个 Slack bot”,结果通常只是一个很薄的 demo。先决定 Slack 入口、需要收集的字段、返回消息以及失败时的处理方式。

Use caseSlack 入口bot 做什么需要控制的风险
支持请求分流/triage add、modal统一标题、严重程度、请求人和频道通知用户粘贴客户名、secret 或私有 URL
故障初响应@bot mention、button返回初始 checklist,并把上下文留在线程中bot 过度自信,没有转交给人工
每日汇总/triage list、定时任务汇总未完成事项,用于 daily 或日报消息过长,在 Slack 中难以阅读
文章或落地页发布前检查Slash Command检查 CTA、内部链接、负责人和发布 URL草稿 URL 和生产 URL 混在一起

这次的结构刻意保持简单。

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
- modal 输入和 view_submission 处理
- Mark done 按钮
- app_mention 的帮助回复
- scopes、secrets、request verification 的说明
- triage.ts 的单元测试
不要使用伪 API。请写可复制运行的 TypeScript。

Socket Mode 还是 Request URL

Socket Mode 由你的 app 主动连接 Slack,通过 WebSocket 接收事件。因此本地开发时不需要公开 HTTPS endpoint。它适合 prototype、内部工具和防火墙后的 PoC。Slack 文档说明,Socket Mode 需要启用设置,并使用以 xapp- 开头的 app-level token。

Request URL 则是 Slack 向你的 HTTPS endpoint 发送 HTTP POST。这是生产环境常见方式。使用 HTTP 接收时,要用 Signing Secret 验证 request signature。Bolt 可以帮你做这件事,但设计文档仍然应该写清楚:不要依赖旧的 verification token。

方式适合场景必要设置容易踩坑
Socket Mode本地开发、内部 PoCSLACK_APP_TOKEN、connections:write进程停止就收不到事件,不适合 Marketplace 分发
Request URL生产 HTTP 部署HTTPS URL、SLACK_SIGNING_SECRETack() 太慢会变成 Slack timeout

建议先用 Socket Mode 验证,再在触达生产频道或外部用户时切到 Request URL。下面的代码通过 SLACK_SOCKET_MODE=true 切换。

Slack Manifest 和 Scopes

把 manifest 放进 repository,避免 dev 和 production 配置悄悄漂移。这里的 scope 保持最小:commands 用于接收 Slash Command,chat:write 用于发消息,app_mentions:read 用于接收 bot mention。

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:history 或 groups:history。只有当 bot 的设计真的需要读取频道历史,并且已经做过隐私 review 时,才添加这类 scope。

创建本地项目

以下假设使用 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 request 是否真的来自 Slack。Claude Code 不需要真实值,只需要变量名、期望行为和日志规则。

可复制运行的 Bolt 实现

先把不依赖 Slack 的逻辑放到 src/triage.ts。

// 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 listeners。

// 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,部署 app 后,把 https://example.com/slack/events 配到 slash commands、interactivity 和 event subscriptions。

常见坑和安全注意

先调用 ack(),再做慢操作。command、button 和 modal 都应该先确认接收,再写数据库或调用外部 API。

把 trigger_id 当作短生命周期值。先打开 modal,再在 view_submission 中验证详细输入。

不要只在代码里排查权限问题。缺少 chat:write、bot 没被邀请进频道、没有配置 app_mention subscription,都要在 Slack settings 中修。

不要混用模式。Socket Mode 需要 SLACK_APP_TOKEN;Request URL 需要 HTTPS 和 SLACK_SIGNING_SECRET。启动时把当前模式写入日志。

不要泄露 secret。不要把 xoxb-、xapp- 或 Signing Secret 粘到 Claude Code prompt、截图、日志、fixture 或文章中。泄露后立即 rotate。

最后,不要让 bot 做太多判断。支持和故障场景下,bot 应该提供下一步检查和 escalation rule,而不是假装知道根因。

上线检查清单

  • manifest scopes 与代码使用的 API 一致。
  • /triage 不与其他已安装 app 冲突。
  • Interactivity 已为 modal 和 button 启用。
  • bot 已被邀请到目标频道。
  • SLACK_BOT_TOKEN、SLACK_APP_TOKEN、SLACK_SIGNING_SECRET 存在 secret manager。
  • npm run test 和 npm run build 通过。
  • Request URL 使用 HTTPS 和 Slack signature verification。
  • Socket Mode 有 process monitoring 和 restart。
  • 日志不包含 token、未脱敏个人信息、客户名或私有 URL。
  • 频道 topic 写明 bot 无法处理时由谁接手。

把 Claude Code 的工作拆开:manifest 和 scopes 提案、与 Slack 无关的逻辑、Bolt listeners、unit tests、deploy checklist。这样 Slack 配置错误和代码 bug 不会混在一起。

ClaudeCodeLab 在 training 和 consultation 中处理这类内部 bot、webhook、API 和 secrets workflow。如果需要可复用的 CLAUDE.md 规则、发布前 review 模板和团队 checklist,可以结合 templates 和 products,让 bot 连接到运营和收入路径,而不是停留在 demo。

实测结果

最快的方式并不是一次生成一个很大的 Slack Bot。更稳定的顺序是:先固定 manifest 和 scopes,再写 triage.ts 这样的纯逻辑,最后连接 Bolt listeners 和 Slack admin settings。Claude Code 在同时负责代码、权限、secrets、测试和 production checklist,并且这些内容可以一起 review 时,效果最好。

#Claude Code #Slack Bot #Bolt JS #Slash Command #工作流自动化
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

让 Claude Code 真正进入可验证的工作流

先用免费 PDF 固定基础,再用 Gumroad 教材复用工作流;如果涉及团队导入、权限或收入路径,可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。