Skip to content

Feat/multi channel im - #892

Open
Neilcc wants to merge 5 commits into
deepcoldy:masterfrom
Neilcc:feat/multi-channel-im
Open

Feat/multi channel im#892
Neilcc wants to merge 5 commits into
deepcoldy:masterfrom
Neilcc:feat/multi-channel-im

Conversation

@Neilcc

@Neilcc Neilcc commented Aug 16, 2026

Copy link
Copy Markdown

No description provided.

Neilcc and others added 5 commits August 10, 2026 11:01
梳理微信 clawbot(=openclaw 微信通道)接入 botmux 的完整方案:
现状盘点、openclaw 机制、botmux 通道接线点调研、推荐架构(方案A)、
分阶段实施步骤、备选项与风险。app_id 等敏感信息已脱敏。

Co-Authored-By: Claude <noreply@anthropic.com>
- src/im/weixin/:微信 Clawbot 通道(client/adapter/index),镜像
  openclaw-weixin 协议长轮询 getUpdates + sendmessage,context_token 按用户
  缓存落盘;ImAdapter 实现,复用 Telegram 骨架,无卡片文本渲染。
- BotConfig 新增 channel?: 'lark'|'weixin'(缺省 lark,飞书主路径字节不变)。
- Telegram 适配器:修正 StreamStatus 导入路径、senderId 类型转换、状态徽标
  用合法 StreamStatus 值,使 pnpm build 恢复绿。
- .gitignore 加固:忽略 bots.json/token/secret 等敏感文件,防 fork 公开仓泄漏。

微信凭证 token 运行时从 openclaw 账户文件读取,不入库。daemon 会话接线为后续增量。
上游已内置 Telegram/Weixin 两个 ImAdapter(src/im/telegram、src/im/weixin),
但 daemon 消息路由仍走飞书硬编码路径,channel 字段此前只是配置面。本次让
channel 字段真正可路由:

- bot-registry: channel 联合类型由 'lark'|'weixin' 扩展为含 'telegram',
  新增 telegramBotToken / wechatClawbotId 两个通道凭证字段
- 新增 src/im/channel-factory.ts:按 channel 构造对应 ImAdapter(telegram
  用 token,weixin 从 openclaw 账户文件读凭证),lark 返回 undefined 保持
  现有飞书路径字节不变
- 新增 telegram adapter 单测 test/telegram-adapter.test.ts(mock Bot API,
  覆盖 getMe/派发/sendMessage/updateMessage/addReaction,4 用例全绿)
- bots.json.example 演示 telegram/weixin 配置示例

影响面:仅新增配置字段与接线层,未触碰 daemon 现有飞书路径(回归面最小)。
验证:pnpm build 通过;telegram 单测 4/4;完整单测 14342 用例中 20 个失败
均为上游环境相关用例(终端宽度渲染/PID 命名空间/worker 时序),与本改动无关。
承接 f21f583 的 channel-factory 接线层,本次让 weixin/telegram bot
真正跑通「入站 -> 会话/worker -> 出站」全链路:

- 新增 src/im/channel-registry.ts:按 larkAppId 保存非飞书 bot 的
  ImAdapter,作为出站路由数据源
- 新增 src/im/channel-bridge.ts:把 ImMessage 合成最小飞书事件 data +
  RoutingContext 灌进既有 botEventHandlers(复用全部会话/worker/CLI
  逻辑);按 isSessionOwner 决定续话 vs 新话题;卡片 JSON -> 纯文本
- src/im/lark/client.ts:sendMessage/replyMessage/updateMessage/
  addReaction/removeReaction/deleteMessage 顶部加通道分支——注册了
  非飞书 adapter 的 bot 路由到 adapter,否则走原飞书路径(字节不变)
- src/daemon.ts:按 isLarkChannel 跳过非飞书 bot 的飞书专属启动步骤
  (open_id 探测 / scope 校验 / 改名改头像 / 文档订阅 / WSClient 订阅),
  改由 createChannelAdapter + adapter.start() 启动对应通道
- 新增单测 test/channel-bridge.test.ts(7 用例全绿);tsc --noEmit 绿
- docs:微信 clawbot 方案状态更新为 Track 2 接线完成(v0.3)

影响面:飞书 bot 从不注册 adapter,所有通道分支对其不可达,回归面最小。

Co-Authored-By: Claude <noreply@anthropic.com>
@Neilcc
Neilcc requested a review from deepcoldy as a code owner August 16, 2026 07:15

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7ee38f7c00

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/bot-registry.ts
* 微信凭证长轮询,消息进 botmux 会话机制。当前 `'weixin'`/`'telegram'` 通道的
* 完整 daemon 会话接线仍在推进:本字段是配置面,缺省不改变任何现行为。
*/
channel?: 'lark' | 'telegram' | 'weixin';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the channel fields while normalizing bot configs

