Files

1 line
55 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "11chens/openclaw-mini", "files": {"docs/CHANNELS_GATEWAY_CASE_STUDY.md": {"dde381976337a3afd102b34b44895a2acc83044a": {"t": 1785747449.8976326, "n": 21477, "h": "056d3067fac5c110", "c": "# Channels 和 Gateway 串联案例\n\n这份文档只讲两个目录:\n\n```text\nsrc/channels\nsrc/gateway\n```\n\n目标是用一个案例把这些文件串起来,而不是逐个孤立解释。\n\n案例:\n\n```text\n飞书群里有人发:\n/ask 查北京天气,顺便发到飞书通知群\n```\n\n这句话会触发三条相关链路:\n\n```text\n主链路 A:飞书消息进入 openclaw-mini\n Feishu callback -> ChannelGateway -> AgentRunGateway\n\n主链路 B:agent 生成 final answer 后,自动回复原飞书消息\n ChannelGateway -> FeishuReplyDeliveryAdapter -> FeishuAppClient\n\n旁支 C:如果模型选择调用 feishu.send_text 工具,则走飞书 webhook outbound\n FeishuWebhookChannel\n```\n\n还有一个本地调试入口:\n\n```text\n旁支 D:CLI 也能制造 ChannelInboundMessage,复用同一个 ChannelGateway\n```\n\n## 1. 目录全景\n\n当前 `src/channels`:\n\n```text\nsrc/channels/base.ts\nsrc/channels/cli.ts\nsrc/channels/delivery.ts\nsrc/channels/feishu.ts\nsrc/channels/feishuAppClient.ts\nsrc/channels/feishuHttpServer.ts\nsrc/channels/feishuInbound.ts\nsrc/channels/feishuReplyDelivery.ts\n```\n\n当前 `src/gateway`:\n\n```text\nsrc/gateway/types.ts\nsrc/gateway/channelEventDeduper.ts\nsrc/gateway/sessionResolver.ts\nsrc/gateway/channelGateway.ts\nsrc/gateway/agentRunGateway.ts\n```\n\n不要把所有文件强行画进一张图。这里拆成四张图:\n\n```text\n图 1:主流程顺序\n图 2:channels 模块分层\n图 3:gateway 内部顺序\n图 4:两个出站分支\n```\n\n### 1.1 图 1:主流程顺序\n\n这张图只回答一个问题:\n\n```text\n飞书消息进来以后,按什么顺序经过 channels 和 gateway?\n```\n\n```mermaid\nflowchart LR\n A[\"1 飞书服务器<br/>POST /feishu/events\"] --> B[\"2 channels/feishuHttpServer.ts<br/>接 HTTP callback\"]\n B --> C[\"3 channels/feishuInbound.ts<br/>解析 event_type 和文本\"]\n C --> D[\"4 gateway/types.ts<br/>ChannelInboundMessage\"]\n D --> E[\"5 gateway/channelGateway.ts<br/>channel 总入口\"]\n E --> F[\"6 gateway/agentRunGateway.ts<br/>创建 run 并进入 agent\"]\n F --> G[\"7 runtime<br/>模型和工具循环\"]\n G --> H[\"8 gateway/channelGateway.ts<br/>拿到 finalMessage 后回复\"]\n H --> I[\"9 channels/feishuReplyDelivery.ts<br/>选择飞书 reply adapter\"]\n I --> J[\"10 channels/feishuAppClient.ts<br/>调用飞书 Auth + Reply API\"]\n```\n\n读法:\n\n```text\n左边是飞书推送事件进来。\n中间是 gateway 把 channel 消息变成 agent run。\n右边是 agent 结束后,channel delivery 把 final answer 发回飞书。\n```\n\n### 1.2 图 2:channels 模块分层\n\n这张图只回答:\n\n```text\nsrc/channels 目录里这些文件分成哪几类?\n```\n\n```mermaid\nflowchart TD\n Channels[\"src/channels\"] --> Inbound[\"入站适配<br/>外部消息 -> ChannelInboundMessage\"]\n Channels --> Reply[\"普通问答回复<br/>finalMessage -> 原 channel\"]\n Channels --> Webhook[\"主动发送工具<br/>feishu.send_text -> webhook\"]\n Channels --> Cli[\"本地 CLI 调试入口\"]\n Channels --> Shared[\"共享类型\"]\n\n Inbound --> Http[\"feishuHttpServer.ts<br/>接 /feishu/events\"]\n Inbound --> Parser[\"feishuInbound.ts<br/>解析飞书 event\"]\n\n Reply --> Delivery[\"delivery.ts<br/>ChannelDeliveryAdapter\"]\n Reply --> FeishuReply[\"feishuReplyDelivery.ts<br/>ReplyTarget -> FeishuAppClient\"]\n Reply --> AppClient[\"feishuAppClient.ts<br/>Auth API + Reply API\"]\n\n Webhook --> FeishuWebhook[\"feishu.ts<br/>FEISHU_WEBHOOK_URL\"]\n\n Cli --> CliFile[\"cli.ts<br/>createCliMessage\"]\n\n Shared --> Base[\"base.ts<br/>DeliveryResult / ChannelAdapter\"]\n```\n\n重点:\n\n```text\nfeishuHttpServer.ts + feishuInbound.ts\n 负责飞书消息进来。\n\nfeishuReplyDelivery.ts + feishuAppClient.ts\n 负责普通问答回复回飞书。\n\nfeishu.ts\n 负责 feishu.send_text 工具走 webhook 主动发消息。\n\ncli.ts\n 负责本地调试入口。\n```\n\n### 1.3 图 3:gateway 内部顺序\n\n这张图只回答:\n\n```text\nChannelGateway 收到 ChannelInboundMessage 后,gateway 内部怎么走?\n```\n\n```mermaid\nflowchart LR\n A[\"ChannelInboundMessage\"] --> B[\"channelGateway.ts<br/>handleInbound\"]\n B --> C[\"channelEventDeduper.ts<br/>检查 dedupeKey\"]\n C --> D[\"sessionResolver.ts<br/>生成 sessionId\"]\n D --> E[\"types.ts<br/>AgentRunRequest\"]\n E --> F[\"agentRunGateway.ts<br/>handleRunRequest\"]\n F --> G[\"写 trace<br/>channel.inbound.received<br/>session.resolved<br/>run.accepted\"]\n G --> H[\"SessionLaneRunner<br/>同 agent+session 串行\"]\n H --> I[\"RunOrchestrator / AgentRuntime\"]\n```\n\n重点:\n\n```text\nchannelGateway.ts\n 是 channel 层总编排。\n\nagentRunGateway.ts\n 是 agent run 入口。\n\nsessionResolver.ts\n 决定这条消息属于哪个 session。\n\nchannelEventDeduper.ts\n 防止飞书重试导致重复 run。\n```\n\n### 1.4 图 4:两个出站分支\n\n这张图只回答:\n\n```text\nopenclaw-mini 向飞书发东西时,有哪两种完全不同的路径?\n```\n\n```mermaid\nflowchart TD\n Final[\"Agent finalMessage<br/>普通问答回答\"] --> AutoReply[\"channelGateway.ts<br/>replyIfNeeded\"]\n AutoReply --> ReplyAdapter[\"feishuReplyDelivery.ts\"]\n ReplyAdapter --> AppClient[\"feishuAppClient.ts\"]\n AppClient --> Auth[\"飞书 Auth API<br/>/auth/v3/tenant_access_token/internal\"]\n AppClient --> ReplyAPI[\"飞书 Reply API<br/>/im/v1/messages/{message_id}/reply\"]\n\n ToolCall[\"模型调用工具<br/>feishu.send_text\"] --> Tool[\"tools/feishu.ts\"]\n Tool --> WebhookChannel[\"channels/feishu.ts<br/>FeishuWebhookChannel\"]\n WebhookChannel --> WebhookURL[\"FEISHU_WEBHOOK_URL\"]\n```\n\n区别:\n\n```text\n上半部分:\n 普通问答自动回复原消息。\n 不需要 FEISHU_WEBHOOK_URL。\n 需要 FEISHU_APP_ID / FEISHU_APP_SECRET。\n\n下半部分:\n 模型主动调用 feishu.send_text 工具。\n 需要 FEISHU_WEBHOOK_URL。\n 不走 reply API。\n```\n\n## 2. 先分清两个方向\n\n### 2.1 入站方向:飞书推送事件给你\n\n```text\n飞书服务器\n -> POST 你的 /feishu/events\n -> openclaw-mini\n```\n\n这条链路使用:\n\n```text\nchannels/feishuHttpServer.ts\nchannels/feishuInbound.ts\ngateway/types.ts\ngateway/channelGateway.ts\ngateway/channelEventDeduper.ts\ngateway/sessionResolver.ts\ngateway/agentRunGateway.ts\n```\n\n### 2.2 出站方向:你调用飞书 API 回复\n\n```text\nopenclaw-mini\n -> POST 飞书 /auth/v3/tenant_access_token/internal\n -> POST 飞书 /im/v1/messages/{message_id}/reply\n```\n\n这条链路使用:\n\n```text\nchannels/delivery.ts\nchannels/feishuReplyDelivery.ts\nchannels/feishuAppClient.ts\nchannels/base.ts\ngateway/channelGateway.ts\n```\n\n### 2.3 另一个出站方向:webhook 工具\n\n```text\nopenclaw-mini\n -> POST FEISHU_WEBHOOK_URL\n```\n\n这条链路使用:\n\n```text\nchannels/base.ts\nchannels/feishu.ts\ntools/feishu.ts\ntools/feishuOutboundProvider.ts\n```\n\n它不是普通双工问答回复,而是 `feishu.send_text` 工具。\n\n## 3. 案例原始输入\n\n飞书群里用户发:\n\n```text\n/ask 查北京天气,顺便发到飞书通知群\n```\n\n飞书会把它包装成事件,大致像这样 POST 到你的 Request URL:\n\n```json\n{\n \"schema\": \"2.0\",\n \"header\": {\n \"event_id\": \"evt_1\",\n \"event_type\": \"im.message.receive_v1\",\n \"token\": \"your-verification-token\",\n \"create_time\": \"2026-06-10T00:00:00.000Z\"\n },\n \"event\": {\n \"sender\": {\n \"sender_id\": {\n \"open_id\": \"ou_user\"\n },\n \"sender_type\": \"user\"\n },\n \"message\": {\n \"message_id\": \"om_message\",\n \"chat_id\": \"oc_chat\",\n \"chat_type\": \"group\",\n \"message_type\": \"text\",\n \"content\": \"{\\\"text\\\":\\\"/ask 查北京天气,顺便发到飞书通知群\\\"}\"\n }\n }\n}\n```\n\n注意:\n\n```text\nevent_type = im.message.receive_v1\n```\n\n它只是事件类型,不是 URL。\n\n## 4. 第一步:channels/feishuHttpServer.ts 接住 HTTP\n\n文件:\n\n```text\nsrc/channels/feishuHttpServer.ts\n```\n\n它的角色:\n\n```text\n把 openclaw-mini 变成一个能接飞书 HTTP callback 的 server。\n```\n\n启动命令:\n\n```powershell\nnpm run dev -- serve-feishu --port 8787 --model-provider deepseek --api-config-dir D:\\Vibethon\\openclaw-eval-private-api-configs\n```\n\n默认监听:\n\n```text\nPOST /feishu/events\n```\n\n核心函数:\n\n```ts\nexport async function handleFeishuRequest(\n config: FeishuHttpServerConfig,\n request: IncomingMessage,\n response: ServerResponse\n): Promise<void>\n```\n\n它做的事情:\n\n```text\n1. 检查 method 是不是 POST。\n2. 检查 path 是不是 /feishu/events。\n3. 读取 request body。\n4. JSON.parse。\n5. 调 parseFeishuCallback()。\n6. 如果是 challenge,直接返回 challenge。\n7. 如果是 dropped/unsupported,返回 200 但不跑 agent。\n8. 如果是 message,调用 ChannelGateway.handleInbound()。\n```\n\n关键代码:\n\n```ts\nconst parsed = parseFeishuCallback(payload, config.inbound);\n```\n\n然后:\n\n```ts\nconst result = await config.gateway.handleInbound({\n agentId: config.agentId,\n message: parsed.message,\n options: config.options,\n receivedAt: new Date().toISOString()\n});\n```\n\n到这里,HTTP server 的工作就结束了。\n\n它不懂 agent,不懂 session,不懂模型。\n\n## 5. 第二步:channels/feishuInbound.ts 把飞书事件转成标准消息\n\n文件:\n\n```text\nsrc/channels/feishuInbound.ts\n```\n\n它的角色:\n\n```text\n理解飞书事件 JSON,把它转换成项目内部统一格式 ChannelInboundMessage。\n```\n\n入口函数:\n\n```ts\nexport function parseFeishuCallback(\n payload: JsonValue,\n config: FeishuInboundConfig\n): FeishuInboundParseResult\n```\n\n返回值有三种:\n\n```ts\ntype FeishuInboundParseResult =\n | { type: \"challenge\"; challenge: string }\n | { type: \"message\"; message: ChannelInboundMessage }\n | { type: \"dropped\" | \"unsupported\"; reason: string };\n```\n\n### 5.1 校验 verification token\n\n```ts\nconst token = callbackToken(body);\nif (config.verificationToken && token !== config.verificationToken) {\n throw new Error(\"Invalid Feishu verification token.\");\n}\n```\n\n意思是:\n\n```text\n飞书事件 payload 里的 token 必须和本地 FEISHU_VERIFICATION_TOKEN 一致。\n```\n\n### 5.2 只接收 im.message.receive_v1\n\n```ts\nconst eventType = stringField(header, \"event_type\") ?? stringField(body, \"event_type\");\nif (eventType !== \"im.message.receive_v1\") {\n return {\n type: \"unsupported\",\n reason: `Unsupported Feishu event type: ${eventType ?? \"unknown\"}`\n };\n}\n```\n\n意思是:\n\n```text\n当前 V6 只处理“机器人收到消息”事件。\n```\n\n### 5.3 群聊过滤\n\n群聊默认只处理:\n\n```text\n@机器人 ...\n/ask ...\n```\n\n所以案例里的:\n\n```text\n/ask 查北京天气,顺便发到飞书通知群\n```\n\n会被接受,并去掉 `/ask`。\n\n最终文本变成:\n\n```text\n查北京天气,顺便发到飞书通知群\n```\n\n### 5.4 生成 ChannelInboundMessage\n\n解析结果大致是:\n\n```ts\n{\n id: \"evt_1\",\n channel: \"feishu\",\n senderId: \"ou_user\",\n text: \"查北京天气,顺便发到飞书通知群\",\n timestamp: \"2026-06-10T00:00:00.000Z\",\n replyTarget: {\n channel: \"feishu\",\n kind: \"feishu-message\",\n chatId: \"oc_chat\",\n messageId: \"om_message\"\n },\n metadata: {\n dedupeKey: \"evt_1\",\n eventId: \"evt_1\",\n messageId: \"om_message\",\n chatId: \"oc_chat\",\n chatType: \"group\"\n }\n}\n```\n\n最重要的是两个字段:\n\n```text\ntext:\n 交给 agent 的用户文本。\n\nreplyTarget:\n agent 回答后,ChannelGateway 用它回复原飞书消息。\n```\n\n## 6. 第三步:gateway/types.ts 定义统一语言\n\n文件:\n\n```text\nsrc/gateway/types.ts\n```\n\n这个文件不是业务逻辑,而是所有 channel/gateway 之间的契约。\n\n### 6.1 ReplyTarget\n\n```ts\nexport type ReplyTarget =\n | {\n channel: \"cli\";\n kind: \"none\";\n sessionId: string;\n }\n | {\n channel: \"feishu\";\n kind: \"feishu-message\";\n chatId: string;\n messageId: string;\n rootMessageId?: string;\n };\n```\n\n含义:\n\n```text\n如果 agent 最后有 final answer,应该回复到哪里?\n```\n\n对于飞书,就是:\n\n```text\n回复 messageId 对应的原消息。\n```\n\n### 6.2 ChannelInboundMessage\n\n```ts\nexport type ChannelInboundMessage = {\n id: string;\n channel: string;\n senderId: string;\n text: string;\n timestamp: string;\n sessionId?: string;\n replyTarget?: ReplyTarget;\n metadata?: JsonObject;\n};\n```\n\n这是所有外部入口统一后的消息格式。\n\n飞书、CLI、未来 Slack/Discord,都应该先变成它。\n\n### 6.3 AgentRunRequest\n\n```ts\nexport type AgentRunRequest = {\n requestId: string;\n agentId: string;\n message: AgentRunInputMessage;\n options: RunRequestOptions;\n receivedAt: string;\n channelTrace?: {\n inboundMessageId: string;\n senderId: string;\n replyTarget?: ReplyTarget;\n };\n};\n```\n\n这是“已经解析好 session,准备交给 agent run”的请求。\n\n所以:\n\n```text\nChannelInboundMessage\n 是 channel 层消息。\n\nAgentRunRequest\n 是 agent run 层请求。\n```\n\n## 7. 第四步:gateway/channelGateway.ts 是 channel 总入口\n\n文件:\n\n```text\nsrc/gateway/channelGateway.ts\n```\n\n它的角色:\n\n```text\n接收统一的 ChannelInboundMessage,完成 channel 层编排。\n```\n\n入口:\n\n```ts\nasync handleInbound(input: ChannelGatewayInput): Promise<ChannelGatewayResponse>\n```\n\n它做五件事:\n\n```text\n1. dedupe\n2. drop\n3. session resolve\n4. 调 AgentRunGateway\n5. 如果有 replyTarget,则自动回复 final answer\n```\n\n### 7.1 去重\n\n```ts\nconst dedupeKey = dedupeKeyFor(input);\nif (this.config.deduper.has(dedupeKey)) {\n return {\n status: \"deduped\",\n reason: `Duplicate channel message: ${dedupeKey}`\n };\n}\n```\n\n案例里:\n\n```text\ndedupeKey = feishu:evt_1\n```\n\n如果飞书重试推送同一个事件,就不会重复跑 agent。\n\n### 7.2 session resolve\n\n```ts\nconst resolved = this.config.sessionResolver.resolve(input);\n```\n\n它把没有 sessionId 的飞书消息变成有 sessionId 的 agent input。\n\n### 7.3 进入 AgentRunGateway\n\n```ts\nconst response = await this.config.runGateway.handleRunRequest({\n requestId: createId(\"req\"),\n agentId: resolved.agentId,\n message: resolved.message,\n options: resolved.options,\n receivedAt: resolved.receivedAt,\n channelTrace: {\n inboundMessageId: input.message.id,\n senderId: input.message.senderId,\n replyTarget: resolved.replyTarget\n }\n});\n```\n\n到这里,channel 层把消息交给 agent run。\n\n### 7.4 final answer 自动回复\n\nagent 完成后:\n\n```ts\nconst delivery = await this.replyIfNeeded(response, resolved.replyTarget, input.options.delivery);\n```\n\n如果 `replyTarget.channel === \"feishu\"`,它会找到 `FeishuReplyDeliveryAdapter`。\n\n## 8. 第五步:gateway/channelEventDeduper.ts 去重\n\n文件:\n\n```text\nsrc/gateway/channelEventDeduper.ts\n```\n\n内容很小:\n\n```ts\nexport interface ChannelEventDeduper {\n has(key: string): boolean;\n mark(key: string): void;\n}\n\nexport class MemoryChannelEventDeduper implements ChannelEventDeduper {\n private readonly keys = new Set<string>();\n}\n```\n\n作用:\n\n```text\n防止同一个 channel event 重复触发 agent run。\n```\n\n当前是内存版,所以服务重启后去重记录会丢。\n\n## 9. 第六步:gateway/sessionResolver.ts 生成 sessionId\n\n文件:\n\n```text\nsrc/gateway/sessionResolver.ts\n```\n\n飞书消息本身没有 openclaw-mini 的 sessionId,所以要推导。\n\n规则:\n\n```ts\nif (message.channel === \"feishu\") {\n const chatId = stringMetadata(message, \"chatId\");\n const rootMessageId = stringMetadata(message, \"rootMessageId\");\n if (rootMessageId) {\n return `feishu:chat:${chatId}:thread:${rootMessageId}`;\n }\n return `feishu:chat:${chatId}`;\n}\n```\n\n案例里没有 thread,则:\n\n```text\nsessionId = feishu:chat:oc_chat\n```\n\n意义:\n\n```text\n同一个飞书 chat 进入同一个 session history。\n```\n\n## 10. 第七步:gateway/agentRunGateway.ts 创建一次 run\n\n文件:\n\n```text\nsrc/gateway/agentRunGateway.ts\n```\n\n它的角色:\n\n```text\n把已经解析好的 AgentRunRequest 变成一次真正的 agent run。\n```\n\n入口:\n\n```ts\nasync handleRunRequest(request: AgentRunRequest): Promise<AgentResponse>\n```\n\n它做:\n\n```text\n1. registry.resolve(agentId)\n2. 创建 RunExecutionContext\n3. 创建 run artifact 目录\n4. 写 channel.inbound.received trace\n5. 写 session.resolved trace\n6. 写 run.accepted trace\n7. 用 SessionLaneRunner 保证同 agent+session 串行\n8. 调 RunOrchestrator.run()\n```\n\n关键代码:\n\n```ts\nconst result = await this.config.lanes.run(\n laneKey(executionContext.agentId, executionContext.sessionId),\n async () => (\n await this.config.orchestrator.run({\n message: request.message,\n executionContext,\n artifactStore,\n agent: profile\n })\n )\n);\n```\n\n案例里 lane key 是:\n\n```text\ndefault:feishu:chat:oc_chat\n```\n\n同一个飞书群连续两条消息会串行执行。\n\n## 11. 第八步:agent runtime 返回 final answer\n\n这一步不在 channels/gateway 目录里,但为了串起来需要知道它返回什么。\n\n`RunOrchestrator` 和 `AgentRuntime` 最终返回:\n\n```ts\nRunResult {\n finalMessage: \"北京今天晴,26°C,微风。\",\n success: true,\n ...\n}\n```\n\n这个 `finalMessage` 回到:\n\n```text\nChannelGateway.replyIfNeeded()\n```\n\n## 12. 第九步:channels/delivery.ts 定义回复 adapter 接口\n\n文件:\n\n```text\nsrc/channels/delivery.ts\n```\n\n它定义:\n\n```ts\nexport interface ChannelDeliveryAdapter {\n readonly channel: string;\n reply(target: ReplyTarget, text: string, options: DeliveryOptions): Promise<DeliveryResult>;\n}\n```\n\n含义:\n\n```text\n不同 channel 应该如何回复 final answer?\n```\n\n当前有两个实现方向:\n\n```text\nCLI:\n CliDeliveryAdapter\n\nFeishu:\n FeishuReplyDeliveryAdapter\n```\n\n`CliDeliveryAdapter` 是 dry-run 风格的本地实现,不会真的发网络请求。\n\n## 13. 第十步:channels/feishuReplyDelivery.ts 把 ReplyTarget 交给飞书客户端\n\n文件:\n\n```text\nsrc/channels/feishuReplyDelivery.ts\n```\n\n它的角色:\n\n```text\n把 ChannelGateway 的“我要回复飞书消息”请求,转成 FeishuAppClient.replyText()。\n```\n\n核心代码:\n\n```ts\nasync reply(target: ReplyTarget, text: string, options: DeliveryOptions): Promise<DeliveryResult> {\n if (target.channel !== \"feishu\" || target.kind !== \"feishu-message\") {\n throw new Error(\"FeishuReplyDeliveryAdapter requires a Feishu message reply target.\");\n }\n return await this.client.replyText(target.messageId, text, options.dryRun);\n}\n```\n\n它不管 app_id/app_secret,也不直接拼 URL。\n\n它只负责:\n\n```text\n确认 target 是飞书消息\n拿 messageId\n调用 FeishuAppClient\n```\n\n## 14. 第十一步:channels/feishuAppClient.ts 调飞书 Open API\n\n文件:\n\n```text\nsrc/channels/feishuAppClient.ts\n```\n\n它是真正调用飞书 Open API 的地方。\n\n### 14.1 dry-run\n\n如果是 dry-run:\n\n```ts\nif (dryRun) {\n return {\n ok: true,\n status: 0,\n responseBody: \"dry-run\",\n dryRun: true,\n payload\n };\n}\n```\n\n不会真的请求飞书。\n\n### 14.2 获取 tenant_access_token\n\n```ts\nconst response = await this.fetchFn(`${this.baseUrl}/auth/v3/tenant_access_token/internal`, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\"\n },\n body: JSON.stringify({\n app_id: this.config.appId,\n app_secret: this.config.appSecret\n })\n});\n```\n\n方向:\n\n```text\nopenclaw-mini -> 飞书 Auth API\n```\n\n### 14.3 调 reply API\n\n```ts\nconst response = await this.fetchFn(`${this.baseUrl}/im/v1/messages/${encodeURIComponent(messageId)}/reply`, {\n method: \"POST\",\n headers: {\n authorization: `Bearer ${token}`,\n \"content-type\": \"application/json\"\n },\n body: JSON.stringify(payload)\n});\n```\n\n方向:\n\n```text\nopenclaw-mini -> 飞书 IM Reply API\n```\n\n这一步完成后,飞书会把回答展示到原消息线程。\n\n## 15. channels/base.ts:两种 outbound 的共同底层类型\n\n文件:\n\n```text\nsrc/channels/base.ts\n```\n\n它定义:\n\n```ts\nexport type DeliveryResult = {\n ok: boolean;\n status: number;\n responseBody: string;\n dryRun: boolean;\n payload: JsonObject;\n};\n```\n\n这个类型同时被两类出站使用:\n\n```text\n普通 final reply:\n FeishuAppClient.replyText()\n\nwebhook tool send:\n FeishuWebhookChannel.sendText()\n```\n\n它还定义了旧式 channel action 接口:\n\n```ts\nexport interface ChannelAdapter {\n readonly name: string;\n sendText(input: SendTextInput): Promise<DeliveryResult>;\n}\n```\n\n这个接口主要给 `feishu.send_text` tool 用。\n\n## 16. 旁支 C:channels/feishu.ts 是 webhook outbound,不是普通 reply\n\n文件:\n\n```text\nsrc/channels/feishu.ts\n```\n\n它实现:\n\n```ts\nexport class FeishuWebhookChannel implements ChannelAdapter\n```\n\n作用:\n\n```text\n向 FEISHU_WEBHOOK_URL 发一条文本消息。\n```\n\n方向:\n\n```text\nopenclaw-mini -> 飞书 webhook URL\n```\n\n不是:\n\n```text\n飞书 -> openclaw-mini\n```\n\n也不是:\n\n```text\nopenclaw-mini -> 飞书 reply API\n```\n\n### 16.1 它什么时候用\n\n当模型调用 `feishu.send_text` 工具时用。\n\n比如用户说:\n\n```text\n查北京天气,顺便发到飞书通知群\n```\n\n模型可能先查天气,然后调用:\n\n```text\nfeishu.send_text\n```\n\n这个工具最终会通过 `FeishuWebhookChannel.sendText()` 发到 `FEISHU_WEBHOOK_URL`。\n\n### 16.2 签名\n\n如果配置了:\n\n```dotenv\nFEISHU_WEBHOOK_SECRET=\n```\n\n它会构造:\n\n```ts\npayload.timestamp = timestamp;\npayload.sign = createFeishuSignature(timestamp, params.secret);\n```\n\n这个签名只属于 webhook outbound。\n\n普通 reply API 不用这个 secret。\n\n## 17. 旁支 D:channels/cli.ts 是本地调试 channel\n\n文件:\n\n```text\nsrc/channels/cli.ts\n```\n\n它做:\n\n```ts\nexport function createCliMessage(params): ChannelInboundMessage\n```\n\nCLI 命令:\n\n```powershell\nnpm run dev -- run --message \"查北京天气\" --session demo --dry-run\n```\n\n会创建:\n\n```ts\n{\n channel: \"cli\",\n sessionId: \"demo\",\n text: \"查北京天气\",\n replyTarget: {\n channel: \"cli\",\n kind: \"none\",\n sessionId: \"demo\"\n }\n}\n```\n\n然后也进入:\n\n```text\nChannelGateway.handleInbound()\n```\n\n这说明 CLI 和飞书不是两套 runtime。\n\n它们只是两个不同 channel adapter,进入同一个 gateway。\n\n## 18. 同一个案例的完整顺序\n\n用户在飞书群发:\n\n```text\n/ask 查北京天气,顺便发到飞书通知群\n```\n\n完整顺序:\n\n```text\n1. 飞书服务器 POST /feishu/events\n -> channels/feishuHttpServer.ts\n\n2. parseFeishuCallback()\n -> channels/feishuInbound.ts\n\n3. 得到 ChannelInboundMessage\n -> gateway/types.ts\n\n4. ChannelGateway.handleInbound()\n -> gateway/channelGateway.ts\n\n5. MemoryChannelEventDeduper 检查 evt_1 是否重复\n -> gateway/channelEventDeduper.ts\n\n6. DefaultSessionResolver 生成 feishu:chat:oc_chat\n -> gateway/sessionResolver.ts\n\n7. AgentRunGateway 创建 run,写 trace,进入 session lane\n -> gateway/agentRunGateway.ts\n\n8. RunOrchestrator / AgentRuntime 调模型和工具\n -> runtime/*\n\n9. 如果模型调用 feishu.send_text\n -> tools/feishu.ts\n -> channels/feishu.ts\n -> FEISHU_WEBHOOK_URL\n\n10. AgentRuntime 返回 finalMessage\n -> ChannelGateway.replyIfNeeded()\n\n11. ChannelGateway 找 channel=feishu 的 delivery adapter\n -> channels/delivery.ts\n -> channels/feishuReplyDelivery.ts\n\n12. FeishuAppClient 获取 token 并调用 reply API\n -> channels/feishuAppClient.ts\n\n13. 飞书把回复展示到原消息\n```\n\n## 19. Trace 里怎么看 channels/gateway\n\n一次正常双工 run 的 trace 里,和 channels/gateway 相关的事件有:\n\n```text\nchannel.inbound.received\nsession.resolved\nrun.accepted\ndelivery.reply.started\ndelivery.reply.finished\n```\n\n对应关系:\n\n| Trace event | 哪个文件写入 | 含义 |\n|---|---|---|\n| `channel.inbound.received` | `gateway/agentRunGateway.ts` | channel 消息进入 run artifact |\n| `session.resolved` | `gateway/agentRunGateway.ts` | sessionId 和 replyTarget 已确定 |\n| `run.accepted` | `gateway/agentRunGateway.ts` | Gateway 接受本次 run |\n| `delivery.reply.started` | `gateway/channelGateway.ts` | 开始把 final answer 回复回 channel |\n| `delivery.reply.finished` | `gateway/channelGateway.ts` | 回复完成或失败 |\n\n如果走了 `feishu.send_text` webhook 工具,还会看到:\n\n```text\ndelivery.sent\n```\n\n这个事件来自 runtime 对 channel action tool 的记录。\n\n## 20. 每个文件一句话总结\n\n### channels\n\n| 文件 | 一句话 |\n|---|---|\n| `channels/base.ts` | 定义通用 delivery result 和旧式 sendText channel adapter |\n| `channels/cli.ts` | 把 CLI 输入转成 `ChannelInboundMessage` |\n| `channels/delivery.ts` | 定义 final answer 自动回复用的 `ChannelDeliveryAdapter` |\n| `channels/feishuHttpServer.ts` | 启动 `/feishu/events`,接收飞书 callback |\n| `channels/feishuInbound.ts` | 解析飞书事件,生成 `ChannelInboundMessage` |\n| `channels/feishuReplyDelivery.ts` | 把 `ReplyTarget` 转给 FeishuAppClient |\n| `channels/feishuAppClient.ts` | 调飞书 Auth API 和 Reply API |\n| `channels/feishu.ts` | 调飞书 webhook URL,服务 `feishu.send_text` 工具 |\n\n### gateway\n\n| 文件 | 一句话 |\n|---|---|\n| `gateway/types.ts` | 定义 channel 和 run 之间的核心类型契约 |\n| `gateway/channelGateway.ts` | channel 层总编排:去重、session、run、reply |\n| `gateway/channelEventDeduper.ts` | 防止同一个 channel event 重复处理 |\n| `gateway/sessionResolver.ts` | 从 channel message 推导 agent sessionId |\n| `gateway/agentRunGateway.ts` | 创建 run、写 run trace、进入 session lane、调用 runtime 编排 |\n\n## 21. 最短阅读路径\n\n如果你只想系统读懂 channels/gateway,按这个顺序:\n\n```text\n1. gateway/types.ts\n2. channels/feishuInbound.ts\n3. channels/feishuHttpServer.ts\n4. gateway/channelGateway.ts\n5. gateway/sessionResolver.ts\n6. gateway/channelEventDeduper.ts\n7. gateway/agentRunGateway.ts\n8. channels/delivery.ts\n9. channels/feishuReplyDelivery.ts\n10. channels/feishuAppClient.ts\n11. channels/feishu.ts\n12. channels/cli.ts\n13. channels/base.ts\n```\n\n这样读的原因:\n\n```text\n先理解统一类型\n再理解飞书如何变成统一类型\n再理解 gateway 如何处理统一类型\n最后看不同出站方式怎么发回去\n```\n\n## 22. 记忆模型\n\n可以把这两个目录记成:\n\n```text\nchannels:\n 负责和外部世界说话。\n 飞书 HTTP、飞书事件、飞书 reply API、飞书 webhook、CLI 都在这里。\n\ngateway:\n 负责把外部消息变成一次 agent run。\n 它处理去重、session、run 创建、run 接受、自动回复。\n```\n\n更短:\n\n```text\nchannels 管“外面怎么进来、怎么出去”。\ngateway 管“进来以后怎么进入 agent,以及 agent 结束后怎么回 channel”。\n```\n"}, "a40f447ab080cc755fc7867325aa3f00bc98ba78": {"t": 1785773373.3431745, "n": 21477, "h": "056d3067fac5c110", "c": "# Channels 和 Gateway 串联案例\n\n这份文档只讲两个目录:\n\n```text\nsrc/channels\nsrc/gateway\n```\n\n目标是用一个案例把这些文件串起来,而不是逐个孤立解释。\n\n案例:\n\n```text\n飞书群里有人发:\n/ask 查北京天气,顺便发到飞书通知群\n```\n\n这句话会触发三条相关链路:\n\n```text\n主链路 A:飞书消息进入 openclaw-mini\n Feishu callback -> ChannelGateway -> AgentRunGateway\n\n主链路 B:agent 生成 final answer 后,自动回复原飞书消息\n ChannelGateway -> FeishuReplyDeliveryAdapter -> FeishuAppClient\n\n旁支 C:如果模型选择调用 feishu.send_text 工具,则走飞书 webhook outbound\n FeishuWebhookChannel\n```\n\n还有一个本地调试入口:\n\n```text\n旁支 D:CLI 也能制造 ChannelInboundMessage,复用同一个 ChannelGateway\n```\n\n## 1. 目录全景\n\n当前 `src/channels`:\n\n```text\nsrc/channels/base.ts\nsrc/channels/cli.ts\nsrc/channels/delivery.ts\nsrc/channels/feishu.ts\nsrc/channels/feishuAppClient.ts\nsrc/channels/feishuHttpServer.ts\nsrc/channels/feishuInbound.ts\nsrc/channels/feishuReplyDelivery.ts\n```\n\n当前 `src/gateway`:\n\n```text\nsrc/gateway/types.ts\nsrc/gateway/channelEventDeduper.ts\nsrc/gateway/sessionResolver.ts\nsrc/gateway/channelGateway.ts\nsrc/gateway/agentRunGateway.ts\n```\n\n不要把所有文件强行画进一张图。这里拆成四张图:\n\n```text\n图 1:主流程顺序\n图 2:channels 模块分层\n图 3:gateway 内部顺序\n图 4:两个出站分支\n```\n\n### 1.1 图 1:主流程顺序\n\n这张图只回答一个问题:\n\n```text\n飞书消息进来以后,按什么顺序经过 channels 和 gateway?\n```\n\n```mermaid\nflowchart LR\n A[\"1 飞书服务器<br/>POST /feishu/events\"] --> B[\"2 channels/feishuHttpServer.ts<br/>接 HTTP callback\"]\n B --> C[\"3 channels/feishuInbound.ts<br/>解析 event_type 和文本\"]\n C --> D[\"4 gateway/types.ts<br/>ChannelInboundMessage\"]\n D --> E[\"5 gateway/channelGateway.ts<br/>channel 总入口\"]\n E --> F[\"6 gateway/agentRunGateway.ts<br/>创建 run 并进入 agent\"]\n F --> G[\"7 runtime<br/>模型和工具循环\"]\n G --> H[\"8 gateway/channelGateway.ts<br/>拿到 finalMessage 后回复\"]\n H --> I[\"9 channels/feishuReplyDelivery.ts<br/>选择飞书 reply adapter\"]\n I --> J[\"10 channels/feishuAppClient.ts<br/>调用飞书 Auth + Reply API\"]\n```\n\n读法:\n\n```text\n左边是飞书推送事件进来。\n中间是 gateway 把 channel 消息变成 agent run。\n右边是 agent 结束后,channel delivery 把 final answer 发回飞书。\n```\n\n### 1.2 图 2:channels 模块分层\n\n这张图只回答:\n\n```text\nsrc/channels 目录里这些文件分成哪几类?\n```\n\n```mermaid\nflowchart TD\n Channels[\"src/channels\"] --> Inbound[\"入站适配<br/>外部消息 -> ChannelInboundMessage\"]\n Channels --> Reply[\"普通问答回复<br/>finalMessage -> 原 channel\"]\n Channels --> Webhook[\"主动发送工具<br/>feishu.send_text -> webhook\"]\n Channels --> Cli[\"本地 CLI 调试入口\"]\n Channels --> Shared[\"共享类型\"]\n\n Inbound --> Http[\"feishuHttpServer.ts<br/>接 /feishu/events\"]\n Inbound --> Parser[\"feishuInbound.ts<br/>解析飞书 event\"]\n\n Reply --> Delivery[\"delivery.ts<br/>ChannelDeliveryAdapter\"]\n Reply --> FeishuReply[\"feishuReplyDelivery.ts<br/>ReplyTarget -> FeishuAppClient\"]\n Reply --> AppClient[\"feishuAppClient.ts<br/>Auth API + Reply API\"]\n\n Webhook --> FeishuWebhook[\"feishu.ts<br/>FEISHU_WEBHOOK_URL\"]\n\n Cli --> CliFile[\"cli.ts<br/>createCliMessage\"]\n\n Shared --> Base[\"base.ts<br/>DeliveryResult / ChannelAdapter\"]\n```\n\n重点:\n\n```text\nfeishuHttpServer.ts + feishuInbound.ts\n 负责飞书消息进来。\n\nfeishuReplyDelivery.ts + feishuAppClient.ts\n 负责普通问答回复回飞书。\n\nfeishu.ts\n 负责 feishu.send_text 工具走 webhook 主动发消息。\n\ncli.ts\n 负责本地调试入口。\n```\n\n### 1.3 图 3:gateway 内部顺序\n\n这张图只回答:\n\n```text\nChannelGateway 收到 ChannelInboundMessage 后,gateway 内部怎么走?\n```\n\n```mermaid\nflowchart LR\n A[\"ChannelInboundMessage\"] --> B[\"channelGateway.ts<br/>handleInbound\"]\n B --> C[\"channelEventDeduper.ts<br/>检查 dedupeKey\"]\n C --> D[\"sessionResolver.ts<br/>生成 sessionId\"]\n D --> E[\"types.ts<br/>AgentRunRequest\"]\n E --> F[\"agentRunGateway.ts<br/>handleRunRequest\"]\n F --> G[\"写 trace<br/>channel.inbound.received<br/>session.resolved<br/>run.accepted\"]\n G --> H[\"SessionLaneRunner<br/>同 agent+session 串行\"]\n H --> I[\"RunOrchestrator / AgentRuntime\"]\n```\n\n重点:\n\n```text\nchannelGateway.ts\n 是 channel 层总编排。\n\nagentRunGateway.ts\n 是 agent run 入口。\n\nsessionResolver.ts\n 决定这条消息属于哪个 session。\n\nchannelEventDeduper.ts\n 防止飞书重试导致重复 run。\n```\n\n### 1.4 图 4:两个出站分支\n\n这张图只回答:\n\n```text\nopenclaw-mini 向飞书发东西时,有哪两种完全不同的路径?\n```\n\n```mermaid\nflowchart TD\n Final[\"Agent finalMessage<br/>普通问答回答\"] --> AutoReply[\"channelGateway.ts<br/>replyIfNeeded\"]\n AutoReply --> ReplyAdapter[\"feishuReplyDelivery.ts\"]\n ReplyAdapter --> AppClient[\"feishuAppClient.ts\"]\n AppClient --> Auth[\"飞书 Auth API<br/>/auth/v3/tenant_access_token/internal\"]\n AppClient --> ReplyAPI[\"飞书 Reply API<br/>/im/v1/messages/{message_id}/reply\"]\n\n ToolCall[\"模型调用工具<br/>feishu.send_text\"] --> Tool[\"tools/feishu.ts\"]\n Tool --> WebhookChannel[\"channels/feishu.ts<br/>FeishuWebhookChannel\"]\n WebhookChannel --> WebhookURL[\"FEISHU_WEBHOOK_URL\"]\n```\n\n区别:\n\n```text\n上半部分:\n 普通问答自动回复原消息。\n 不需要 FEISHU_WEBHOOK_URL。\n 需要 FEISHU_APP_ID / FEISHU_APP_SECRET。\n\n下半部分:\n 模型主动调用 feishu.send_text 工具。\n 需要 FEISHU_WEBHOOK_URL。\n 不走 reply API。\n```\n\n## 2. 先分清两个方向\n\n### 2.1 入站方向:飞书推送事件给你\n\n```text\n飞书服务器\n -> POST 你的 /feishu/events\n -> openclaw-mini\n```\n\n这条链路使用:\n\n```text\nchannels/feishuHttpServer.ts\nchannels/feishuInbound.ts\ngateway/types.ts\ngateway/channelGateway.ts\ngateway/channelEventDeduper.ts\ngateway/sessionResolver.ts\ngateway/agentRunGateway.ts\n```\n\n### 2.2 出站方向:你调用飞书 API 回复\n\n```text\nopenclaw-mini\n -> POST 飞书 /auth/v3/tenant_access_token/internal\n -> POST 飞书 /im/v1/messages/{message_id}/reply\n```\n\n这条链路使用:\n\n```text\nchannels/delivery.ts\nchannels/feishuReplyDelivery.ts\nchannels/feishuAppClient.ts\nchannels/base.ts\ngateway/channelGateway.ts\n```\n\n### 2.3 另一个出站方向:webhook 工具\n\n```text\nopenclaw-mini\n -> POST FEISHU_WEBHOOK_URL\n```\n\n这条链路使用:\n\n```text\nchannels/base.ts\nchannels/feishu.ts\ntools/feishu.ts\ntools/feishuOutboundProvider.ts\n```\n\n它不是普通双工问答回复,而是 `feishu.send_text` 工具。\n\n## 3. 案例原始输入\n\n飞书群里用户发:\n\n```text\n/ask 查北京天气,顺便发到飞书通知群\n```\n\n飞书会把它包装成事件,大致像这样 POST 到你的 Request URL:\n\n```json\n{\n \"schema\": \"2.0\",\n \"header\": {\n \"event_id\": \"evt_1\",\n \"event_type\": \"im.message.receive_v1\",\n \"token\": \"your-verification-token\",\n \"create_time\": \"2026-06-10T00:00:00.000Z\"\n },\n \"event\": {\n \"sender\": {\n \"sender_id\": {\n \"open_id\": \"ou_user\"\n },\n \"sender_type\": \"user\"\n },\n \"message\": {\n \"message_id\": \"om_message\",\n \"chat_id\": \"oc_chat\",\n \"chat_type\": \"group\",\n \"message_type\": \"text\",\n \"content\": \"{\\\"text\\\":\\\"/ask 查北京天气,顺便发到飞书通知群\\\"}\"\n }\n }\n}\n```\n\n注意:\n\n```text\nevent_type = im.message.receive_v1\n```\n\n它只是事件类型,不是 URL。\n\n## 4. 第一步:channels/feishuHttpServer.ts 接住 HTTP\n\n文件:\n\n```text\nsrc/channels/feishuHttpServer.ts\n```\n\n它的角色:\n\n```text\n把 openclaw-mini 变成一个能接飞书 HTTP callback 的 server。\n```\n\n启动命令:\n\n```powershell\nnpm run dev -- serve-feishu --port 8787 --model-provider deepseek --api-config-dir D:\\Vibethon\\openclaw-eval-private-api-configs\n```\n\n默认监听:\n\n```text\nPOST /feishu/events\n```\n\n核心函数:\n\n```ts\nexport async function handleFeishuRequest(\n config: FeishuHttpServerConfig,\n request: IncomingMessage,\n response: ServerResponse\n): Promise<void>\n```\n\n它做的事情:\n\n```text\n1. 检查 method 是不是 POST。\n2. 检查 path 是不是 /feishu/events。\n3. 读取 request body。\n4. JSON.parse。\n5. 调 parseFeishuCallback()。\n6. 如果是 challenge,直接返回 challenge。\n7. 如果是 dropped/unsupported,返回 200 但不跑 agent。\n8. 如果是 message,调用 ChannelGateway.handleInbound()。\n```\n\n关键代码:\n\n```ts\nconst parsed = parseFeishuCallback(payload, config.inbound);\n```\n\n然后:\n\n```ts\nconst result = await config.gateway.handleInbound({\n agentId: config.agentId,\n message: parsed.message,\n options: config.options,\n receivedAt: new Date().toISOString()\n});\n```\n\n到这里,HTTP server 的工作就结束了。\n\n它不懂 agent,不懂 session,不懂模型。\n\n## 5. 第二步:channels/feishuInbound.ts 把飞书事件转成标准消息\n\n文件:\n\n```text\nsrc/channels/feishuInbound.ts\n```\n\n它的角色:\n\n```text\n理解飞书事件 JSON,把它转换成项目内部统一格式 ChannelInboundMessage。\n```\n\n入口函数:\n\n```ts\nexport function parseFeishuCallback(\n payload: JsonValue,\n config: FeishuInboundConfig\n): FeishuInboundParseResult\n```\n\n返回值有三种:\n\n```ts\ntype FeishuInboundParseResult =\n | { type: \"challenge\"; challenge: string }\n | { type: \"message\"; message: ChannelInboundMessage }\n | { type: \"dropped\" | \"unsupported\"; reason: string };\n```\n\n### 5.1 校验 verification token\n\n```ts\nconst token = callbackToken(body);\nif (config.verificationToken && token !== config.verificationToken) {\n throw new Error(\"Invalid Feishu verification token.\");\n}\n```\n\n意思是:\n\n```text\n飞书事件 payload 里的 token 必须和本地 FEISHU_VERIFICATION_TOKEN 一致。\n```\n\n### 5.2 只接收 im.message.receive_v1\n\n```ts\nconst eventType = stringField(header, \"event_type\") ?? stringField(body, \"event_type\");\nif (eventType !== \"im.message.receive_v1\") {\n return {\n type: \"unsupported\",\n reason: `Unsupported Feishu event type: ${eventType ?? \"unknown\"}`\n };\n}\n```\n\n意思是:\n\n```text\n当前 V6 只处理“机器人收到消息”事件。\n```\n\n### 5.3 群聊过滤\n\n群聊默认只处理:\n\n```text\n@机器人 ...\n/ask ...\n```\n\n所以案例里的:\n\n```text\n/ask 查北京天气,顺便发到飞书通知群\n```\n\n会被接受,并去掉 `/ask`。\n\n最终文本变成:\n\n```text\n查北京天气,顺便发到飞书通知群\n```\n\n### 5.4 生成 ChannelInboundMessage\n\n解析结果大致是:\n\n```ts\n{\n id: \"evt_1\",\n channel: \"feishu\",\n senderId: \"ou_user\",\n text: \"查北京天气,顺便发到飞书通知群\",\n timestamp: \"2026-06-10T00:00:00.000Z\",\n replyTarget: {\n channel: \"feishu\",\n kind: \"feishu-message\",\n chatId: \"oc_chat\",\n messageId: \"om_message\"\n },\n metadata: {\n dedupeKey: \"evt_1\",\n eventId: \"evt_1\",\n messageId: \"om_message\",\n chatId: \"oc_chat\",\n chatType: \"group\"\n }\n}\n```\n\n最重要的是两个字段:\n\n```text\ntext:\n 交给 agent 的用户文本。\n\nreplyTarget:\n agent 回答后,ChannelGateway 用它回复原飞书消息。\n```\n\n## 6. 第三步:gateway/types.ts 定义统一语言\n\n文件:\n\n```text\nsrc/gateway/types.ts\n```\n\n这个文件不是业务逻辑,而是所有 channel/gateway 之间的契约。\n\n### 6.1 ReplyTarget\n\n```ts\nexport type ReplyTarget =\n | {\n channel: \"cli\";\n kind: \"none\";\n sessionId: string;\n }\n | {\n channel: \"feishu\";\n kind: \"feishu-message\";\n chatId: string;\n messageId: string;\n rootMessageId?: string;\n };\n```\n\n含义:\n\n```text\n如果 agent 最后有 final answer,应该回复到哪里?\n```\n\n对于飞书,就是:\n\n```text\n回复 messageId 对应的原消息。\n```\n\n### 6.2 ChannelInboundMessage\n\n```ts\nexport type ChannelInboundMessage = {\n id: string;\n channel: string;\n senderId: string;\n text: string;\n timestamp: string;\n sessionId?: string;\n replyTarget?: ReplyTarget;\n metadata?: JsonObject;\n};\n```\n\n这是所有外部入口统一后的消息格式。\n\n飞书、CLI、未来 Slack/Discord,都应该先变成它。\n\n### 6.3 AgentRunRequest\n\n```ts\nexport type AgentRunRequest = {\n requestId: string;\n agentId: string;\n message: AgentRunInputMessage;\n options: RunRequestOptions;\n receivedAt: string;\n channelTrace?: {\n inboundMessageId: string;\n senderId: string;\n replyTarget?: ReplyTarget;\n };\n};\n```\n\n这是“已经解析好 session,准备交给 agent run”的请求。\n\n所以:\n\n```text\nChannelInboundMessage\n 是 channel 层消息。\n\nAgentRunRequest\n 是 agent run 层请求。\n```\n\n## 7. 第四步:gateway/channelGateway.ts 是 channel 总入口\n\n文件:\n\n```text\nsrc/gateway/channelGateway.ts\n```\n\n它的角色:\n\n```text\n接收统一的 ChannelInboundMessage,完成 channel 层编排。\n```\n\n入口:\n\n```ts\nasync handleInbound(input: ChannelGatewayInput): Promise<ChannelGatewayResponse>\n```\n\n它做五件事:\n\n```text\n1. dedupe\n2. drop\n3. session resolve\n4. 调 AgentRunGateway\n5. 如果有 replyTarget,则自动回复 final answer\n```\n\n### 7.1 去重\n\n```ts\nconst dedupeKey = dedupeKeyFor(input);\nif (this.config.deduper.has(dedupeKey)) {\n return {\n status: \"deduped\",\n reason: `Duplicate channel message: ${dedupeKey}`\n };\n}\n```\n\n案例里:\n\n```text\ndedupeKey = feishu:evt_1\n```\n\n如果飞书重试推送同一个事件,就不会重复跑 agent。\n\n### 7.2 session resolve\n\n```ts\nconst resolved = this.config.sessionResolver.resolve(input);\n```\n\n它把没有 sessionId 的飞书消息变成有 sessionId 的 agent input。\n\n### 7.3 进入 AgentRunGateway\n\n```ts\nconst response = await this.config.runGateway.handleRunRequest({\n requestId: createId(\"req\"),\n agentId: resolved.agentId,\n message: resolved.message,\n options: resolved.options,\n receivedAt: resolved.receivedAt,\n channelTrace: {\n inboundMessageId: input.message.id,\n senderId: input.message.senderId,\n replyTarget: resolved.replyTarget\n }\n});\n```\n\n到这里,channel 层把消息交给 agent run。\n\n### 7.4 final answer 自动回复\n\nagent 完成后:\n\n```ts\nconst delivery = await this.replyIfNeeded(response, resolved.replyTarget, input.options.delivery);\n```\n\n如果 `replyTarget.channel === \"feishu\"`,它会找到 `FeishuReplyDeliveryAdapter`。\n\n## 8. 第五步:gateway/channelEventDeduper.ts 去重\n\n文件:\n\n```text\nsrc/gateway/channelEventDeduper.ts\n```\n\n内容很小:\n\n```ts\nexport interface ChannelEventDeduper {\n has(key: string): boolean;\n mark(key: string): void;\n}\n\nexport class MemoryChannelEventDeduper implements ChannelEventDeduper {\n private readonly keys = new Set<string>();\n}\n```\n\n作用:\n\n```text\n防止同一个 channel event 重复触发 agent run。\n```\n\n当前是内存版,所以服务重启后去重记录会丢。\n\n## 9. 第六步:gateway/sessionResolver.ts 生成 sessionId\n\n文件:\n\n```text\nsrc/gateway/sessionResolver.ts\n```\n\n飞书消息本身没有 openclaw-mini 的 sessionId,所以要推导。\n\n规则:\n\n```ts\nif (message.channel === \"feishu\") {\n const chatId = stringMetadata(message, \"chatId\");\n const rootMessageId = stringMetadata(message, \"rootMessageId\");\n if (rootMessageId) {\n return `feishu:chat:${chatId}:thread:${rootMessageId}`;\n }\n return `feishu:chat:${chatId}`;\n}\n```\n\n案例里没有 thread,则:\n\n```text\nsessionId = feishu:chat:oc_chat\n```\n\n意义:\n\n```text\n同一个飞书 chat 进入同一个 session history。\n```\n\n## 10. 第七步:gateway/agentRunGateway.ts 创建一次 run\n\n文件:\n\n```text\nsrc/gateway/agentRunGateway.ts\n```\n\n它的角色:\n\n```text\n把已经解析好的 AgentRunRequest 变成一次真正的 agent run。\n```\n\n入口:\n\n```ts\nasync handleRunRequest(request: AgentRunRequest): Promise<AgentResponse>\n```\n\n它做:\n\n```text\n1. registry.resolve(agentId)\n2. 创建 RunExecutionContext\n3. 创建 run artifact 目录\n4. 写 channel.inbound.received trace\n5. 写 session.resolved trace\n6. 写 run.accepted trace\n7. 用 SessionLaneRunner 保证同 agent+session 串行\n8. 调 RunOrchestrator.run()\n```\n\n关键代码:\n\n```ts\nconst result = await this.config.lanes.run(\n laneKey(executionContext.agentId, executionContext.sessionId),\n async () => (\n await this.config.orchestrator.run({\n message: request.message,\n executionContext,\n artifactStore,\n agent: profile\n })\n )\n);\n```\n\n案例里 lane key 是:\n\n```text\ndefault:feishu:chat:oc_chat\n```\n\n同一个飞书群连续两条消息会串行执行。\n\n## 11. 第八步:agent runtime 返回 final answer\n\n这一步不在 channels/gateway 目录里,但为了串起来需要知道它返回什么。\n\n`RunOrchestrator` 和 `AgentRuntime` 最终返回:\n\n```ts\nRunResult {\n finalMessage: \"北京今天晴,26°C,微风。\",\n success: true,\n ...\n}\n```\n\n这个 `finalMessage` 回到:\n\n```text\nChannelGateway.replyIfNeeded()\n```\n\n## 12. 第九步:channels/delivery.ts 定义回复 adapter 接口\n\n文件:\n\n```text\nsrc/channels/delivery.ts\n```\n\n它定义:\n\n```ts\nexport interface ChannelDeliveryAdapter {\n readonly channel: string;\n reply(target: ReplyTarget, text: string, options: DeliveryOptions): Promise<DeliveryResult>;\n}\n```\n\n含义:\n\n```text\n不同 channel 应该如何回复 final answer?\n```\n\n当前有两个实现方向:\n\n```text\nCLI:\n CliDeliveryAdapter\n\nFeishu:\n FeishuReplyDeliveryAdapter\n```\n\n`CliDeliveryAdapter` 是 dry-run 风格的本地实现,不会真的发网络请求。\n\n## 13. 第十步:channels/feishuReplyDelivery.ts 把 ReplyTarget 交给飞书客户端\n\n文件:\n\n```text\nsrc/channels/feishuReplyDelivery.ts\n```\n\n它的角色:\n\n```text\n把 ChannelGateway 的“我要回复飞书消息”请求,转成 FeishuAppClient.replyText()。\n```\n\n核心代码:\n\n```ts\nasync reply(target: ReplyTarget, text: string, options: DeliveryOptions): Promise<DeliveryResult> {\n if (target.channel !== \"feishu\" || target.kind !== \"feishu-message\") {\n throw new Error(\"FeishuReplyDeliveryAdapter requires a Feishu message reply target.\");\n }\n return await this.client.replyText(target.messageId, text, options.dryRun);\n}\n```\n\n它不管 app_id/app_secret,也不直接拼 URL。\n\n它只负责:\n\n```text\n确认 target 是飞书消息\n拿 messageId\n调用 FeishuAppClient\n```\n\n## 14. 第十一步:channels/feishuAppClient.ts 调飞书 Open API\n\n文件:\n\n```text\nsrc/channels/feishuAppClient.ts\n```\n\n它是真正调用飞书 Open API 的地方。\n\n### 14.1 dry-run\n\n如果是 dry-run:\n\n```ts\nif (dryRun) {\n return {\n ok: true,\n status: 0,\n responseBody: \"dry-run\",\n dryRun: true,\n payload\n };\n}\n```\n\n不会真的请求飞书。\n\n### 14.2 获取 tenant_access_token\n\n```ts\nconst response = await this.fetchFn(`${this.baseUrl}/auth/v3/tenant_access_token/internal`, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\"\n },\n body: JSON.stringify({\n app_id: this.config.appId,\n app_secret: this.config.appSecret\n })\n});\n```\n\n方向:\n\n```text\nopenclaw-mini -> 飞书 Auth API\n```\n\n### 14.3 调 reply API\n\n```ts\nconst response = await this.fetchFn(`${this.baseUrl}/im/v1/messages/${encodeURIComponent(messageId)}/reply`, {\n method: \"POST\",\n headers: {\n authorization: `Bearer ${token}`,\n \"content-type\": \"application/json\"\n },\n body: JSON.stringify(payload)\n});\n```\n\n方向:\n\n```text\nopenclaw-mini -> 飞书 IM Reply API\n```\n\n这一步完成后,飞书会把回答展示到原消息线程。\n\n## 15. channels/base.ts:两种 outbound 的共同底层类型\n\n文件:\n\n```text\nsrc/channels/base.ts\n```\n\n它定义:\n\n```ts\nexport type DeliveryResult = {\n ok: boolean;\n status: number;\n responseBody: string;\n dryRun: boolean;\n payload: JsonObject;\n};\n```\n\n这个类型同时被两类出站使用:\n\n```text\n普通 final reply:\n FeishuAppClient.replyText()\n\nwebhook tool send:\n FeishuWebhookChannel.sendText()\n```\n\n它还定义了旧式 channel action 接口:\n\n```ts\nexport interface ChannelAdapter {\n readonly name: string;\n sendText(input: SendTextInput): Promise<DeliveryResult>;\n}\n```\n\n这个接口主要给 `feishu.send_text` tool 用。\n\n## 16. 旁支 C:channels/feishu.ts 是 webhook outbound,不是普通 reply\n\n文件:\n\n```text\nsrc/channels/feishu.ts\n```\n\n它实现:\n\n```ts\nexport class FeishuWebhookChannel implements ChannelAdapter\n```\n\n作用:\n\n```text\n向 FEISHU_WEBHOOK_URL 发一条文本消息。\n```\n\n方向:\n\n```text\nopenclaw-mini -> 飞书 webhook URL\n```\n\n不是:\n\n```text\n飞书 -> openclaw-mini\n```\n\n也不是:\n\n```text\nopenclaw-mini -> 飞书 reply API\n```\n\n### 16.1 它什么时候用\n\n当模型调用 `feishu.send_text` 工具时用。\n\n比如用户说:\n\n```text\n查北京天气,顺便发到飞书通知群\n```\n\n模型可能先查天气,然后调用:\n\n```text\nfeishu.send_text\n```\n\n这个工具最终会通过 `FeishuWebhookChannel.sendText()` 发到 `FEISHU_WEBHOOK_URL`。\n\n### 16.2 签名\n\n如果配置了:\n\n```dotenv\nFEISHU_WEBHOOK_SECRET=\n```\n\n它会构造:\n\n```ts\npayload.timestamp = timestamp;\npayload.sign = createFeishuSignature(timestamp, params.secret);\n```\n\n这个签名只属于 webhook outbound。\n\n普通 reply API 不用这个 secret。\n\n## 17. 旁支 D:channels/cli.ts 是本地调试 channel\n\n文件:\n\n```text\nsrc/channels/cli.ts\n```\n\n它做:\n\n```ts\nexport function createCliMessage(params): ChannelInboundMessage\n```\n\nCLI 命令:\n\n```powershell\nnpm run dev -- run --message \"查北京天气\" --session demo --dry-run\n```\n\n会创建:\n\n```ts\n{\n channel: \"cli\",\n sessionId: \"demo\",\n text: \"查北京天气\",\n replyTarget: {\n channel: \"cli\",\n kind: \"none\",\n sessionId: \"demo\"\n }\n}\n```\n\n然后也进入:\n\n```text\nChannelGateway.handleInbound()\n```\n\n这说明 CLI 和飞书不是两套 runtime。\n\n它们只是两个不同 channel adapter,进入同一个 gateway。\n\n## 18. 同一个案例的完整顺序\n\n用户在飞书群发:\n\n```text\n/ask 查北京天气,顺便发到飞书通知群\n```\n\n完整顺序:\n\n```text\n1. 飞书服务器 POST /feishu/events\n -> channels/feishuHttpServer.ts\n\n2. parseFeishuCallback()\n -> channels/feishuInbound.ts\n\n3. 得到 ChannelInboundMessage\n -> gateway/types.ts\n\n4. ChannelGateway.handleInbound()\n -> gateway/channelGateway.ts\n\n5. MemoryChannelEventDeduper 检查 evt_1 是否重复\n -> gateway/channelEventDeduper.ts\n\n6. DefaultSessionResolver 生成 feishu:chat:oc_chat\n -> gateway/sessionResolver.ts\n\n7. AgentRunGateway 创建 run,写 trace,进入 session lane\n -> gateway/agentRunGateway.ts\n\n8. RunOrchestrator / AgentRuntime 调模型和工具\n -> runtime/*\n\n9. 如果模型调用 feishu.send_text\n -> tools/feishu.ts\n -> channels/feishu.ts\n -> FEISHU_WEBHOOK_URL\n\n10. AgentRuntime 返回 finalMessage\n -> ChannelGateway.replyIfNeeded()\n\n11. ChannelGateway 找 channel=feishu 的 delivery adapter\n -> channels/delivery.ts\n -> channels/feishuReplyDelivery.ts\n\n12. FeishuAppClient 获取 token 并调用 reply API\n -> channels/feishuAppClient.ts\n\n13. 飞书把回复展示到原消息\n```\n\n## 19. Trace 里怎么看 channels/gateway\n\n一次正常双工 run 的 trace 里,和 channels/gateway 相关的事件有:\n\n```text\nchannel.inbound.received\nsession.resolved\nrun.accepted\ndelivery.reply.started\ndelivery.reply.finished\n```\n\n对应关系:\n\n| Trace event | 哪个文件写入 | 含义 |\n|---|---|---|\n| `channel.inbound.received` | `gateway/agentRunGateway.ts` | channel 消息进入 run artifact |\n| `session.resolved` | `gateway/agentRunGateway.ts` | sessionId 和 replyTarget 已确定 |\n| `run.accepted` | `gateway/agentRunGateway.ts` | Gateway 接受本次 run |\n| `delivery.reply.started` | `gateway/channelGateway.ts` | 开始把 final answer 回复回 channel |\n| `delivery.reply.finished` | `gateway/channelGateway.ts` | 回复完成或失败 |\n\n如果走了 `feishu.send_text` webhook 工具,还会看到:\n\n```text\ndelivery.sent\n```\n\n这个事件来自 runtime 对 channel action tool 的记录。\n\n## 20. 每个文件一句话总结\n\n### channels\n\n| 文件 | 一句话 |\n|---|---|\n| `channels/base.ts` | 定义通用 delivery result 和旧式 sendText channel adapter |\n| `channels/cli.ts` | 把 CLI 输入转成 `ChannelInboundMessage` |\n| `channels/delivery.ts` | 定义 final answer 自动回复用的 `ChannelDeliveryAdapter` |\n| `channels/feishuHttpServer.ts` | 启动 `/feishu/events`,接收飞书 callback |\n| `channels/feishuInbound.ts` | 解析飞书事件,生成 `ChannelInboundMessage` |\n| `channels/feishuReplyDelivery.ts` | 把 `ReplyTarget` 转给 FeishuAppClient |\n| `channels/feishuAppClient.ts` | 调飞书 Auth API 和 Reply API |\n| `channels/feishu.ts` | 调飞书 webhook URL,服务 `feishu.send_text` 工具 |\n\n### gateway\n\n| 文件 | 一句话 |\n|---|---|\n| `gateway/types.ts` | 定义 channel 和 run 之间的核心类型契约 |\n| `gateway/channelGateway.ts` | channel 层总编排:去重、session、run、reply |\n| `gateway/channelEventDeduper.ts` | 防止同一个 channel event 重复处理 |\n| `gateway/sessionResolver.ts` | 从 channel message 推导 agent sessionId |\n| `gateway/agentRunGateway.ts` | 创建 run、写 run trace、进入 session lane、调用 runtime 编排 |\n\n## 21. 最短阅读路径\n\n如果你只想系统读懂 channels/gateway,按这个顺序:\n\n```text\n1. gateway/types.ts\n2. channels/feishuInbound.ts\n3. channels/feishuHttpServer.ts\n4. gateway/channelGateway.ts\n5. gateway/sessionResolver.ts\n6. gateway/channelEventDeduper.ts\n7. gateway/agentRunGateway.ts\n8. channels/delivery.ts\n9. channels/feishuReplyDelivery.ts\n10. channels/feishuAppClient.ts\n11. channels/feishu.ts\n12. channels/cli.ts\n13. channels/base.ts\n```\n\n这样读的原因:\n\n```text\n先理解统一类型\n再理解飞书如何变成统一类型\n再理解 gateway 如何处理统一类型\n最后看不同出站方式怎么发回去\n```\n\n## 22. 记忆模型\n\n可以把这两个目录记成:\n\n```text\nchannels:\n 负责和外部世界说话。\n 飞书 HTTP、飞书事件、飞书 reply API、飞书 webhook、CLI 都在这里。\n\ngateway:\n 负责把外部消息变成一次 agent run。\n 它处理去重、session、run 创建、run 接受、自动回复。\n```\n\n更短:\n\n```text\nchannels 管“外面怎么进来、怎么出去”。\ngateway 管“进来以后怎么进入 agent,以及 agent 结束后怎么回 channel”。\n```\n"}}}}