OpenIM 的 OpenClaw Channel插件架构
一、项目定位与上下文
@openim/openclaw-channel(以下简称”OpenIM Channel插件”)是 OpenClaw Gateway 的一个Channel插件,职责是将 OpenIM 即时通讯协议桥接到 OpenClaw 的 AI Agent 体系中。它让 Agent 能够通过 OpenIM 协议收发消息——包括单聊、群聊、图片、视频、文件——并将入站消息路由到 Agent 进行智能处理,再将 Agent 的回复投递回 OpenIM 会话。
从架构角色上看,这是一个典型的协议适配层(Protocol Adapter),处于 OpenIM SDK 和 OpenClaw Gateway Runtime 之间,向上对接 OpenClaw Gateway 的插件 API,向下封装 @openim/client-sdk 的 WebSocket 长连接和消息收发。下面从整体架构、数据流、关键设计决策、与 Gateway 的集成模式四个维度展开分析。
二、整体架构
2.1 分层结构
这个分层自下而上分为三层,插件内部又分为三个子层:
最底层:OpenIM Server + SDK。OpenIM Server 是 IM 服务端,SDK(@openim/client-sdk)封装了与 Server 的 WebSocket 长连接和 REST API 调用。SDK 是一个哑管道——只管建连、断连、构造消息对象、发送消息、推送收到的消息,不理解任何业务语义。
config(配置层)— 最底层,不依赖任何其他模块。负责从 JSON 配置文件、环境变量、JWT token 中解析并归一化账号配置,为上层提供结构确定的 OpenIMAccountConfig 对象
clients(连接层)— 依赖 config 和 SDK。管理每个账号的 SDK 实例生命周期(创建、登录、事件绑定、登出、清理),对上层暴露 getConnectedClient 来获取可用的 SDK 实例
media(消息层)— 依赖 clients 和 SDK。封装消息构造和发送的细节(文本、图片、视频、文件),处理本地文件读取、URL 推断、MIME 类型猜测等脏活,对上层提供 sendTextToTarget、sendImageToTarget 等简洁接口
在这三个子层之上,是三个面向 OpenClaw Gateway 的接口模块,它们处于同一层级,互不依赖,都通过 media 层间接使用 SDK:
channel(频道出站)— Gateway 框架调用的统一频道接口
tools(Agent 工具)— Agent 自主调用的消息发送工具
inbound(入站处理)— 最复杂的模块,处理从消息接收到 Agent 分发的完整管线
最顶层:OpenClaw Gateway Runtime。提供 Agent 调度、会话路由、消息信封格式化、会话持久化、活动统计等基础设施。插件通过 api.runtime 在入站处理阶段调用这些能力。
依赖方向严格单向:config ← clients ← media ← channel/tools/inbound → Gateway Runtime,没有循环依赖。
2.2 数据流
理解了分层结构之后,来看消息在这些层之间是怎么流动的。出站和入站是两个方向,出站相对简单,入站是核心。
出站路径(Gateway → OpenIM)有三条,虽然最终都通过 media.ts 调用 SDK 发送,但触发方式和能力各不相同:
路径 1:Channel 出站 — Gateway 框架驱动,走统一的频道接口。典型场景是运营人员通过 CLI 发通知:
openclaw send –channel openim –to user:1001 “双十一活动开始了,全场八折”
→ channel.outbound.resolveTarget({ to: “user:1001” })
→ channel.outbound.sendText({ to: “user:1001”, text: “…” })
→ media.sendTextToTarget → SDK 发送
Gateway 不关心底层是 OpenIM 还是 Slack,它只调 sendText,具体怎么发是插件的事。这条路径只支持文本。
路径 2:Tool 出站 — Agent 自主决策,调用注册的工具。典型场景是 Agent 处理完用户问题后主动发送富媒体内容,或者跨会话转发:
→ Agent 调用 openim_send_image({
image: “https://logistics.example.com/screenshot/12345.png”
→ tools.ts execute → media.sendImageToTarget → SDK 发送
Tool 出站可以指定任意 target(不限于当前会话),支持文本、图片、视频、文件四种消息类型。
路径 3:Deliver 回调 — 入站处理自动触发,Agent 的文本回复通过 dispatchReplyWithBufferedBlockDispatcher 的 deliver 回调自动投递回原会话:
→ deliver({ text: “您的包裹已到达北京分拣中心” })
→ inbound.ts sendReplyFromInbound → media.sendTextToTarget → SDK 发送
| 谁触发 |
目标 |
能力 |
典型场景 |
| Channel 出站 |
Gateway/CLI |
指定目标 |
仅文本 |
| Tool 出站 |
Agent 决策 |
任意目标 |
文本/图片/视频/文件 |
| Deliver 回调 |
入站自动触发 |
原会话 |
仅文本 |
入站路径的复杂度远高于出站,这也是整个插件的核心价值所在。下面用一个具体场景走一遍完整调用链,并标注每一步的执行主体(SDK / 插件 / Gateway)。
假设配置了 default 账号(userID: bot001),requireMention: true,inboundWhitelist 为空。用户张三(sendID: user:2001)在群 group:888 里,引用了李四的一条消息,@了机器人,附带一句话和一张图片。
1. [SDK] WebSocket 收到推送,解析为 MessageItem,触发 CbEvents.OnRecvNewMessage
2. [插件 · clients.ts] handler 收到事件,调用 consumeMessage(msg)
→ 转交给 processInboundMessage(api, client, msg)
3. [插件 · inbound.ts] 自身消息过滤
→ msg.sendID (“user:2001”) !== client.config.userID (“bot001”)
4. [插件 · inbound.ts] 去重检查
→ key = “default:msg_abc123″,inboundDedup 里没有
5. [插件 · inbound.ts] 消息体解析 extractInboundBody(msg)
→ 发现 quoteElem → 递归解析李四的原始消息(depth=0 < 2)
→ 解析 atTextElem.text → “@bot001 帮我分析一下这个方案”
→ 解析 pictureElem → { kind: “image”, url: “https://oss.xxx/方案截图.png” }
→ 组装:body = “[Quote] 李四: 这个方案怎么样?\nReply: …\nReply attachment: [Image] …”
→ kind = “mixed”, media = [图片项]
6. [插件 · inbound.ts] 群聊门控
→ isGroupMessage → sessionType === Group ✓
→ 白名单为空 → 走 requireMention 逻辑
→ requireMention: true → 检查 atUserList 是否包含 “bot001” → 包含 ✓
→ runtime.channel.routing.resolveAgentRoute({ sessionKey: “openim:group:888”, … })
→ 返回 { agentId: “main”, sessionKey: “openim:group:888” }
8. [插件 · inbound.ts] 媒体物化(HTTP fetch,不是 SDK 调用)
→ fetch(“https://oss.xxx/方案截图.png”),15 秒超时,20MB 上限
→ 下载成功 → 转 base64 → { type: “image”, data: “iVBORw0KGgo…”, mimeType: “image/png” }
→ 如果下载失败,不中断流程,warning 追加到消息体末尾,Agent 能看到失败信息
→ runtime.channel.reply.formatInboundEnvelope({ channel: “OpenIM”, from: “张三”, … })
→ runtime.channel.session.recordInboundSession({ storePath, sessionKey, ctx })
→ runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx, replyOptions: { images: [base64图片] }
→ Agent 收到文本 + 图片,生成回复:”根据李四的方案,主要有三个优点…”
12. [插件 · inbound.ts] deliver 回调被触发
→ sendReplyFromInbound(client, msg, “根据李四的方案…”)
→ target = { kind: “group”, id: “888” }
13. [插件 · media.ts + SDK] 构造并发送消息
→ sdk.createTextMessage(“根据李四的方案,主要有三个优点…”)
→ sdk.sendMessage({ recvID: “”, groupID: “888”, message })
14. [SDK] 通过 WebSocket 将消息发送到 OpenIM Server
从执行主体的分布可以看出:步骤 1 和 13-14 是 OpenIM SDK 在干活,7/9/10/11 是 OpenClaw Gateway 在干活,剩下的 2/3/4/5/6/8/12 全是插件的逻辑。插件承担了入站处理的大部分工作——这正是这个项目作为协议适配层的核心价值。
三、关键设计决策分析
上面的数据流展示了消息”怎么走”,这一章深入分析关键环节”为什么这么设计”。
3.1 多账号并发模型
插件通过 Map 管理多个 SDK 实例,每个账号独立登录、独立监听事件。getConnectedClient 的查找策略是:指定 accountId → “default” → 第一个可用实例。
这个设计的优点是简单直接,每个账号完全隔离。但值得注意的是,getSDK() 在 @openim/client-sdk 中每次调用都返回新实例(从代码推断),这意味着多账号场景下会有多条 WebSocket 长连接。对于 10 个以内的账号这完全没问题,但如果未来需要支持大规模账号(比如客服场景的几百个坐席),可能需要考虑连接池或者服务端代理模式。
3.2 入站消息的防御性设计
入站调用链(2.2 节步骤 3-6、8)中的多个环节都体现了防御性编程思维:
去重机制:基于 clientMsgID/serverMsgID 的内存去重 Map,5 分钟 TTL,2000 条上限后触发清理。这是因为 OpenIM SDK 可能通过 OnRecvNewMessage、OnRecvNewMessages、OnRecvOfflineNewMessages 三个事件重复投递同一条消息。去重窗口选择 5 分钟是合理的——足够覆盖网络抖动和离线消息同步场景,又不会占用过多内存。
自身消息过滤:msg.sendID === client.config.userID 的检查放在最前面,避免 Agent 回复自己的消息形成死循环。这是 IM 机器人开发中的经典陷阱。
引用消息递归解析:extractInboundBody 支持递归解析 quoteElem,但设置了 depth < 2 的深度限制。这防止了恶意构造的深层嵌套引用导致栈溢出,同时保留了足够的上下文信息供 Agent 理解对话。
媒体获取的容错:materializeInboundMedia 对每个媒体项独立 try-catch,单个图片获取失败不影响其他媒体和文本内容的处理。失败信息以 warning 形式附加到消息体中,确保 Agent 至少能看到”有一张图片但获取失败了”这个事实。
图片大小限制:20MB 上限 + 15 秒超时 + AbortController,三重保护防止大文件或慢速下载阻塞入站处理。而且做了两次大小检查——先检查 Content-Length header(快速拒绝),再检查实际 buffer 大小(防止 header 不准确)。
3.3 群聊安全策略
入站调用链步骤 6 的群聊门控,由 inboundWhitelist 和 requireMention 两个配置字段组合形成决策树:
│ ├─ 是 → 群聊必须 @机器人(无论 requireMention 设置如何)
└─ 否 → requireMention 开启?
还有一个容易忽略的点:单聊完全不受 requireMention 影响。白名单对单聊的过滤在更前面——白名单非空时,不在白名单里的用户发的单聊消息同样会被丢弃。
3.4 配置归一化与三级回退
accounts. 配置 → 顶层 channel 配置(单账号兼容) → 环境变量
加上 JWT token 自动解析 userID 和 platformID,用户最少只需要提供 token、wsAddr、apiAddr 三个字段就能跑起来。decodeJwtPayload 自己实现了 Base64URL 解码而不依赖第三方 JWT 库,保持了零额外依赖的原则。
无论配置来自哪个层级,normalizeAccount 都会将其归一化为结构确定的 OpenIMAccountConfig 对象——处理类型转换(环境变量的字符串转数字)、默认值填充(enabled 默认 true、platformID 默认 5)、JWT claims 推导(从 token 中提取 userID/platformID)、白名单去重等脏活。归一化之后,下游模块拿到的永远是类型安全、字段完整的配置对象,不需要再做任何防御性检查。
SDK 连接(5 个字段):userID、token、wsAddr、apiAddr、platformID 是 OpenIM SDK 登录的必要参数。其中三个核心字段各管一件事:
token — 身份凭证,OpenIM Server 签发的 JWT 字符串,登录时证明”我是这个用户”。它内部的 claims 还藏着 UserID 和 PlatformID,所以插件能从 token 自动推导这两个值,用户不用重复填
wsAddr — WebSocket 地址(如 ws://127.0.0.1:10001),SDK 与 Server 之间的长连接通道,用于实时收发消息。登录后 SDK 保持这条连接,新消息通过它推送过来,发消息也走这条连接
apiAddr — REST API 地址(如 http://127.0.0.1:10002),SDK 调用 createTextMessage、createImageMessageByURL 等方法时请求这个地址来构造消息对象,处理创建消息、上传文件等非实时操作
userID 和 platformID 通常从 token 自动推导,也可以显式覆盖。三个核心字段缺一不可——没有 token 登不上,没有 wsAddr 收不到消息,没有 apiAddr 构造不了消息对象。这也是 normalizeAccount 中检查这三个字段任一为空就返回 null(配置无效)的原因
账号管理(2 个字段):accountId 是多账号场景下的唯一标识,Agent 调用工具时通过它选择发送账号;enabled 控制账号启停,服务启动时跳过禁用的账号
入站安全策略(2 个字段):requireMention 控制群聊是否必须 @机器人 才触发处理;inboundWhitelist 限制只处理特定用户的消息。这两个字段的组合逻辑详见 3.3 节
此外,userID 在入站处理中还承担了双重角色——既用于过滤自身消息(防止 Agent 回复自己形成死循环),也用于判断群聊中是否被 @提及。
3.5 视频走文件通道
出站路径中还有一个值得注意的产品决策:sendVideoToTarget 直接委托给 sendFileToTarget,注释写明了这是产品策略而非技术限制。OpenIM 的视频消息在不同客户端上的渲染行为不一致,而文件消息的兼容性更好。代码中用注释明确标注了这个决策的原因,方便后续维护者理解。
四、与 OpenClaw Gateway 的集成模式
前面的分析聚焦于插件内部的设计,这一章转向插件与 OpenClaw Gateway 之间的集成方式——插件如何注册自己的能力,以及运行时如何依赖 Gateway 的基础设施。
4.1 四个注册扩展点
四个注册扩展点都在 index.ts 的 register(api) 函数里,Gateway 启动时调用这个函数,插件通过 api.registerXxx 把自己的能力挂上去。
1. api.registerChannel — 频道注册
告诉 Gateway:”我是一个叫 openim 的消息频道,支持单聊和群聊”。注册之后,用户执行 openclaw send –channel openim –to user:123 “你好” 时,Gateway 会找到这个插件,调用它的 outbound.sendText 发消息。类似于在外卖平台注册了一个配送渠道——平台知道有你,有订单就派给你。
2. api.registerCli — CLI 命令注册
给 Gateway 的命令行工具加一个子命令 openclaw openim setup,启动交互式配置向导,一步步填 token、wsAddr、apiAddr,自动写入配置文件。setup 模块用了动态 import(),运行时不需要 TUI 时不会加载 @clack/prompts。
3. api.registerTool — Agent 工具注册
给 AI Agent 注册可调用的工具。注册了 4 个:openim_send_text、openim_send_image、openim_send_video、openim_send_file。每个工具声明了 name、description、parameters(JSON Schema),Agent 在对话中自主决定什么时候调、传什么参数——跟 ChatGPT 的 function calling 是一个概念。比如 Agent 判断需要给用户发一张物流截图,就会调用 openim_send_image({ target: “user:1001”, image: “https://…” })。
4. api.registerService — 后台服务注册
注册一个有生命周期的后台服务 openim-sdk,Gateway 负责在合适的时机调用 start 和 stop。start 时读取所有启用的账号配置,逐个登录 SDK、建立 WebSocket 连接、绑定消息监听;stop 时解绑事件、登出、断开连接。这是整个插件的运行时核心——没有这个服务,前面三个注册都是空壳:Channel 出站找不到 SDK 实例,Tool 调用拿不到连接,入站消息根本收不到。
Service(地基)— 管理 SDK 连接,没有它其他三个都不工作
Channel(被动出站)— Gateway 框架调用,走统一频道接口
Tool(主动出站)— Agent 自主调用,能力更丰富
CLI(配置入口)— 用户交互,生成配置文件供 Service 启动时读取
4.2 运行时 API 依赖
注册阶段只是把能力挂到 Gateway 上,还没有真正处理消息。运行时依赖是插件在实际处理消息的过程中,有些环节自己做不了,必须调用 Gateway 提供的能力才能往下走。
结合 2.2 节的入站调用链来看,14 个步骤中哪些插件自己搞定,哪些必须找 Gateway:
→ SDK 收消息、自身过滤、去重、消息解析、群聊门控
→ 这些逻辑全在插件代码里,不需要 Gateway 参与
步骤 7:必须找 OpenClaw Gateway — 会话路由
→ 插件不知道系统里有几个 Agent、路由规则是什么
→ 调用 runtime.channel.routing.resolveAgentRoute()
→ Gateway 返回:交给 main Agent,sessionKey 是 “openim:group:888”
→ 下载图片、转 base64,纯 HTTP 操作
步骤 9:必须找 OpenClaw Gateway — 信封格式化
→ 不同部署可能有不同的格式偏好(要不要时间戳、发送者格式等)
→ 调用 runtime.channel.reply.formatInboundEnvelope()
步骤 10:必须找 OpenClaw Gateway — 会话持久化
→ 调用 runtime.channel.session.recordInboundSession()
步骤 11:必须找 OpenClaw Gateway — Agent 分发(最核心的依赖)
→ “把消息交给 Agent 处理,处理完了帮我把回复发出去”
→ Agent 调度、推理、回复生成全在 Gateway 里
→ 调用 runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher()
→ Agent 生成回复后触发 deliver 回调,发送逻辑又回到插件手里
→ deliver 回调 → media.sendTextToTarget → SDK 发送
这些能力是 OpenClaw Gateway 作为宿主平台提供的基础设施,插件作为协议适配层不应该也不可能自己实现。
综上,OpenIM Channel插件通过清晰的分层架构、防御性设计及与OpenClaw Gateway的解耦,高效实现了IM与AI Agent体系的融合,是一个稳定、高可参考性的协议适配层实现。