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 JavaScriptBolt command listenerSocket ModeSlash CommandEvents APIchat.postMessagerequest verificationtokens。相关 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_TOKENconnections: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:historygroups: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_TOKENSLACK_APP_TOKENSLACK_SIGNING_SECRET 存在 secret manager。
  • npm run testnpm 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,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

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