loadBotConfigs constructs each BotConfig explicitly around line 2664 but never copies channel, telegramBotToken, or wechatClawbotId. Consequently every entry loaded from bots.json reaches the daemon with channel === undefined, is treated as Lark, and never starts either new adapter; copy and validate all three fields during normalization.

AGENTS.md reference: AGENTS.md:L52-L59

Useful? React with 👍 / 👎.

Comment thread bots.json.example
"allowedUsers": ["erin@company.com"],
"channel": "telegram",
"larkAppId": "tg_my_bot1",
"larkAppSecret": "",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exempt non-Lark bots from the Lark credential lifecycle

The documented Telegram/Weixin entries use an empty larkAppSecret, but loadBotConfigs rejects every non-apiOnly empty secret at lines 2403-2408. Even after relaxing that validation, registerBot(cfg) runs before the new channel check and constructs a Lark SDK client for every non-apiOnly bot, which requires a secret. Treat non-Lark channels like transport-disabled bots in both validation and client construction so these examples can boot.

AGENTS.md reference: AGENTS.md:L52-L59

Useful? React with 👍 / 👎.

Comment on lines +117 to +120
const [chatId, msgId] = this.splitMessageKey(messageId);
const msg = await this.client.sendMessage(Number(chatId), content, {
parseMode: format === 'rich' ? 'MarkdownV2' : 'Markdown',
replyToMessageId: Number(msgId),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Accept Telegram thread keys when replying

Every Telegram inbound is bridged as a thread-scoped session anchored at msg.threadId, such as tg:888, so normal session output calls this method with that value. splitMessageKey('tg:888') produces ['tg', '888'], and Number(chatId) becomes NaN; the Bot API therefore receives an invalid chat ID and the first reply/card cannot be delivered. Parse tg:<chatId>[:<threadId>] anchors separately or carry a distinct reply target.

AGENTS.md reference: AGENTS.md:L54-L58

Useful? React with 👍 / 👎.

parseMode: format === 'rich' ? 'MarkdownV2' : 'Markdown',
replyToMessageId: tgThread,
});
return String(msg.message_id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return a routable Telegram message key

sendMessage returns only Telegram's message ID, while updateMessage, addReaction, and removeReaction require the composite <chatId>:<messageId> key. Any non-thread outbound path that persists this result—such as a scheduled/dashboard message or card—later interprets the message ID as both chat and message ID and edits/reacts in the wrong chat; return this.messageKey(msg.chat.id, msg.message_id) consistently.

Useful? React with 👍 / 👎.

Comment thread src/daemon.ts
}
registerChannelAdapter(cfg.larkAppId, adapter);
logger.info(`[channel] ${cfg.larkAppId} 启动 ${cfg.channel} 通道(account=${adapter.getBotUserId() ?? '-'})`);
void adapter.start(buildChannelImEventHandler(botEventHandlers, cfg.larkAppId));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Observe adapter startup failures

For Telegram, adapter.start() awaits getMe() before polling, but this promise is discarded. With an invalid token or a transient startup network failure, the rejection is unhandled after the adapter has already been registered and logged as started, potentially terminating the daemon or leaving all outbound traffic routed to a channel that never polls; await or attach a rejection handler and only advertise/register a successfully started adapter.

Useful? React with 👍 / 👎.

Comment thread bots.json.example
"larkAppSecret": "",
"wechatClawbotId": "wxid_xxxx",
"cliId": "codex",
"allowedUsers": ["zhuchengcheng@bytedance.com"],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Replace the real user identity in the public example

This example embeds a company email that appears to identify a real person, despite the repository rule prohibiting real group-member identities from entering public git history. Replace it with a neutral placeholder such as weixin-user-id@example.com.

AGENTS.md reference: AGENTS.md:L67-L67

Useful? React with 👍 / 👎.

@deepcoldy

Copy link
Copy Markdown
Owner

感谢这个 PR——多通道(微信/Telegram)接入的整体架构是清晰的:channel-registry / factory / bridge 把非飞书通道桥接进现有会话逻辑,通过 channel 分支尽量保持既有飞书行为,本次 build 与所跑回归测试均绿,新增单测也都通过。以下是一份自动评审的初步意见,最终以维护者审阅为准;只陈述技术事实与修改建议。合并前建议先处理以下几点(按严重度)。

需要说明的是:新增的 11 个单测只覆盖 adapter 单体,没有覆盖 bridge → daemon → adapter 的真实回路,所以下面几个阻断点在“测试全绿”的情况下仍然存在——建议补跨层集成测试。

阻断级

