{"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 飞书服务器
POST /feishu/events\"] --> B[\"2 channels/feishuHttpServer.ts
接 HTTP callback\"]\n B --> C[\"3 channels/feishuInbound.ts
解析 event_type 和文本\"]\n C --> D[\"4 gateway/types.ts
ChannelInboundMessage\"]\n D --> E[\"5 gateway/channelGateway.ts
channel 总入口\"]\n E --> F[\"6 gateway/agentRunGateway.ts
创建 run 并进入 agent\"]\n F --> G[\"7 runtime
模型和工具循环\"]\n G --> H[\"8 gateway/channelGateway.ts
拿到 finalMessage 后回复\"]\n H --> I[\"9 channels/feishuReplyDelivery.ts
选择飞书 reply adapter\"]\n I --> J[\"10 channels/feishuAppClient.ts
调用飞书 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[\"入站适配
外部消息 -> ChannelInboundMessage\"]\n Channels --> Reply[\"普通问答回复
finalMessage -> 原 channel\"]\n Channels --> Webhook[\"主动发送工具
feishu.send_text -> webhook\"]\n Channels --> Cli[\"本地 CLI 调试入口\"]\n Channels --> Shared[\"共享类型\"]\n\n Inbound --> Http[\"feishuHttpServer.ts
接 /feishu/events\"]\n Inbound --> Parser[\"feishuInbound.ts
解析飞书 event\"]\n\n Reply --> Delivery[\"delivery.ts
ChannelDeliveryAdapter\"]\n Reply --> FeishuReply[\"feishuReplyDelivery.ts
ReplyTarget -> FeishuAppClient\"]\n Reply --> AppClient[\"feishuAppClient.ts
Auth API + Reply API\"]\n\n Webhook --> FeishuWebhook[\"feishu.ts
FEISHU_WEBHOOK_URL\"]\n\n Cli --> CliFile[\"cli.ts
createCliMessage\"]\n\n Shared --> Base[\"base.ts
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
handleInbound\"]\n B --> C[\"channelEventDeduper.ts
检查 dedupeKey\"]\n C --> D[\"sessionResolver.ts
生成 sessionId\"]\n D --> E[\"types.ts
AgentRunRequest\"]\n E --> F[\"agentRunGateway.ts
handleRunRequest\"]\n F --> G[\"写 trace
channel.inbound.received
session.resolved
run.accepted\"]\n G --> H[\"SessionLaneRunner
同 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
普通问答回答\"] --> AutoReply[\"channelGateway.ts
replyIfNeeded\"]\n AutoReply --> ReplyAdapter[\"feishuReplyDelivery.ts\"]\n ReplyAdapter --> AppClient[\"feishuAppClient.ts\"]\n AppClient --> Auth[\"飞书 Auth API
/auth/v3/tenant_access_token/internal\"]\n AppClient --> ReplyAPI[\"飞书 Reply API
/im/v1/messages/{message_id}/reply\"]\n\n ToolCall[\"模型调用工具
feishu.send_text\"] --> Tool[\"tools/feishu.ts\"]\n Tool --> WebhookChannel[\"channels/feishu.ts
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\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\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();\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\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;\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 {\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;\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 飞书服务器
POST /feishu/events\"] --> B[\"2 channels/feishuHttpServer.ts
接 HTTP callback\"]\n B --> C[\"3 channels/feishuInbound.ts
解析 event_type 和文本\"]\n C --> D[\"4 gateway/types.ts
ChannelInboundMessage\"]\n D --> E[\"5 gateway/channelGateway.ts
channel 总入口\"]\n E --> F[\"6 gateway/agentRunGateway.ts
创建 run 并进入 agent\"]\n F --> G[\"7 runtime
模型和工具循环\"]\n G --> H[\"8 gateway/channelGateway.ts
拿到 finalMessage 后回复\"]\n H --> I[\"9 channels/feishuReplyDelivery.ts
选择飞书 reply adapter\"]\n I --> J[\"10 channels/feishuAppClient.ts
调用飞书 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[\"入站适配
外部消息 -> ChannelInboundMessage\"]\n Channels --> Reply[\"普通问答回复
finalMessage -> 原 channel\"]\n Channels --> Webhook[\"主动发送工具
feishu.send_text -> webhook\"]\n Channels --> Cli[\"本地 CLI 调试入口\"]\n Channels --> Shared[\"共享类型\"]\n\n Inbound --> Http[\"feishuHttpServer.ts
接 /feishu/events\"]\n Inbound --> Parser[\"feishuInbound.ts
解析飞书 event\"]\n\n Reply --> Delivery[\"delivery.ts
ChannelDeliveryAdapter\"]\n Reply --> FeishuReply[\"feishuReplyDelivery.ts
ReplyTarget -> FeishuAppClient\"]\n Reply --> AppClient[\"feishuAppClient.ts
Auth API + Reply API\"]\n\n Webhook --> FeishuWebhook[\"feishu.ts
FEISHU_WEBHOOK_URL\"]\n\n Cli --> CliFile[\"cli.ts
createCliMessage\"]\n\n Shared --> Base[\"base.ts
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
handleInbound\"]\n B --> C[\"channelEventDeduper.ts
检查 dedupeKey\"]\n C --> D[\"sessionResolver.ts
生成 sessionId\"]\n D --> E[\"types.ts
AgentRunRequest\"]\n E --> F[\"agentRunGateway.ts
handleRunRequest\"]\n F --> G[\"写 trace
channel.inbound.received
session.resolved
run.accepted\"]\n G --> H[\"SessionLaneRunner
同 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
普通问答回答\"] --> AutoReply[\"channelGateway.ts
replyIfNeeded\"]\n AutoReply --> ReplyAdapter[\"feishuReplyDelivery.ts\"]\n ReplyAdapter --> AppClient[\"feishuAppClient.ts\"]\n AppClient --> Auth[\"飞书 Auth API
/auth/v3/tenant_access_token/internal\"]\n AppClient --> ReplyAPI[\"飞书 Reply API
/im/v1/messages/{message_id}/reply\"]\n\n ToolCall[\"模型调用工具
feishu.send_text\"] --> Tool[\"tools/feishu.ts\"]\n Tool --> WebhookChannel[\"channels/feishu.ts
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\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\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();\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\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;\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 {\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;\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"}}}}