Architecture · 2026-06-11

miniCTO Slack Bot × Metabot

现在 Slack 这条链路只到「OAuth 装上 + 装完打招呼」就停住了,bot 自己回的是写死的 echo。 本文画清楚:(1) 已经搭起来的部分长什么样、(2) 一条消息今天怎么走、(3) 接入 metabot 之后会怎么走。

Slack 用户 · workspace events + Web API miniCTO-slack gateway · OAuth · token DB @slack/bolt · Postgres slack.minicto.ai · :4891 metabot Claude 会话引擎 /api/talk · sender adapter events postMessage /api/talk /api/internal/send-*

01 现在跑着的东西 已部署

用户访问 slack.minicto.ai → CF DNS-only → 我们机器 nginx → node。

拓扑图

实线 = 长连接 / 反代;虚线 = 偶发调用(外发 Slack API)
用户浏览器 / Slack 客户端 HTTPS 请求方 Slack API events 推 + chat.postMessage 收 43.166.187.19 · 本机 Cloudflare DNS A 记录 · DNS-only nginx + LE :443 → :4891 minicto-slack (Node) @slack/bolt · pm2 #15 :4891 Postgres (Docker) installations 表 127.0.0.1:5436 api.slack.com (Slack App 配置) OAuth redirect · events URL · 7 个 scope 外部资源(非本机)
入站 HTTPS 链路
Slack API 双向调用

服务栈一行总结

slack.minicto.ai
Cloudflare DNS-only A 记录 → 43.166.187.19
DNS
nginx
443/80 → 127.0.0.1:4891;Let's Encrypt 到期 2026-09-09 自动续
:443 / :80
minicto-slack (Node)
@slack/bolt ExpressReceiver:OAuth + Events handler + 落地页 + 本文档
:4891
Postgres (Docker)
multi-tenant token 持久化;表 installations
127.0.0.1:5436
Slack App
api.slack.com:OAuth redirect + 7 bot scopes + Events URL 已验证(含 im:write)
Distribution

02 安装流程 · OAuth 一键装到 workspace 已实现

从用户点 Add to Slack 到 bot 出现在 Slack + 收到欢迎 DM。

时序图

注意第 7 步:欢迎 DM 是 bot 主动说话的第一次发生
sequenceDiagram autonumber participant U as 用户浏览器 participant G as miniCTO-slack participant S as slack.com participant DB as Postgres U->>G: GET / G-->>U: 落地页 HTML (Add to Slack 按钮) U->>G: GET /slack/install Note over G: 生成 state JWT
编码 scope/client_id G-->>U: 302 → slack.com/oauth/v2/authorize U->>S: 登录 + 选 workspace + Allow S-->>U: 302 → /slack/oauth_redirect?code=... U->>G: GET /slack/oauth_redirect G->>S: POST oauth.v2.access (code → bot_token) S-->>G: { bot_token, team, user, scopes, ... } G->>DB: INSERT installations (JSONB) G->>S: conversations.open (开 IM) G->>S: chat.postMessage 欢迎 DM G-->>U: "miniCTO is installed ✓"

03 今天的消息流 · 写死 echo 已实现

还没接 metabot;app_mention 触发的回话是 hardcoded。

时序图

sequenceDiagram autonumber participant U as 用户 participant S as Slack participant G as miniCTO-slack participant DB as Postgres U->>S: @miniCTO 你好 S->>G: POST /slack/events
(X-Slack-Signature 签名) Note over G: Bolt 验签
(signing secret + 1min 时间窗) G->>DB: SELECT installation by team_id DB-->>G: bot_token Note over G: app.event('app_mention') 触发
写死 echo 文案 G->>S: chat.postMessage (threaded) S-->>U: bot 回话
// src/server.js — 现在的 handler
app.event('app_mention', async ({ event, client }) => {
  await client.chat.postMessage({
    channel:   event.channel,
    thread_ts: event.thread_ts ?? event.ts,
    text:      `hi <@${event.user}> — miniCTO 在线。你刚说: "..."`,
  });
});

04 接入 metabot · gateway 模式 待实施

把 minicto-slack 定位成 Slack 网关:管 OAuth + token,不做对话逻辑。对话交给 metabot。

总览流程图