1. Telegram 正常会话回复必失败(chat_id=NaN)
channel-bridge.ts 把会话 anchor 固定为 tg:<chatId>[:threadId];thread-scope 的 sessionReply 会把该 anchor 交给 TelegramAdapter.replyMessage,而后者的 splitMessageKey 只接受 <chatId>:<messageId> 格式(adapter.ts:117-123 / 238-258)。实跑 splitMessageKey('tg:888:12') 得到 ['tg','888:12'],两个 Number(...) 都是 NaN,最终 chat_id 非法。thread-scope 会话的每条回复都会在这里挂掉。建议统一 threadKey / messageKey / replyMessage 三者的 id 约定,并补真正的 bridge→daemon reply 集成测试。

2. 微信白名单身份空间冲突(配了必锁死 / 不配则放开)
上游 openclaw-weixin 明确微信 user id 恒以 @im.wechat 结尾(src/channel.tstargetResolverhttps://github.com/Tencent/openclaw-weixin/blob/main/src/channel.ts#L190-L194)。而 daemon 侧 allowedUsers 会经 entryNeedsContactResolve 判定(含 @ → 当邮箱)走飞书 contact 解析成 ou_ 或丢弃,再由 evaluateTalk原始 senderresolvedAllowedUsers 比对。结果:配了微信 allowedUsers → 解析失败被拒(锁死);不配 → 落 open 模式,对所有能触达该 bot 的发送者开放。中间没有能用的配法,example 里的企业邮箱同样不可用。建议给非飞书通道引入 channel-specific 身份模型(按原始 channel id 直配直比),不要走飞书 contact 解析。

3. Telegram 群聊入口绕过权限/寻址层
真实飞书路径在 handler 上游有“是否 @ 到本 bot / group access / 路由”的门(event-dispatchercheckGroupMessageAccess);bridge 直调 handleNewTopic/handleThreadReply 绕过了它,而 Telegram adapter 又不解析 bot mention。下游 enforceMessageQuotaForCliInput 只做身份授权(evaluateTalk),补不上“是否找本 bot”。于是任意被 Telegram Bot API 投递到 adapter 的群消息都会启动/续写 AI 会话(默认 privacy mode 会限制投递,但一旦放开或加了权限就会全量触发)。建议把跨通道入口收敛到等价的 channel-neutral admission/routing 层,并补群聊 non-mention 用例。

应修

4. daemon 启动期仍残留飞书依赖(不止一行)
daemon.ts 约 20669 行的 allowedUsers 解析只判了 !apiOnly,没和相邻 5 处一样加 isLarkChannel gate,导致非飞书 bot 启动仍会打飞书 contact API。此外 handler 内的 resolveSender / getAvailableBots 等仍是 Lark 专属查询;registerBot 也仍会给 channel bot 建 Lark client。“非飞书通道跳过所有飞书专属步骤”的设计目前不成立,建议做一次系统性的 non-Lark runtime API audit,而非只补一行。

5. 微信 context_token / sync cursor 未持久化,重启后连续性断档
channel-factory 构造 WeixinAdapter 时没有传 contextTokenFile,所以 restoreContextTokens / persistContextTokens 两支实际不可达;get_updates_buf 也只在内存。结果:重启后、在该用户下一条入站把 token 补回来之前,恢复中的/延迟出站无法携带 context_token(与 commit 描述的“缓存落盘”不符)。上游实现会持久化 *.sync.json。建议接入 per-account 的 context-token 与 sync cursor 文件,并补 daemon restart 的恢复/去重测试。

6. Telegram 出站契约还不闭环
sendMessage 返回裸 message id,replyMessage 返回 <chatId>:<messageId>,两者 key 格式不一致——后续按返回值做 edit/reaction 时会把 message id 当 chat id。另外 lark/client.tsupdateMessage 没有像 send/reply 那样先做 extractChannelCardText,会把整段 Lark card JSON 直接交给 Telegram。还有:所有 text 都强制 parse_mode=Markdown,没有转义、纯文本 fallback,也没有 4096 长度分片——即使修好 anchor(第 1 点),真实 CLI 回复里常见的不配对 Markdown 仍会 400。建议统一复合 message key,并在 Telegram transport 层完成转义 / 降级 / 分片。

清理级(可作为附带修改)

7. 仓库卫生

  • .gitignore 的全局 *token* / *secret* / credential* / token* 通配过宽,会静默忽略未来新建的同类源码/测试/fixture(现有已跟踪文件不受影响;git check-ignore 可见如 src/utils/user-token.tssrc/core/dashboard-secret.tsdocs/setup/credentials.png 都会命中)。建议收窄到具体密钥文件名/目录。
  • 方案文档与 example 含单机部署状态、绝对备份路径(/root/.botmux/...bak)和疑似真实企业邮箱,建议改成中性占位符并移除环境流水账,避免进入公开历史。

另外,分支目前与 master 有冲突(落后较多 commit),建议先 rebase、补齐跨层 e2e 后再重新请求评审。以上仅为自动化的初步意见,欢迎讨论,最终以维护者审阅为准。再次感谢贡献 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants