ARTICLE · 1278725
ESP-Claw源码剖析-09_IM通信集成
IM 通道是人与 ESP-Claw 设备对话的主入口:Telegram、QQ、飞书、微信四个远端平台,外加一个进程内的 Web 通道,全部由设备直接连接各平台的开放接口收发消息,中间没有自建消息服务器。本章剖析 cap_im_platform 与 cap_im_local 两个组件如何把这些异构平台折叠成统一的能力组与事件流:入站消息如何变成事件并流入 Agent,Agent 的回复如何经出站绑定表找到正确的通道与聊天 ID 回发,媒体附件如何落盘与事件化。视角依旧是"为什么这样设计"。
目录
IM 通道的定位与组件版图1.1 设备直连:为什么 IM 是主入口1.2 两个组件的分工
IM 抽象:能力组、网关与发送器2.1 gateway 与 send_*:一组能力两种角色2.2 新平台如何接入
各平台的接入差异3.1 传输方式对比:长轮询与长连接3.2 凭证管理链路3.3 微信特例:扫码登录与进度限流
入站流:一条消息如何进入 Agent
出站流:出站绑定与 send_message 动作5.1 out_message 事件与默认绑定解析5.2 注册制而非 switch-case5.3 多通道并发会话的隔离
cap_im_local:Web IM 的进程内桥
媒体消息:附件落盘与事件化
设计动机总评参考文献
1. IM 通道的定位与组件版图
1.1 设备直连:为什么 IM 是主入口
第一性问题是:一个没有屏幕(或只有小屏幕)的嵌入式 Agent,用户用什么客户端跟它说话?自研 App 意味着开发、分发、升级三重成本;而 IM 机器人什么都不需要——用户的微信、QQ、Telegram、飞书早已装好,加个好友即可对话。ESP-Claw 选择"设备直连平台开放接口"而非"自建服务器中转",出于三层考虑:
免运维:设备主动外连平台(长轮询或 WebSocket),不需要公网 IP、域名、证书与常驻服务器,家庭 NAT 环境即插即用。
隐私:消息只经过 IM 平台与 LLM 服务两方,不落在开发者自建的中转机上。
可裁剪:四个平台各自受 Kconfig 开关(
CONFIG_APP_CLAW_CAP_IM_TG/QQ/FEISHU/WECHAT)控制,按目标市场只编入需要的通道。