蓝色 = 入站(用户消息进 metabot);绿色 = 出站(bot 回话出 Slack)
flowchart LR classDef slack fill:#1a1410,stroke:#ECB22E,color:#e6edf3 classDef gw fill:#0f1424,stroke:#7b9cff,color:#e6edf3 classDef mb fill:#0f1f1c,stroke:#6ee7c4,color:#e6edf3 classDef store fill:#121821,stroke:#1f2a3a,color:#c9d4e3 USER([用户在 Slack 发消息]) SE[POST /slack/events]:::slack SP[chat.postMessage]:::slack EVENT[events handler]:::gw INTAPI["/api/internal/send-text"]:::gw PG[(Postgres
installations)]:::store TALK[POST /api/talk]:::mb SESS[Claude session]:::mb ADAPT[SlackSenderAdapter]:::mb USER -- 入站 --> SE SE --> EVENT EVENT -- "POST /api/talk async" --> TALK TALK --> SESS SESS -- "sender.sendText" --> ADAPT ADAPT -- "POST /api/internal/send-text" --> INTAPI INTAPI --> PG INTAPI -- "chat.postMessage" --> SP SP -- 出站 --> USER linkStyle 0 stroke:#7b9cff,stroke-width:1.5 linkStyle 1 stroke:#7b9cff,stroke-width:1.5 linkStyle 2 stroke:#7b9cff,stroke-width:1.5 linkStyle 3 stroke:#7b9cff,stroke-width:1.5 linkStyle 4 stroke:#6ee7c4,stroke-width:1.5 linkStyle 5 stroke:#6ee7c4,stroke-width:1.5 linkStyle 6 stroke:#6ee7c4,stroke-width:1.5 linkStyle 7 stroke:#6ee7c4,stroke-width:1.5 linkStyle 8 stroke:#6ee7c4,stroke-width:1.5

入站时序:用户 → metabot

sequenceDiagram autonumber participant U as 用户 participant S as Slack participant G as miniCTO-slack participant M as metabot U->>S: @miniCTO 帮我跑测试 S->>G: POST /slack/events
(app_mention / message.im) Note over G: 验签
去 @ 前缀
映射 chatId/senderId G->>M: POST /api/talk (async) Note right of M: { botName, chatId,
senderId, prompt, async:true } M-->>G: 200 { taskId, status:'pending' } G-->>S: 200 (3 秒内 ACK) Note over M: 路由到对应 Claude session
开始跑 prompt

出站时序:metabot → 用户

sequenceDiagram autonumber participant M as metabot session participant A as SlackSenderAdapter participant G as miniCTO-slack participant DB as Postgres participant S as Slack participant U as 用户 M->>A: sender.sendText(chatId, text) Note over A: 解析 chatId
"slack:T...:C..." A->>G: POST /api/internal/send-text
(Bearer) G->>DB: SELECT token WHERE team_id = T... DB-->>G: bot_token G->>S: chat.postMessage S-->>U: bot 回话出现 G-->>A: 200 OK

05 chatId / senderId 命名 设计中

前缀化避免跟飞书 oc_* / p_* 冲突;team_id 嵌进去让 adapter 反向找 token。

chatId 拆解

slack : T914QLDUL : C03ABCDEF platform 硬编码前缀 ↑ 跟 oc_* 不撞 team_id workspace 标识 ↑ 反查 token 用 channel_id 具体频道 / DM / 群 ↑ 决定 session 边界

三种场景的命名对照

场景chatIdsenderId
频道里 @ bot slack:T914QLDUL:C03ABCDEF slack:U07XYZ
DM 给 bot slack:T914QLDUL:dm:U07XYZ slack:U07XYZ
多人 DM (mpim) slack:T914QLDUL:G04MPIM slack:U07XYZ

session 隔离粒度

  • v1 默认:按 chatId 隔离,按 sender 拆。一个频道 = 一条 Claude 会话,跟飞书目前一样。
  • 群聊里按发言人拆:metabot 已有 makeTaskKey(chatId, senderId) 机制,加一行配置即可启用。

06 落地 checklist 待开工

v1 只追求 Slack DM/@ → metabot → 文本回话 打通;流式卡片、图片、Block Kit 都 v2。

事项状态
gateway POST /api/internal/send-text(Bearer 鉴权 + 查表 + chat.postMessage) 待做
gateway app_mention + message.im handler 改成转发 POST /api/talk 到 metabot 待做
gateway 消息去重(Slack retry 标头 X-Slack-Retry-Num 要忽略) 待做
metabot 新建 src/slack/slack-sender-adapter.ts 实现 IMessageSender 待做
metabot index.tsstartSlackBot(),注册 platform: 'slack' 的 bot 待做
metabot 共享 secret 配置 + URL 配置(gateway 地址 / 鉴权 token) 待做
端到端测:miniCTO workspace DM bot → 看到 metabot 起会话 → bot 回话 待做