用 Claude Code 构建 Slack Bot:从问题分流到故障初响应和日报
使用 Bolt JS、Socket Mode、Slash Command、安全配置、测试和上线清单构建 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 case | Slack 入口 | 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 | 本地开发、内部 PoC | SLACK_APP_TOKEN、connections:write | 进程停止就收不到事件,不适合 Marketplace 分发 |
| Request URL | 生产 HTTP 部署 | HTTPS URL、SLACK_SIGNING_SECRET | ack() 太慢会变成 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 时,效果最好。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。