单台设备可同时挂多个通道:每个通道一个独立的收发任务,但共享同一个事件路由器与同一个 Agent。用户在哪个通道说话,回复就从哪个通道回去。
1.2 两个组件的分工
IM 相关代码分两个能力组件(第5章《能力系统》的能力组机制):
components/claw_capabilities/cap_im_platform/:四个远端平台(cap_im_tg.c、cap_im_qq.c、cap_im_feishu.c、cap_im_wechat.c)加共享附件工具cap_im_attachment.c,每个平台约 1500~3400 行。components/claw_capabilities/cap_im_local/:进程内本地通道cap_im_local.c(约 500 行),为设备自带 Web 聊天页充当 IM 网关。
远端平台的聚合入口只有 39 行(src/cap_im_platform.c),做的事仅仅是在 Kconfig 开关下依次调用各平台的注册函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
esp_err_tcap_im_platform_register_groups(void){#if CONFIG_APP_CLAW_CAP_IM_FEISHUESP_RETURN_ON_ERROR(cap_im_feishu_register_group(), TAG, "register Feishu IM group failed");#endif#if CONFIG_APP_CLAW_CAP_IM_QQESP_RETURN_ON_ERROR(cap_im_qq_register_group(), TAG, "register QQ IM group failed");#endif#if CONFIG_APP_CLAW_CAP_IM_TGESP_RETURN_ON_ERROR(cap_im_tg_register_group(), TAG, "register Telegram IM group failed");#endif// 微信分支同构,略return ESP_OK;}
真正的接线发生在应用层:components/common/app_claw/app_capabilities.c 的能力组表为每个平台准备 prepare/register 两个回调,register 回调里完成"注册能力组 + 注入凭证 + 注入附件配置"三件事;app_claw.c 随后为每个通道注册出站绑定(第 5 节详述)。组件本身不知道应用何时、以何种顺序装配它——这正是能力系统"注册制"的体现。
2. IM 抽象:能力组、网关与发送器
2.1 gateway 与 send_*:一组能力两种角色
每个平台注册一个 claw_cap_group,组内描述符固定为两种角色(以 Telegram 为例,摘自 cap_im_tg.c):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
staticconstclaw_cap_descriptor_t s_tg_descriptors[] = {{.id = "tg_gateway", // 事件源:收消息.kind = CLAW_CAP_KIND_EVENT_SOURCE,.cap_flags = CLAW_CAP_FLAG_EMITS_EVENTS |CLAW_CAP_FLAG_SUPPORTS_LIFECYCLE,.init = cap_im_tg_gateway_init,.start = cap_im_tg_gateway_start, // 启动轮询任务.stop = cap_im_tg_gateway_stop,},{.id = "tg_send_message", // 可调用:发消息.kind = CLAW_CAP_KIND_CALLABLE,.cap_flags = CLAW_CAP_FLAG_CALLABLE_BY_LLM,.execute = cap_im_tg_send_message_execute,// input_schema_json 声明 chat_id / message 两个入参,略},// tg_send_image / tg_send_file 同构,略};
值得注意的是:这里没有 C 层的 ops 虚函数表,抽象完全落在两条约定上。其一,gateway 是一个带生命周期的事件源能力,start 内部自建 FreeRTOS 任务或 WebSocket 客户端,框架不 prescribe 线程模型——Telegram 在 cap_im_tg_gateway_start 里创建轮询任务,QQ/飞书创建的是 esp_websocket_client 会话。其二,send_* 是可被 LLM 调用的工具,且 chat_id 允许缺省:cap_im_tg_send_message_execute 先从输入 JSON 取 chat_id,取不到就回退到调用上下文 ctx->chat_id。这意味着 Agent 在会话中途调工具回话时,不必(也无法)知道用户在哪个平台、哪个聊天里——位置感由框架携带,模型只管说内容。
四个平台的头文件契约完全同构:cap_im_<x>_register_group / set_token(或 set_credentials)/ set_attachment_config / start / stop / send_text / send_image / send_file。QQ 额外有 cap_im_qq_set_msg_type(选择回发消息格式),微信额外有一组扫码登录接口(见 3.3 节)。
2.2 新平台如何接入
接入一个新平台(例如钉钉)的改动清单:
新建
cap_im_dingtalk.c/.h,实现凭证 setter、<x>_gateway生命周期(自建收发任务)、<x>_send_message的 execute 与 HTTP 发送;在
cap_im_platform.c与 Kconfig 中加入开关CONFIG_APP_CLAW_CAP_IM_DINGTALK;在
app_capabilities.c能力组表中加一行 prepare/register,注入凭证;在
app_claw.c中注册出站绑定:claw_event_router_register_outbound_binding("dingtalk", "dingtalk_send_message")。
关键在于:事件路由器、claw_core、其他平台、会话系统零改动。新平台只需遵守"入站发布 message 事件、出站注册 <x>_send_message 能力"两条约定,其余基础设施自动生效——路由规则里的 {{event.source_channel}} 模板会把回复送回新通道,会话系统自动按新通道的 chat_id 建会话。
3. 各平台的接入差异
3.1 传输方式对比:长轮询与长连接
四个平台的传输方式以源码为准汇总如下(通道名即出站绑定表的键):
telegram | getUpdates(timeout 20 s,offset 游标推进) | ||
qq | /gateway 取 wss 地址后 identify 鉴权) | ||
feishu | |||
wechat | ilinkai.weixin.qq.com,35 s 超时 + sync 游标) | ||
web |
为什么全部是"设备主动外连"而没有 webhook 回调?因为 webhook 要求设备有公网可达的 HTTPS 端点,这在家庭 NAT 与移动网络下不现实,也违背免运维目标。Telegram 的 cap_im_tg_poll_once 构造 {"timeout":20,"offset":next_update_id} 调 getUpdates,失败后退避 3 秒重试;QQ 先用 app_id/secret 换 access_token(提前 300 秒刷新、遇 401 失效重取),再请求 https://api.sgroup.qq.com/gateway 拿到 wss 地址,建连后发送 op=2 identify(鉴权头 QQBot <access_token>);飞书则请求 https://open.feishu.cn/callback/ws/endpoint 获取 WebSocket 配置(URL 与 PingInterval,默认 120 秒心跳),连上后只关注 im.message.receive_v1 事件,鉴权用 /auth/v3/tenant_access_token/internal 换取的 tenant_access_token。
QQ 与飞书同为 WebSocket,代码却各自实现一遍分片重组(cap_im_qq_ws_assembly_t 与飞书的 cap_im_feishu_ws_frame_t)——两家的帧格式、心跳、重连策略差异足够大,强行抽象出"通用 IM WebSocket 层"只会制造一个两头不靠的接口,这是"不做投机抽象"的取舍。
3.2 凭证管理链路
凭证不走第 8 章那样的独立配置组件,而是复用应用的统一配置体系:
定义:
application/edge_agent/components/app_config/app_config.c用APP_CONFIG_FIELD(tg_bot_token, "tg_bot_token", APP_DEFAULT_TG_BOT_TOKEN)声明字段,默认值来自 Kconfig,覆盖值持久化在 settings 存储;注入:
app_cap_register_im_tg()等函数把app_claw_config_t里的凭证经cap_im_tg_set_token()、cap_im_qq_set_credentials()、cap_im_feishu_set_credentials()、cap_im_wechat_set_client_config()塞进组件静态状态;运行期修改:Web 配置页把 IM 凭证归入
"im"组(http_server_config_api.c的CONFIG_FIELD("im", tg_bot_token)等),改完即可热生效;串口侧另有cmd_cap_im_tg.c等 CLI 命令做同样的事。
也就是说,IM 组件对凭证来源零假设:它只暴露 setter,谁来调、值存哪里是应用装配层的决策。这让 cap_im_platform 可以脱离 app_config 在别的应用里复用。
3.3 微信特例:扫码登录与进度限流
个人微信没有官方机器人 API,固件默认对接 https://ilinkai.weixin.qq.com 网关(wechat_base_url 可配置),配合扫码登录绑定个人账号:cap_im_wechat_qr_login_start() 启动流程,cap_im_wechat_qr_login_get_status() 轮询出的 cap_im_wechat_qr_login_status_t 里带有 qr_data_url(渲染成二维码供扫码)与 active/completed/persisted 等状态位,登录成功后 token 持久化(cap_im_wechat_qr_login_mark_persisted()),此后用 35 秒长轮询加 sync_buf 游标增量拉取新消息。
微信还有一个独有的细节——进度消息限流。Agent 推理过程中的 agent_stage 阶段事件("正在查天气……")会经由路由规则回发到 IM;微信对话流密集,连续刷屏体验极差。cap_im_wechat_stage_send_mode_for_event() 为每个 chat_id 维护连续 stage 计数:前 CAP_IM_WECHAT_STAGE_LIMIT(5)条照发,第 6 条改发一条"进度过多"提示,之后全部静默跳过,直到出现非 stage 消息清零计数。这是"平台适配差异下沉到平台文件"的范例:路由器与 claw_core 对此一无所知。
4. 入站流:一条消息如何进入 Agent

流程执行说明:
收取阶段(步骤 1-3):平台侧消息到达 Telegram 云端后,由
cap_im_tg_poll_task的cap_im_tg_poll_once()经 20 秒长轮询取回,解析出 chat_id、sender_id、message_id 与文本,调用cap_im_tg_publish_inbound_text(),后者只是claw_event_router_publish_message("tg_gateway", "telegram", chat_id, content, sender_id, message_id)的薄封装。该函数(第6章《事件路由系统》)把参数折叠成claw_event_t:event_type="message"、content_type="text"、session_policy=CLAW_SESSION_POLICY_CHAT、correlation_id=入站 message_id——这条 correlation 线后面要用来串回复。规则短路阶段(步骤 4-5):路由器按
router_rules.json顺序匹配。im_any_message_working_reply(consume_on_match=false,不消费事件)先回一句"🦞 ESP-Claw is snapping on it..."稳住用户;/session、/llm前缀分别被im_session_command、im_llm_command规则用call_cap动作短路给session_command、llm_config_command能力处理,毫秒级应答,不消耗一次 LLM 调用。
这里的消费语义是精心排布的:工作回复规则排在前面但不消费事件,前缀命令规则消费事件后即终止匹配,普通文本规则最后消费并交给 Agent。于是同一条消息既得到即时安抚,又不会重复触发多条链路——"应答"与"处理"是两条规则而非一个分支,各自可独立启停。
进入 Agent 阶段(步骤 6-8):普通文本命中 im_any_message_agent(consume_on_match=true),动作run_agent的模板target_channel={{event.source_channel}}把回程地址写进请求。claw_event_router_execute_agent_action()组装claw_agent_mgr_root_input_t——source_channel/source_chat_id/source_message_id全部随行,且flags带上CLAW_CORE_REQUEST_FLAG_PUBLISH_OUT_MESSAGE(这是出站事件的关键开关)——然后claw_agent_mgr_submit_root()提交。此后是第4章《Agent核心设计》的推理循环。
入站流的设计要点:四个平台的 cap_im_<x>_publish_inbound_text 长得一模一样(只换 source_cap 与通道名字符串)。平台适配层把一切差异(轮询/长连接、JSON 方言、鉴权)消化在自己文件里,交给路由器的已经是同一种 message 事件——异构性在进入系统之前就被吸收了。
5. 出站流:出站绑定与 send_message 动作
5.1 out_message 事件与默认绑定解析
Agent 完成回复后,claw_core_publish_out_message_if_requested() 检查请求 flags 里的 CLAW_CORE_REQUEST_FLAG_PUBLISH_OUT_MESSAGE(由路由器的 run_agent 动作设置),调用 build_agent_out_message_event() 构造事件:source_cap="claw_core"、event_type="out_message"、通道取 target_channel(无则回落 source_channel)、correlation_id 复制入站消息的 message_id,随后 claw_event_router_publish()。规则 agent_out_message_send_message 匹配到它,执行 send_message 动作:
claw_event_router_execute_send_message_action()用模板渲染出channel/chat_id/message(message 缺省回落last.output);claw_event_router_resolve_outbound_cap()把通道名翻译成能力 id:先问应用注入的自定义 resolver,未命中(返回ESP_ERR_NOT_FOUND)再用默认实现——遍历绑定表按通道名字符串匹配:
1 2 3 4 5 6 7 8 9 10 11
claw_event_router_lock(); // 默认解析器:加锁遍历绑定表for (i = 0; i < s_runtime->binding_count; i++) {if (strcmp(s_runtime->bindings[i].channel,target_channel ? target_channel : "") == 0) {strlcpy(cap_name, s_runtime->bindings[i].cap_name, cap_name_size);claw_event_router_unlock();return ESP_OK; // 命中:返回能力 id}}claw_event_router_unlock();return ESP_ERR_NOT_FOUND; // 通道未注册绑定
claw_cap_call("tg_send_message", {chat_id, message}, caller=CLAW_CAP_CALLER_SYSTEM)调起平台发送能力,最终落到cap_im_tg_send_text()的 HTTPsendMessage。
绑定表由 app_claw.c 在启动时注册,每个通道一行:
1 2 3 4 5 6
#if CONFIG_APP_CLAW_CAP_IM_TGESP_RETURN_ON_ERROR(claw_event_router_register_outbound_binding("telegram", "tg_send_message"),TAG, "Failed to bind Telegram outbound");#endif// qq→qq_send_message、feishu→feishu_send_message、// wechat→wechat_send_message、web→local_send_message 同构,略
此外还有一条并行的出站流:claw_core_publish_stage_text() 发布的 agent_stage 进度事件,被规则 agent_stage_im_notify 同样送回原聊天,让用户在长任务中看到中间进展(微信侧受 3.3 节的限流保护)。
5.2 注册制而非 switch-case
出站分发最容易写成 if (strcmp(channel,"telegram")==0) cap_im_tg_send_text(...) else if ... 的硬编码。ESP-Claw 没有这么做,收益有三:
路由器零依赖:
claw_event_router.c不 include 任何cap_im_*.h,通道到能力的映射是运行期字符串绑定,路由器组件可以脱离全部 IM 平台单独编译测试;新平台零侵入:如 2.2 节所述,新通道只需注册能力加一行绑定,不改路由器一行代码;
策略可覆盖:
outbound_resolver钩子允许应用实现比"按通道"更细的策略(例如把某类通知强制改投另一通道),默认实现只兜底。
本质上,通道名与能力 id 都是字符串协议,绑定表是唯一将二者焊接在一起的地方——这和第5章"能力按 id 字符串调用"的设计一脉相承。
5.3 多通道并发会话的隔离
一个用户同时在 Telegram 和微信里跟同一台设备说话,两段对话必须互不串味。隔离靠会话 ID 的构造:claw_session_mgr_build_session_id() 以 agent_id + source_channel + chat_id 生成会话 ID,同一个人在两个平台天然得到两个会话,各自维护独立的上下文与记忆(第7章《记忆系统》);同一平台内,不同群聊/单聊的 chat_id 也互不干扰。会话策略由入站事件携带——IM 消息统一标记 CLAW_SESSION_POLICY_CHAT(按聊天归档),而定时器、开机这类触发事件走 trigger/global 策略,不混入对话历史;NOSAVE 策略则直接返回空会话 ID,用于一次性查询。若会话管理器尚未就绪,构造函数会退回默认会话 ID(claw_session_mgr_write_default_session_id),保证启动早期消息不丢。回程方向,target_channel/target_chat_id 由 run_agent 动作从入站事件拷贝而来,回复永远走"来时的路",除非规则显式改写 target。correlation_id 则把出站 out_message 与入站 message 串起来,供日志与上层审计。
值得强调的是,这套隔离对 Agent 完全透明:claw_core 收到的请求里只有 source_channel/source_chat_id 与文本,它不需要任何"多租户"意识。并发正确性由两端的约定保证——入站时路由器把通道与聊天写进请求,出站时绑定表按同一组字符串找回出口,中间没有任何共享可变状态。
6. cap_im_local:Web IM 的进程内桥
cap_im_local 是五个通道里最便宜的一个,却最能检验抽象的完备性。设备自带的 Web 聊天页(application/edge_agent/components/http_server/http_server_webim_api.c,WebSocket 长连接)需要一个 IM 入口,做法不是给 Web 开特权通道,而是把它做成"第五个 IM 平台":
入站:浏览器 WS 消息 →
cap_im_local_emit_user_message("web", chat_id, ...)→ 与 Telegram 完全相同的claw_event_router_publish_message("local_gateway", ...),走同一条规则链、进同一个 Agent、建同构的会话;出站:绑定表里
("web", "local_send_message"),路由器照常claw_cap_call("local_send_message", ...)→cap_im_local_send_text_impl()→ 交给注册的出站回调webim_outbound_cb(过滤 channel=="web")→ WS 广播回浏览器。
组件自身注册的仍是标准两件套:local_gateway(EVENT_SOURCE,cap_im_local_start/stop 只翻转一个 started 标志位)与 local_send_message(CALLABLE_BY_LLM,入参 schema 与各平台一致)。消息未显式给出 channel 时回落默认通道 "local",发送者回落 "local_user";入站消息若带附件链接,cap_im_local_emit_user_message() 会把链接列表拼成 Markdown 的 [Links] 尾部随正文一起进事件,保持与远端平台一致的"文本+附件"呈现。
回调注入是这里的关键设计:cap_im_local_set_outbound_callback() 让 cap_im_local 组件不依赖任何 HTTP/WebSocket 头文件——发送方向被反转成了接口。cap_im_local_message_t 还带 link_url/link_label 字段,Agent 回复附带的文件链接以结构化方式呈现给页面。日志里那句 "Web IM outbound -> WebSocket (no server-side history)" 顺带说明了取舍:Web 通道不落服务端历史,历史由第7章的会话记忆体系统一持有。
cap_im_local 同时保留了默认通道 "local":任何进程内代码(CLI、测试)都可以用它模拟一个 IM 用户,为第6章的规则调试提供无网络环境。
7. 媒体消息:附件落盘与事件化
图片、语音、文件的处理横跨四个平台,共享 cap_im_attachment.c 一套工具:cap_im_attachment_build_saved_paths() 生成确定性存储路径(<root>/<platform>/<chat_id>/<message_id>/...,extension 由 MIME/URL/原始文件名三重猜测)、cap_im_attachment_download_url_to_file() 带大小上限下载、cap_im_attachment_build_payload_json() 构造统一负载。应用侧统一配置:存储根目录取 paths->im_attachment_root,入站附件上限 APP_IM_ATTACHMENT_MAX_BYTES(2 MB),默认开启。
以 Telegram 入站图片为例(cap_im_tg_save_attachment 流程):轮询任务发现 photo/file_id 后不阻塞主循环,而是把任务投给独立队列,由 tg_attach 任务(8 KB 栈、优先 PSRAM)执行 getFile 换取 file_path,拼出 https://api.telegram.org/file/bot<token>/<path> 下载落盘,然后构造负载并发布事件:
1 2 3 4 5 6 7 8 9 10
payload_json = cap_im_attachment_build_payload_json(&(cap_im_attachment_payload_config_t) {.platform = "telegram",.attachment_kind = attachment_kind, // photo / voice / document.saved_path = saved_path, // 落盘绝对路径.mime = mime,.caption = caption, // 用户附言.source_key = "platform_file_id", // 平台原始 ID 透传.size_bytes = bytes,});
该事件以 event_type="attachment_saved"、content_type 标注媒体类型进入路由器,规则 im_attachment_saved_reply 立即回一句 "File received from telegram"。真正的消费在下游:负载 JSON 里的 saved_path 让视觉技能(第8章《技能系统》)或 Agent 的多模态工具可以直接读取本地文件——平台差异再次在附件工具层被抹平,事件里只剩统一的"文件已就位"语义。
出站方向,四个平台都提供 send_image/send_file 能力(Telegram 走 sendPhoto/sendDocument 的 multipart 上传,cap_im_tg_send_multipart_file),LLM 可显式调用它们把生成或截取的文件发回聊天。
8. 设计动机总评
回看本章的设计,可以提炼成三条主线:
异构性就地消化:每个平台文件是一堵墙,把轮询/长连接、鉴权、JSON 方言、消息格式差异全部关在墙内;对外只暴露两种同构能力(gateway 事件源、send_* 工具)与两条字符串约定(通道名、能力 id)。新平台接入成本被压到"写一个文件 + 一行绑定"。
位置感由框架携带:chat_id 回退到
ctx->chat_id、run_agent 携带 source/target 通道、会话按 channel+chat 隔离、correlation_id 串联往返——模型和业务规则永远不需要理解"用户在哪",多通道并发自然正确。字符串协议替代编译期耦合:出站绑定表让路由器与 IM 实现彻底解耦,
cap_im_local用同一套抽象接住了 Web 页面,证明这套接口对"远端云平台"与"进程内页面"两类极端都足够通用。
与第10章《MCP通信体系》对照会更清晰:IM 解决"人与设备"的对话入口,MCP 解决"设备与外部工具服务"的能力扩展;两者一个向内收敛为 message 事件,一个向外发散为工具调用,共同围出第4章 Agent 的输入输出边界。而这一切的前提是第3章《启动流程与应用外壳》完成的装配——Kconfig 裁剪、能力组注册、绑定表写入,让同一份固件在"只有 Telegram"与"全家桶"之间自由伸缩。
参考文献
[1] Telegram Bot API:https://core\.telegram\.org/bots/api
[2] QQ 机器人开放平台文档:https://bot\.q\.qq\.com/wiki/
[3] 飞书开放平台事件订阅文档:https://open\.feishu\.cn/document/
[4] 微信开放文档:https://developers\.weixin\.qq\.com/doc/
[5] Espressif esp-websocket-client 组件:https://components\.espressif\.com/components/espressif/esp\_websocket\_client















































































