From d3130aedd9a9f46eef7f662350bda60e3f80b9d1 Mon Sep 17 00:00:00 2001 From: Neilcc Date: Mon, 10 Aug 2026 00:09:08 +0800 Subject: [PATCH 1/5] =?UTF-8?q?docs(wechat):=20=E5=BE=AE=E4=BF=A1=20clawbo?= =?UTF-8?q?t=20=E6=8E=A5=E5=85=A5=20botmux=20=E6=96=B9=E6=A1=88=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 梳理微信 clawbot(=openclaw 微信通道)接入 botmux 的完整方案: 现状盘点、openclaw 机制、botmux 通道接线点调研、推荐架构(方案A)、 分阶段实施步骤、备选项与风险。app_id 等敏感信息已脱敏。 Co-Authored-By: Claude --- docs/wechat-clawbot-plan.md | 170 ++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 docs/wechat-clawbot-plan.md diff --git a/docs/wechat-clawbot-plan.md b/docs/wechat-clawbot-plan.md new file mode 100644 index 000000000..b3f7f5237 --- /dev/null +++ b/docs/wechat-clawbot-plan.md @@ -0,0 +1,170 @@ +# 微信 Clawbot 接入 botmux 方案文档 + +> 状态:**方案梳理中(v0.1)** +> 日期:2026-08-09 +> 目标:让「微信 Clawbot」的对话能控制本服务器(走 botmux → Claude Code / Codex 会话) +> 参考:[微信 clawbot 实测文章](https://mp.weixin.qq.com/s/1_8gWO-lo-BqsuKbGCwxfg) + +--- + +## 0. 现状盘点(2026-08-09,服务器刚重启后) + +### 已修复 / 已确认 +| 项 | 状态 | +|---|---| +| botmux 服务 | ✅ 已用 `systemctl --user restart botmux` 重启;用户级 `botmux.service` 开机自启 | +| 飞书 claude 机器人(app_id 已脱敏) | ✅ 健康,`all critical scopes granted` | +| 飞书 codex 机器人(app_id 已脱敏) | ➖ **已从 bots.json 移除**(报 `app unauthorized 10014`,凭证问题;且计划让它转接微信) | +| claude CLI | ✅ 2.1.226,已 `ln -s` 到 `/usr/local/bin/claude`,重启不再消失 | +| codex CLI | ✅ 0.147.0,`/usr/local/bin/codex` | +| tg-bridge(Telegram 网关) | ✅ 在跑,token 有效(bot `Zccfinbot`),能调 claude `-p` | +| botmux Telegram 适配器 | ⏳ 代码在 `botmux-fork/src/im/telegram/`,**未接线进 daemon** | + +### 备份 +- `/root/.botmux/bots.json.bak.1786290378` +- `/root/.botmux/ecosystem.config.json.bak.1786290378` + +--- + +## 1. 名词澄清 + +- **Clawbot**:微信官方近期上线的能力,把**本地 openclaw** 通过官方接口接入微信(安卓/iOS 已支持,微信需 **8.0.70**,个别机型除外)。 +- **openclaw**:一个「多通道 AI 网关」(`npm i -g openclaw`,2026.7.x),支持 WhatsApp/Telegram/WeChat/Feishu/QQ/… 等通道,把各通道消息路由到本地 Agent。 +- **openclaw-weixin 插件**:负责微信通道的插件(`@tencent-weixin/openclaw-weixin`,按 dist-tag 分发,靠 `openclaw plugins install` 拉取)。 +- **botmux**:本项目,飞书话题群 ↔ AI 编程 CLI 桥接,负责**会话管理 / PTY / CLI 适配 / 卡片**。 + +> 关键机制:微信 clawbot 的接入不是「微信 Bot API」,而是 **openclaw 的微信通道**。所以微信必然经过 openclaw。我们要做的,是让 openclaw 收到的微信消息路由到 botmux。 + +--- + +## 2. 接入现状研究(openclaw 怎么装、怎么接微信) + +官方接入命令(mac 直接可用;**服务器/Windows 需先装好 Claude Code 或 codex 来辅助**): + +```bash +npm install -g openclaw # 1. 装 openclaw 网关 +npx -y @tencent-weixin/openclaw-weixin-cli install # 2. 装微信插件 + 引导 +``` + +`install` 自动完成: +1. 检测 `openclaw --version`,按兼容矩阵选插件 dist-tag +2. `openclaw plugins install "@tencent-weixin/openclaw-weixin@"` +3. `openclaw channels login --channel openclaw-weixin` → **弹出二维码,微信扫码绑定** +4. `openclaw gateway restart` + +openclaw 的 agent runtime 层(`docs/concepts/agent-runtimes.md`): +- 内嵌 harness:`openclaw`(默认)、`codex`、`copilot`(插件) +- **CLI 后端**:模型 ref 配 `agentRuntime.id: "claude-cli"` → 走本机 Claude CLI 执行 +- 外部 harness(Claude Code / Gemini / OpenCode / Cursor)走 **ACP/acpx** + +--- + +## 3. botmux 通道接线点(架构调研结论) + +> 调研基于 `botmux-fork` 源码。**重要**:`ImAdapter` 接口是「为未来预留」的,**Lark 主路径根本没走它**。 + +### 3.1 真实结构(不是 ImAdapter) +- **出站**:`src/im/lark/client.ts` 的自由函数(绕开 ImAdapter),第一个参数都是 `larkAppId` → `getBotClient(larkAppId)`(`bot-registry.ts:1830`)。 +- **入站**:`src/daemon.ts:20682` 组装的 `botEventHandlers`(类型 `EventHandlers`,`event-dispatcher.ts:2022`),由 `startLarkEventDispatcher`(`event-dispatcher.ts:2634`,内部 `Lark.WSClient` 长连接)喂 WS 事件。 +- **接线分支**:`daemon.ts:20715` 的 `if (!cfg.apiOnly) startLarkEventDispatcher(...)`。 +- **每 bot 一个 daemon 进程**(PM2 按 `BOTMUX_BOT_INDEX` 取 bots.json 对应项)。 + +### 3.2 加通道最重要的阻抗点 +`EventHandlers` 的签名是 `(data: any, ctx: RoutingContext)`,而预留的 `ImAdapter`/`ImEventHandler` 是 `(msg: ImMessage)` / `(action: ImCardAction)`——两者**不一致**。所以无论 Telegram 还是微信,都要写一层**适配**,把新通道的 `ImMessage` 转成 Lark 的 `data/ctx`,才能灌进 `botEventHandlers`。 + +### 3.3 三种可选改造路径 +1. **硬接 `botEventHandlers`**(推荐,改动小):在 `daemon.ts:20715` 按 `cfg.channel` 分派,新通道适配器把消息转 `data/ctx` 后直接喂既有 handler。复用全部会话/worker/CLI 逻辑。 +2. **让新通道完整实现 `ImAdapter`**(Telegram 适配器现状):需把 `botEventHandlers` 也适配成 `ImEventHandler`,改动大、且 Lark 出站是自由函数,两套体系并存,维护成本高。 +3. **完全重构为多通道抽象**:动 `Lark` 主路径,风险最大,不推荐现在做。 + +### 3.4 Telegram 适配器现状(`src/im/telegram/`) +- `adapter.ts`(289 行):`TelegramAdapter implements ImAdapter` + `TelegramCardBuilder implements ImCardBuilder`,**两接口方法基本实现完整**。 +- `client.ts`:基于 fetch 的 `TelegramClient`,长轮询(空闲 30s 超时)。 +- `index.ts`:re-export。 +- **未接线**:全仓库除 `telegram/` 自身与 test 外零 import。是现成的**轮询型通道模板**。 + +--- + +## 4. 推荐架构 + +``` +手机微信(clawbot,微信 8.0.70) + │ 官方接口 + ▼ +openclaw 网关(微信通道插件 openclaw-weixin) + │ 微信扫码绑定后,微信消息进入 openclaw + ▼ +[ 桥接层:openclaw → botmux ] + │ 把 openclaw 收到的微信消息转成 botmux 的 BotMessage + ▼ +botmux(botEventHandlers → 会话管理 → worker → CLI 适配器) + │ 按该 bot 的 cliId 启动 Claude Code / Codex + ▼ +受控的服务器 / 代码执行 +``` + +两条物理落地方案: + +### 方案 A(推荐):botmux 内建「openclaw 微信转发」通道 +- botmux 新增 `channel: 'openclaw-weixin'` 的 bot。 +- botmux 以**长轮询/HTTP**方式从 openclaw 拉微信消息(或 openclaw 推 webhook 给 botmux)。 +- 复用 Telegram 适配器的**轮询骨架**,把 `TelegramClient` 换成「openclaw 微信拉取/推送客户端」。 +- **优点**:微信会话直接进 botmux 既有会话/终端/CLI 机制;与 Telegram 一条代码路径。 +- **待确认**:openclaw 是否暴露「外部拉取微信消息」的接口(大概率需看 openclaw 的 channel/plugin SDK,或为 openclaw 写一个「转发到 botmux」的自定义 agent 后端)。 + +### 方案 B:openclaw 作前端,agent 后端指向 botmux +- openclaw 跑微信通道;把 openclaw 的 agent 配置成「调用 botmux」。 +- openclaw 有 CLI 后端 / ACP 外部 harness 机制,可写一个最小 harness 把对话转发给 botmux。 +- **优点**:openclaw 自己管微信心跳/重连/多账号。 +- **缺点**:多一层,且 openclaw 的 harness 开发曲线不低。 + +> 本方案文档当前推荐 **方案 A**,因为它最贴合 botmux 现有「bot + 通道 + CLI」模型,且与正在做的 Telegram 适配器共用骨架。方案 B 作为备选。 + +--- + +## 5. 实施步骤(供后续执行) + +### Phase 0 — 环境准备(本机) +- [ ] `npm install -g openclaw`(约 88MB) +- [ ] 确认 `openclaw --version` +- [ ] 确认微信手机端 **≥ 8.0.70** + +### Phase 1 — 微信绑定(**需用户手机扫码,手动**) +- [ ] `npx -y @tencent-weixin/openclaw-weixin-cli install` +- [ ] 终端弹二维码 → 用户用微信扫码绑定 +- [ ] `openclaw channels login --channel openclaw-weixin` 确认 +- [ ] `openclaw gateway restart` +- [ ] 验证:微信给 clawbot 发消息,openclaw 能收到 + +### Phase 2 — 桥接层 +- [ ] 调研 openclaw 的 channel/plugin SDK,确认「外部读取微信消息」或「转发给任意后端」的接口 +- [ ] 实现 openclaw → botmux 的转发(HTTP/WS/webhook) +- [ ] 或实现 botmux 侧的「openclaw 微信拉取客户端」 + +### Phase 3 — botmux 微信通道 +- [ ] `BotConfig` 加字段 `channel?: 'lark'|'telegram'|'openclaw-weixin'` +- [ ] `daemon.ts:20715` 按 `cfg.channel` 分派启动对应通道 +- [ ] 写适配层:新通道 `ImMessage` → `botEventHandlers` 的 `data/ctx` +- [ ] bots.json 加一个 `channel: 'openclaw-weixin'`、`cliId: 'codex'`(用户要用 codex 接微信)的 bot +- [ ] 复用 Telegram 的 `TelegramCardBuilder` 思路做「无卡片文本渲染」 + +### Phase 4 — 验证 +- [ ] 微信发消息 → 收到 codex/claude 会话回复 +- [ ] 断网/重连/重启,会话可恢复 +- [ ] 与飞书、Telegram 并存不互相影响 + +--- + +## 6. 备选/待确认项 +- [ ] **openclaw 是否暴露可编程的微信消息接口**(最关键依赖,需先确认才能定方案 A 的具体实现) +- [ ] 微信 clawbot 是否只支持**个人微信**(企业微信/群聊支持度待验证) +- [ ] 语音/图片/文件:微信 clawbot 支持读图片、收语音、发电脑文件,需在适配器里映射 `ImAttachment` +- [ ] 是否要一个独立系统服务(`openclaw.service`)托管 openclaw,开机自启(仿照 `tg-bridge.service`) + +--- + +## 7. 风险 +- openclaw 是第三方大项目(88MB),依赖面广,升级可能有破坏性变更。 +- 微信官方 clawbot 是最新能力,接口/protocol 可能快速变化。 +- 微信风控:个人微信自动化存在封号风险,绑定用主号需谨慎。 +- botmux 加通道的核心阻抗在 `EventHandlers` 签名适配,需仔细测试不破坏飞书主路径。 \ No newline at end of file From 21868f40c33b1dd499f6bc5d096f9e5cc618d5e3 Mon Sep 17 00:00:00 2001 From: Neilcc Date: Mon, 10 Aug 2026 00:58:35 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(im):=20=E6=96=B0=E5=A2=9E=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=20clawbot=20=E9=80=9A=E9=81=93=E9=80=82=E9=85=8D?= =?UTF-8?q?=E5=99=A8=20+=20=E4=BF=AE=E5=A4=8D=20Telegram=20=E9=80=82?= =?UTF-8?q?=E9=85=8D=E5=99=A8=E7=BC=96=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 会话接线为后续增量。 --- .gitignore | 64 +++++++ src/bot-registry.ts | 7 + src/im/telegram/adapter.ts | 338 +++++++++++++++++++++++++++++++++++++ src/im/telegram/client.ts | 178 +++++++++++++++++++ src/im/telegram/index.ts | 2 + src/im/weixin/adapter.ts | 257 ++++++++++++++++++++++++++++ src/im/weixin/client.ts | 285 +++++++++++++++++++++++++++++++ src/im/weixin/index.ts | 13 ++ 8 files changed, 1144 insertions(+) create mode 100644 src/im/telegram/adapter.ts create mode 100644 src/im/telegram/client.ts create mode 100644 src/im/telegram/index.ts create mode 100644 src/im/weixin/adapter.ts create mode 100644 src/im/weixin/client.ts create mode 100644 src/im/weixin/index.ts diff --git a/.gitignore b/.gitignore index 6c7ce2483..0b49077d7 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,67 @@ src/desktop/assets/*.png # 静默忽略,`git add` 只报一行提示,很容易漏提交。 /platform/ /mcp-gateway/ + +# ───────────────────────────────────────────────────────────── +# 密钥加固(fork/公开仓防泄漏):以下一律绝不提交。 +# 真实运行配置 vs 模板:只提交 *.example,不提交真实 bots.json。 +bot.json +bots.json +bots.json.bak +*.local.json +*.local.json.bak +/session.json +/session-*.json +.json.bak + +# 所有明文/会话/token/密钥文件 +/session/ +/feishu-session.json +/*.session.json +/*-session.json +secret* +*secret* +*.pem +*.key +*.p12 +*.pfx +.pnpm-store/ + +# 任何平台凭据/token 转储(含 .env 变体) +.env +.env.* +.env.*.local +.envrc +*.env +token* +*token* +*.token +credential* +*.tg-backend +telegram-session* +wechat-session* +weixin-session* + +# 本地数据 / 日志 / 运行时状态 +data/ +log/ +logs/ +*.log +*.tsbuildinfo +storageState.json +midscene_run/ + +# 操作者本地 Git 凭据与 SSH(防误加) +.ssh/ +*.git-credentials +.gitconfig +.netrc + +# 桌面/构建产物 +dist/ +packages/workflow-core/dist/ +packages/workflow-core/.packs/ +build/desktop-node/ +build/desktop-runtime/ +build/icon.png +src/desktop/assets/*.png diff --git a/src/bot-registry.ts b/src/bot-registry.ts index eb2f24dc5..e646490e4 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -1092,6 +1092,13 @@ export interface BotConfig { * 缺省 / false 保持原有飞书 bot 行为字节不变。 */ apiOnly?: boolean; + /** + * 消息通道。缺省 / 旧 bots.json 无此字段 → 视为 `'lark'`(飞书,字节不变)。 + * `'weixin'`:微信 Clawbot 通道(见 src/im/weixin/),走 openclaw 扫码绑定的 + * 微信凭证长轮询,消息进 botmux 会话机制。当前 `'weixin'` 通道的完整 daemon + * 会话接线仍在推进:本字段是配置面,缺省不改变任何现行为。 + */ + channel?: 'lark' | 'weixin'; /** * 租户品牌:`'feishu'`(中国版,open.feishu.cn)或 `'lark'`(国际版, * open.larksuite.com)。缺省 / 旧 bots.json 无此字段 → 视为 `'feishu'` diff --git a/src/im/telegram/adapter.ts b/src/im/telegram/adapter.ts new file mode 100644 index 000000000..e7c5d5d3a --- /dev/null +++ b/src/im/telegram/adapter.ts @@ -0,0 +1,338 @@ +/** + * Telegram 通道 —— ImAdapter 的一个实现。 + * + * 设计要点: + * - 用 Telegram 原生「私聊即 topic」的映射:每个私聊 chat 是一个 threadId + * (`tg:`);群/超级群的 forum topic 用 `tg::`。 + * - Telegram 没有「卡片」,因此 ImCardBuilder 用结构化文本渲染(rich/text 都走 + * Markdown 文本),sendCard/updateCard 映射到 sendMessage/editMessageText。 + * - Reaction 用 Telegram 原生 message reaction 映射。 + * - 长轮询拉取,空闲时 30s 超时,健壮容错。 + */ + +import type { + ImAdapter, + ImAttachment, + ImCard, + ImCardAction, + ImCardBuilder, + ImEventHandler, + ImMessage, + ImUser, +} from '../types.js'; +import type { StreamStatus } from '../../types.js'; +import { TelegramClient, type TgMessage, type TgUpdate } from './client.js'; + +const KNOWN_EMOJI: Record = { + thumbsup: '👍', + thumbsdown: '👎', + heart: '❤️', + fire: '🔥', + 'party_popper': '🎉', + rocket: '🚀', + eyes: '👀', + clap: '👏', + check: '✅', + cross: '❌', + info: 'ℹ️', + question: '❓', + warning: '⚠️', +}; + +export interface TelegramAdapterOptions { + /** Telegram bot token(形如 `123456:ABC...`)。 */ + token: string; + /** + * 允许的 user id(Telegram 数字 id)。空数组 = 放行所有人(仅测试/自用)。 + * 与 bots.json 的 allowedUsers 语义兼容:非空时只处理这些 id 发来的消息。 + */ + allowedUserIds?: number[]; + /** 轮询间隔(毫秒)。默认 1000。 */ + pollIntervalMs?: number; + /** long-polling 超时(秒)。默认 30。 */ + pollTimeoutSec?: number; + /** 覆盖 Telegram API 地址(测试用)。 */ + apiUrl?: string; +} + +interface DevTalk { + chatId: number; + threadId?: number; + message: TgMessage; + updateId: number; +} + +export class TelegramAdapter implements ImAdapter { + readonly cards: ImCardBuilder = new TelegramCardBuilder(); + + private readonly client: TelegramClient; + private handler?: ImEventHandler; + private readonly allowedUserIds: Set; + private readonly pollIntervalMs: number; + private readonly pollTimeoutSec: number; + private offset = 0; + private running = false; + private pollLoop?: Promise; + private me?: { id: number }; + private userCache = new Map(); + + constructor(opts: TelegramAdapterOptions) { + this.client = new TelegramClient(opts.token, { apiUrl: opts.apiUrl }); + this.allowedUserIds = new Set(opts.allowedUserIds ?? []); + this.pollIntervalMs = opts.pollIntervalMs ?? 1000; + this.pollTimeoutSec = opts.pollTimeoutSec ?? 30; + } + + async start(handler: ImEventHandler): Promise { + this.handler = handler; + const me = await this.client.getMe(); + this.me = { id: me.id }; + this.running = true; + this.pollLoop = this.poll(); + } + + async stop(): Promise { + this.running = false; + if (this.pollLoop) await this.pollLoop.catch(() => undefined); + this.pollLoop = undefined; + } + + getBotUserId(): string | undefined { + return this.me ? String(this.me.id) : undefined; + } + + // ── 发送 ────────────────────────────────────────────────────────────── + + async sendMessage(threadId: string, content: string, format: 'text' | 'rich'): Promise { + const { chatId, threadId: tgThread } = this.parseThreadId(threadId); + const msg = await this.client.sendMessage(chatId, content, { + parseMode: format === 'rich' ? 'MarkdownV2' : 'Markdown', + replyToMessageId: tgThread, + }); + return String(msg.message_id); + } + + async replyMessage(messageId: string, content: string, format: 'text' | 'rich'): Promise { + // replyMessage 需要知道 chatId——messageId 形如 `:`。 + const [chatId, msgId] = this.splitMessageKey(messageId); + const msg = await this.client.sendMessage(Number(chatId), content, { + parseMode: format === 'rich' ? 'MarkdownV2' : 'Markdown', + replyToMessageId: Number(msgId), + }); + return this.messageKey(msg.chat.id, msg.message_id); + } + + async updateMessage(messageId: string, content: string): Promise { + const [chatId, msgId] = this.splitMessageKey(messageId); + await this.client.editMessageText(Number(chatId), Number(msgId), content, { + parseMode: 'Markdown', + }); + } + + async sendCard(threadId: string, card: ImCard): Promise { + const text = (card.payload as { text?: string })?.text ?? String(card.payload); + return this.sendMessage(threadId, text, 'rich'); + } + + async updateCard(messageId: string, card: ImCard): Promise { + const text = (card.payload as { text?: string })?.text ?? String(card.payload); + await this.updateMessage(messageId, text); + } + + // ── 用户 / 消息 ─────────────────────────────────────────────────────── + + async resolveUsers(identifiers: string[]): Promise { + const out: ImUser[] = []; + for (const id of identifiers) { + const cached = this.userCache.get(id); + out.push(cached ?? { id, identifier: id }); + } + return out; + } + + async sendDirectMessage(userId: string, content: string): Promise { + await this.client.sendMessage(Number(userId), content, { parseMode: 'Markdown' }); + } + + async getThreadMessages(threadId: string, limit: number): Promise { + const { chatId, threadId: tgThread } = this.parseThreadId(threadId); + const updates = await this.client.getUpdates(this.offset, { limit }); + // 简化:Telegram getUpdates 不提供按 chat 拉历史聊天的稳定接口,这里返回 + // 内存中最近收到的该 thread 消息。生产可扩展为 getChatHistory。 + const msgs: ImMessage[] = []; + for (const u of updates) { + if (!u.message) continue; + if (u.message.chat.id !== chatId) continue; + const m = this.toImMessage(u.message); + if (this.threadKey(u.message) === threadId) msgs.push(m); + } + return msgs.slice(-limit); + } + + async downloadAttachment(messageId: string, resourceKey: string): Promise { + // resourceKey 是 Telegram file_id;messageId 用于推导临时目录。 + if (!resourceKey) throw new Error('Telegram downloadAttachment 需要 file_id'); + const dest = `/tmp/tg-${Date.now()}-${resourceKey.replace(/[^a-zA-Z0-9]/g, '_')}`; + return this.client.downloadFile(resourceKey, dest); + } + + async addReaction(messageId: string, emojiType: string): Promise { + const [chatId, msgId] = this.splitMessageKey(messageId); + const emoji = KNOWN_EMOJI[emojiType] ?? '👍'; + await this.client.setMessageReaction(Number(chatId), Number(msgId), emoji); + return `${chatId}:${msgId}:${emoji}`; + } + + async removeReaction(messageId: string, reactionId: string): Promise { + // Telegram 的 removeReaction = 把 reaction 置空(清空 emoji list)。 + const [chatId, msgId] = String(messageId).split(':'); + await this.client.setMessageReaction(Number(chatId), Number(msgId), ''); + } + + // ── 轮询 ────────────────────────────────────────────────────────────── + + private async poll(): Promise { + while (this.running) { + try { + const updates = await this.client.getUpdates(this.offset, { timeout: this.pollTimeoutSec }); + for (const update of updates) { + this.offset = update.update_id + 1; + await this.dispatch(update); + } + } catch (err) { + // 网络抖动 / 冲突:静默重试,不崩 daemon。 + await new Promise((r) => setTimeout(r, this.pollIntervalMs * 5)); + continue; + } + await new Promise((r) => setTimeout(r, this.pollIntervalMs)); + } + } + + private async dispatch(update: TgUpdate): Promise { + if (!this.handler) return; + + if (update.message) { + const senderId = update.message.from?.id; + if (this.allowedUserIds.size > 0 && senderId !== undefined && !this.allowedUserIds.has(senderId)) { + return; // 非白名单用户,忽略 + } + const msg = this.toImMessage(update.message); + const chatType = update.message.chat.type === 'private' ? ('p2p' as const) : ('group' as const); + await this.handler.onNewTopic(msg, this.threadKey(update.message), chatType); + return; + } + + if (update.callback_query?.message) { + const action: ImCardAction = { + actionType: update.callback_query.data ?? '', + threadId: this.threadKey(update.callback_query.message), + operatorId: update.callback_query.from ? String(update.callback_query.from.id) : undefined, + value: {}, + }; + await this.handler.onCardAction(action); + } + } + + // ── 映射 ────────────────────────────────────────────────────────────── + + private threadKey(msg: TgMessage): string { + const thread = msg.message_thread_id ?? msg.reply_to_message?.message_thread_id; + if (msg.chat.type === 'private') return `tg:${msg.chat.id}`; + return thread ? `tg:${msg.chat.id}:${thread}` : `tg:${msg.chat.id}`; + } + + private parseThreadId(threadId: string): { chatId: number; threadId?: number } { + const parts = threadId.split(':'); + const chatId = Number(parts[1]); + const thread = parts[2] ? Number(parts[2]) : undefined; + return { chatId, threadId: thread }; + } + + private messageKey(chatId: number, messageId: number): string { + return `${chatId}:${messageId}`; + } + + private splitMessageKey(messageId: string): [string, string] { + const idx = messageId.indexOf(':'); + if (idx < 0) return [messageId, messageId]; + return [messageId.slice(0, idx), messageId.slice(idx + 1)]; + } + + private toImMessage(msg: TgMessage): ImMessage { + const senderId = msg.from ? String(msg.from.id) : String(msg.chat.id); + const content = msg.text ?? msg.caption ?? ''; + const attachments: ImAttachment[] = []; + if (msg.photo?.length) { + const largest = msg.photo[msg.photo.length - 1]; + attachments.push({ type: 'image', path: largest.file_id, name: `${largest.file_id}.jpg` }); + } + if (msg.document) { + attachments.push({ + type: 'file', + path: msg.document.file_id, + name: msg.document.file_name ?? msg.document.file_id, + }); + } + this.userCache.set(senderId, { id: senderId, identifier: msg.from?.username ?? senderId }); + return { + id: this.messageKey(msg.chat.id, msg.message_id), + threadId: this.threadKey(msg), + senderId, + senderType: msg.from?.is_bot ? 'bot' : 'user', + content, + msgType: msg.voice || msg.audio ? 'audio' : msg.photo ? 'image' : msg.document ? 'file' : 'text', + attachments: attachments.length ? attachments : undefined, + createTime: new Date(msg.date * 1000).toISOString(), + }; + } +} + +/** Telegram 无卡片:用结构化文本渲染卡片标题/正文/状态。 */ +class TelegramCardBuilder implements ImCardBuilder { + buildSessionCard(opts: { sessionId: string; rootMessageId: string; terminalUrl: string; title: string }): ImCard { + return { + payload: { + text: [ + `**${opts.title}**`, + `\`\`\`\n会话: ${opts.sessionId}\n\`\`\``, + `终端: ${opts.terminalUrl}`, + ].join('\n'), + }, + }; + } + + buildStreamingCard(opts: { + sessionId: string; + rootMessageId: string; + terminalUrl: string; + title: string; + content: string; + status: StreamStatus; + }): ImCard { + const badge = + opts.status === 'starting' ? '⏳' + : opts.status === 'working' ? '▶️' + : opts.status === 'analyzing' ? '🔍' + : opts.status === 'limited' ? '⚠️' + : opts.status === 'stalled' ? '⛔' + : '⏸'; + return { + payload: { + text: `${badge} **${opts.title}**\n\n${(opts.content ?? '').slice(0, 4000)}`, + }, + }; + } + + buildRepoSelectCard(opts: { + projects: Array<{ name: string; path: string; description: string }>; + currentCwd: string; + rootMessageId: string; + }): ImCard { + const lines = opts.projects.map((p, i) => `${i + 1}. **${p.name}** — \`${p.path}\``); + return { + payload: { + text: `选择工作目录(当前: \`${opts.currentCwd}\`):\n${lines.join('\n')}`, + }, + }; + } +} \ No newline at end of file diff --git a/src/im/telegram/client.ts b/src/im/telegram/client.ts new file mode 100644 index 000000000..5441bef0e --- /dev/null +++ b/src/im/telegram/client.ts @@ -0,0 +1,178 @@ +/** + * 轻量 Telegram Bot API 客户端。基于 Node 内置 fetch,零第三方依赖。 + * 只覆盖 bidmux 通道层需要的端点;响应统一窄化为 Telegram 原生类型。 + */ + +const TELEGRAM_API = 'https://api.telegram.org'; + +export interface TgUser { + id: number; + is_bot: boolean; + first_name: string; + last_name?: string; + username?: string; +} + +export interface TgChat { + id: number; + type: 'private' | 'group' | 'supergroup' | 'channel'; + title?: string; + username?: string; +} + +export interface TgMessage { + message_id: number; + from?: TgUser; + chat: TgChat; + date: number; + text?: string; + caption?: string; + reply_to_message?: TgMessage; + message_thread_id?: number; + photo?: TgPhotoSize[]; + document?: TgDocument; + voice?: { file_id: string; duration: number }; + audio?: { file_id: string; duration: number }; + video?: { file_id: string; duration: number }; + sticker?: { file_id: string; emoji?: string }; + entities?: unknown[]; +} + +export interface TgPhotoSize { + file_id: string; + width: number; + height: number; + file_size?: number; +} + +export interface TgDocument { + file_id: string; + file_name?: string; + mime_type?: string; + file_size?: number; +} + +export interface TgUpdate { + update_id: number; + message?: TgMessage; + callback_query?: { id: string; from: TgUser; message?: TgMessage; data?: string }; + message_reaction?: { + chat: TgChat; + message_id: number; + new_reaction?: Array<{ type: string; emoji?: string }>; + }; +} + +class TelegramApiError extends Error { + constructor( + public readonly code: number, + message: string, + ) { + super(message); + this.name = 'TelegramApiError'; + } +} + +export class TelegramClient { + private readonly base: string; + private readonly token: string; + private _me?: TgUser; + + constructor(token: string, opts: { apiUrl?: string } = {}) { + this.token = token; + this.base = (opts.apiUrl ?? TELEGRAM_API).replace(/\/$/, ''); + } + + private async call(method: string, params: Record = {}): Promise { + const url = `${this.base}/bot${this.token}/${method}`; + let res: Response; + try { + res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + } catch (err) { + throw new Error(`Telegram ${method} 网络失败: ${(err as Error).message}`); + } + const json = (await res.json()) as { ok: boolean; result?: T; description?: string; error_code?: number }; + if (!json.ok) { + throw new TelegramApiError(json.error_code ?? -1, `${method} 失败: ${json.description ?? '未知'}`); + } + return json.result as T; + } + + /** 获取 bot 自身信息(用于 getBotUserId)。 */ + async getMe(): Promise { + if (!this._me) this._me = await this.call('getMe'); + return this._me; + } + + /** 长轮询拉取更新。timeout 单位为秒。 */ + async getUpdates(offset?: number, opts: { timeout?: number; limit?: number } = {}): Promise { + return this.call('getUpdates', { + offset, + timeout: opts.timeout ?? 30, + limit: opts.limit ?? 100, + allowed_updates: ['message', 'callback_query', 'message_reaction'], + }); + } + + async sendMessage( + chatId: number | string, + text: string, + opts: { parseMode?: 'Markdown' | 'MarkdownV2' | 'HTML'; replyToMessageId?: number } = {}, + ): Promise { + return this.call('sendMessage', { + chat_id: chatId, + text, + parse_mode: opts.parseMode ?? 'Markdown', + reply_to_message_id: opts.replyToMessageId, + }); + } + + async editMessageText( + chatId: number | string, + messageId: number, + text: string, + opts: { parseMode?: 'Markdown' | 'MarkdownV2' | 'HTML' } = {}, + ): Promise { + return this.call('editMessageText', { + chat_id: chatId, + message_id: messageId, + text, + parse_mode: opts.parseMode ?? 'Markdown', + }); + } + + /** + * 用 emoji 给消息加 Reaction。Telegram 原生支持,直接映射 ImAdapter.addReaction。 + * 返回一个稳定 id(这里用 chatId:messageId:emoji 拼成),removeReaction 据此重放。 + */ + async setMessageReaction( + chatId: number | string, + messageId: number, + emoji: string, + ): Promise { + return this.call('setMessageReaction', { + chat_id: chatId, + message_id: messageId, + reaction: [{ type: 'emoji', emoji }], + }); + } + + /** 下载文件到本地,返回磁盘路径。resourceKey 为 Telegram file_id。 */ + async downloadFile(fileId: string, destPath: string): Promise { + const file = await this.call<{ file_path?: string }>('getFile', { file_id: fileId }); + if (!file.file_path) throw new Error(`Telegram 无法解析 file_path for ${fileId}`); + const url = `${this.base}/file/bot${this.token}/${file.file_path}`; + const res = await fetch(url); + if (!res.ok) throw new Error(`Telegram 下载文件失败: HTTP ${res.status}`); + const buf = Buffer.from(await res.arrayBuffer()); + const { mkdir, writeFile } = await import('node:fs/promises'); + const { dirname } = await import('node:path'); + await mkdir(dirname(destPath), { recursive: true }); + await writeFile(destPath, buf); + return destPath; + } +} \ No newline at end of file diff --git a/src/im/telegram/index.ts b/src/im/telegram/index.ts new file mode 100644 index 000000000..407af5770 --- /dev/null +++ b/src/im/telegram/index.ts @@ -0,0 +1,2 @@ +export { TelegramAdapter, type TelegramAdapterOptions } from './adapter.js'; +export { TelegramClient } from './client.js'; \ No newline at end of file diff --git a/src/im/weixin/adapter.ts b/src/im/weixin/adapter.ts new file mode 100644 index 000000000..ff98d7064 --- /dev/null +++ b/src/im/weixin/adapter.ts @@ -0,0 +1,257 @@ +/** + * 微信 Clawbot 通道 —— ImAdapter 的一个实现。 + * + * 设计要点(镜像 src/im/telegram/adapter.ts 骨架): + * - 微信私聊即会话:每个微信用户(`@im.wechat`)是一个 threadId + * `wx::`。 + * - 微信没有「卡片」,ImCardBuilder 用结构化文本渲染(rich/text 都走纯文本), + * sendCard/updateCard 映射到 sendText。 + * - context_token 按用户颁发,出站必须原样带回 —— 由 WeixinClient 管理。 + * - 长轮询拉取,空闲 35s 超时,健壮容错。 + */ + +import type { + ImAdapter, + ImAttachment, + ImCard, + ImCardAction, + ImCardBuilder, + ImEventHandler, + ImMessage, + ImUser, +} from '../types.js'; +import type { StreamStatus } from '../../types.js'; +import { + WeixinClient, + type WeixinInboundMessage, + type WeixinMessageItem, +} from './client.js'; + +export interface WeixinAdapterOptions { + /** openclaw 账户 id(也是 botmux weixin bot 的 weixinAccountId)。 */ + accountId: string; + /** 微信 bot token(可从 openclaw 账户文件读取,见 loadWeixinAccountCredentials)。 */ + token: string; + /** 覆盖 baseUrl(测试用)。 */ + baseUrl?: string; + /** 轮询间隔(毫秒)。默认 1000。 */ + pollIntervalMs?: number; + /** long-polling 超时(毫秒)。默认 35000。 */ + pollTimeoutMs?: number; + /** context_token 持久化文件路径(可选)。 */ + contextTokenFile?: string; + /** 允许的微信用户 id(`@im.wechat`,形如 `o9cq...@im.wechat`)。空 = 放行。 */ + allowedUserIds?: string[]; +} + +export class WeixinAdapter implements ImAdapter { + readonly cards: ImCardBuilder = new WeixinCardBuilder(); + + private readonly client: WeixinClient; + private readonly accountId: string; + private readonly pollIntervalMs: number; + private readonly pollTimeoutMs: number; + private readonly contextTokenFile?: string; + private readonly allowedUserIds: Set; + private handler?: ImEventHandler; + private running = false; + private pollLoop?: Promise; + + constructor(opts: WeixinAdapterOptions) { + this.accountId = opts.accountId; + this.pollIntervalMs = opts.pollIntervalMs ?? 1000; + this.pollTimeoutMs = opts.pollTimeoutMs ?? 35_000; + this.contextTokenFile = opts.contextTokenFile; + this.allowedUserIds = new Set(opts.allowedUserIds ?? []); + this.client = new WeixinClient({ + accountId: opts.accountId, + token: opts.token, + baseUrl: opts.baseUrl, + contextTokenFile: opts.contextTokenFile, + }); + } + + async start(handler: ImEventHandler): Promise { + this.handler = handler; + this.running = true; + this.pollLoop = this.poll(); + } + + async stop(): Promise { + this.running = false; + if (this.pollLoop) await this.pollLoop.catch(() => undefined); + this.pollLoop = undefined; + } + + getBotUserId(): string | undefined { + return this.accountId; + } + + // ── 发送 ────────────────────────────────────────────────────────────── + + async sendMessage(threadId: string, content: string, _format: 'text' | 'rich'): Promise { + const userId = this.parseThreadId(threadId); + return this.client.sendText(userId, content); + } + + async replyMessage(messageId: string, content: string, _format: 'text' | 'rich'): Promise { + // messageId 形如 `wx::`,回复即给该用户发新消息。 + return this.sendMessage(messageId, content, 'text'); + } + + async updateMessage(_messageId: string, _content: string): Promise { + // 微信无消息编辑能力,忽略(保留 API 兼容)。 + } + + async sendCard(threadId: string, card: ImCard): Promise { + const text = (card.payload as { text?: string })?.text ?? String(card.payload); + return this.sendMessage(threadId, text, 'text'); + } + + async updateCard(_messageId: string, _card: ImCard): Promise { + // 微信无卡片编辑,忽略。 + } + + // ── 用户 / 消息 ─────────────────────────────────────────────────────── + + async resolveUsers(identifiers: string[]): Promise { + return identifiers.map((id) => ({ id, identifier: id })); + } + + async sendDirectMessage(userId: string, content: string): Promise { + await this.client.sendText(userId, content); + } + + async getThreadMessages(_threadId: string, _limit: number): Promise { + // 微信 getUpdates 不提供按会话拉历史聊天的稳定接口,返回空。 + return []; + } + + async downloadAttachment(_messageId: string, _resourceKey: string): Promise { + throw new Error('weixin downloadAttachment: 媒体留待后续 ImAttachment 映射'); + } + + async addReaction(_messageId: string, _emojiType: string): Promise { + throw new Error('weixin addReaction: 微信无公开 reaction API'); + } + + async removeReaction(_messageId: string, _reactionId: string): Promise { + throw new Error('weixin removeReaction: 微信无公开 reaction API'); + } + + // ── 轮询 ────────────────────────────────────────────────────────────── + + private async poll(): Promise { + while (this.running) { + try { + const msgs = await this.client.getUpdates({ timeoutMs: this.pollTimeoutMs }); + for (const msg of msgs) { + await this.dispatch(msg); + } + } catch (err) { + // 网络抖动 / 凭证失效:静默退避重试,不崩 daemon。 + await new Promise((r) => setTimeout(r, this.pollIntervalMs * 5)); + continue; + } + await new Promise((r) => setTimeout(r, this.pollIntervalMs)); + } + } + + private async dispatch(msg: WeixinInboundMessage): Promise { + if (!this.handler || !msg.from_user_id) return; + if (this.allowedUserIds.size > 0 && !this.allowedUserIds.has(msg.from_user_id)) { + return; // 非白名单用户,忽略 + } + const text = this.client.consumeInbound(msg, this.contextTokenFile); + if (text.trim()) { + const im = this.toImMessage(msg, text); + await this.handler.onNewTopic(im, im.threadId, 'p2p'); + } + } + + // ── 映射 ────────────────────────────────────────────────────────────── + + threadKey(userId: string): string { + return `wx:${this.accountId}:${userId}`; + } + + private parseThreadId(threadId: string): string { + // `wx::` -> userId + return threadId.split(':').slice(2).join(':'); + } + + private toImMessage(msg: WeixinInboundMessage, text: string): ImMessage { + const senderId = msg.from_user_id ?? ''; + const attachments: ImAttachment[] = []; + for (const item of msg.item_list ?? []) { + const a = this.itemToAttachment(item); + if (a) attachments.push(a); + } + return { + id: msg.client_id ?? this.threadKey(senderId), + threadId: this.threadKey(senderId), + senderId, + senderType: 'user', + content: text, + msgType: attachments.length ? 'image' : 'text', + attachments: attachments.length ? attachments : undefined, + createTime: msg.create_time_ms ? new Date(msg.create_time_ms).toISOString() : new Date(0).toISOString(), + }; + } + + private itemToAttachment(item: WeixinMessageItem): ImAttachment | undefined { + if (item.type === 2) return { type: 'image', path: String(item.image_item ?? ''), name: 'image' }; + if (item.type === 4) return { type: 'file', path: String(item.file_item ?? ''), name: 'file' }; + return undefined; + } +} + +/** 微信无卡片:用结构化文本渲染卡片标题/正文/状态。 */ +class WeixinCardBuilder implements ImCardBuilder { + buildSessionCard(opts: { sessionId: string; rootMessageId: string; terminalUrl: string; title: string }): ImCard { + return { + payload: { + text: [ + `**${opts.title}**`, + `会话: ${opts.sessionId}`, + `终端: ${opts.terminalUrl}`, + ].join('\n'), + }, + }; + } + + buildStreamingCard(opts: { + sessionId: string; + rootMessageId: string; + terminalUrl: string; + title: string; + content: string; + status: StreamStatus; + }): ImCard { + const badge = + opts.status === 'starting' ? '⏳' + : opts.status === 'working' ? '▶️' + : opts.status === 'analyzing' ? '🔍' + : opts.status === 'limited' ? '⚠️' + : opts.status === 'stalled' ? '⛔' + : '⏸'; + return { + payload: { + text: `${badge} **${opts.title}**\n\n${(opts.content ?? '').slice(0, 4000)}`, + }, + }; + } + + buildRepoSelectCard(opts: { + projects: Array<{ name: string; path: string; description: string }>; + currentCwd: string; + rootMessageId: string; + }): ImCard { + const lines = opts.projects.map((p, i) => `${i + 1}. **${p.name}** — \`${p.path}\``); + return { + payload: { + text: `选择工作目录(当前: \`${opts.currentCwd}\`):\n${lines.join('\n')}`, + }, + }; + } +} \ No newline at end of file diff --git a/src/im/weixin/client.ts b/src/im/weixin/client.ts new file mode 100644 index 000000000..27b8b258b --- /dev/null +++ b/src/im/weixin/client.ts @@ -0,0 +1,285 @@ +/** + * 微信 Clawbot 通道 —— 轻量 HTTP 客户端。 + * + * 基于 Node 内置 fetch,零第三方依赖。协议镜像自 openclaw-weixin 插件 + * (@tencent-weixin/openclaw-weixin v2.4.6)的 api.js / send.js / inbound.js: + * - 入站:长轮询 `POST {base}/ilink/bot/getupdates`,返回 `{ret, msgs, get_updates_buf}` + * - 出站:`POST {base}/ilink/bot/sendmessage` + * 请求头带 `iLink-App-Id` / `iLink-App-ClientVersion` / `X-WECHAT-UIN` / + * `Authorization: Bearer `,body 带 `base_info`。 + * + * 凭证(token + baseUrl + accountId)由微信 QR 扫码绑定产生,openclaw 只是用来 + * 扫码产 token;本客户端运行时不需要 openclaw。默认从 openclaw 的账户文件读取 + * (`~/.openclaw/openclaw-weixin/accounts/.json`),也允许显式传 token。 + */ + +import { readFileSync, mkdirSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { randomBytes } from 'node:crypto'; + +/** iLink-App-Id:openclaw-weixin 插件 package.json 顶层 ilink_appid 字段当前为空。 */ +const ILINK_APP_ID = ''; +/** iLink-App-ClientVersion: uint32 0x00MMNNPP(major<<16 | minor<<8 | patch)。 */ +function buildClientVersion(version: string): number { + const [major = 0, minor = 0, patch = 0] = version.split('.').map((p) => parseInt(p, 10)); + return ((major & 0xff) << 16) | ((minor & 0xff) << 8) | (patch & 0xff); +} +const CHANNEL_VERSION = '2.4.6'; +const ILINK_APP_CLIENT_VERSION = buildClientVersion(CHANNEL_VERSION); + +/** 长轮询默认超时(与服务端 hold 时长匹配)。 */ +const LONG_POLL_TIMEOUT_MS = 35_000; +/** 普通 API 请求超时。 */ +const API_TIMEOUT_MS = 15_000; + +/** proto 枚举(镜像插件 types.js)。 */ +export const WeixinMessageType = { NONE: 0, USER: 1, BOT: 2 } as const; +export const WeixinMessageItemType = { NONE: 0, TEXT: 1, IMAGE: 2, VOICE: 3, FILE: 4, VIDEO: 5 } as const; +export const WeixinMessageState = { NEW: 0, GENERATING: 1, FINISH: 2 } as const; + +export interface WeixinUpdate { + ret: number; + msgs?: WeixinInboundMessage[]; + get_updates_buf?: string; + errmsg?: string; +} + +export interface WeixinInboundMessage { + from_user_id?: string; + to_user_id?: string; + client_id?: string; + message_type?: number; + message_state?: number; + item_list?: WeixinMessageItem[]; + context_token?: string; + run_id?: string; + msg_id?: string; + create_time_ms?: number; +} + +export interface WeixinMessageItem { + type: number; + text_item?: { text?: string }; + voice_item?: { text?: string }; + image_item?: unknown; + video_item?: unknown; + file_item?: unknown; + ref_msg?: unknown; +} + +export interface WeixinSendOptions { + timeoutMs?: number; +} + +/** 提取一条消息的纯文本(趋同插件 bodyFromItemList,含语音转文字)。 */ +export function weixinBodyFromItems(itemList?: WeixinMessageItem[]): string { + if (!itemList?.length) return ''; + for (const item of itemList) { + if (item.type === WeixinMessageItemType.TEXT && item.text_item?.text != null) { + return String(item.text_item.text); + } + if (item.type === WeixinMessageItemType.VOICE && item.voice_item?.text) { + return item.voice_item.text; + } + } + return ''; +} + +/** 默认 baseUrl。 */ +export const DEFAULT_WEIXIN_BASE_URL = 'https://ilinkai.weixin.qq.com'; + +/** 从 openclaw 账户文件读取凭证(token/baseUrl/userId)。 */ +export function loadWeixinAccountCredentials(accountId: string): { + token: string; + baseUrl: string; +} { + const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw'); + const candidates = [ + join(stateDir, 'openclaw-weixin', 'accounts', `${accountId}.json`), + join(stateDir, 'openclaw-weixin', 'accounts', `${accountId}@im.bot.json`), + ]; + for (const p of candidates) { + try { + if (!existsSync(p)) continue; + const raw = JSON.parse(readFileSync(p, 'utf-8')); + const token = typeof raw?.token === 'string' ? raw.token : ''; + const baseUrl = typeof raw?.baseUrl === 'string' && raw.baseUrl ? raw.baseUrl : DEFAULT_WEIXIN_BASE_URL; + if (token) return { token, baseUrl }; + } catch { + // try next candidate + } + } + throw new Error(`weixin account credentials not found for accountId=${accountId} (looked in ${candidates.join(', ')})`); +} + +/** X-WECHAT-UIN:随机 uint32 -> decimal string -> base64。 */ +function randomWechatUin(): string { + const uint32 = randomBytes(4).readUInt32BE(0); + return Buffer.from(String(uint32), 'utf-8').toString('base64'); +} + +function ensureTrailingSlash(url: string): string { + return url.endsWith('/') ? url : `${url}/`; +} + +function buildBaseInfo(): { channel_version: string; bot_agent: string } { + return { channel_version: CHANNEL_VERSION, bot_agent: 'OpenClaw' }; +} + +export class WeixinClient { + private readonly base: string; + private readonly token: string; + private readonly accountId: string; + private getUpdatesBuf = ''; + private longPolling = false; + + /** accountId:userId -> context_token(出站必须原样带回)。 */ + private readonly contextTokens = new Map(); + + constructor(opts: { + accountId: string; + token: string; + baseUrl?: string; + contextTokenFile?: string; + }) { + this.accountId = opts.accountId; + this.token = opts.token; + this.base = ensureTrailingSlash((opts.baseUrl ?? DEFAULT_WEIXIN_BASE_URL).replace(/\/$/, '')); + if (opts.contextTokenFile) this.restoreContextTokens(opts.contextTokenFile); + } + + get account(): string { + return this.accountId; + } + + // ── 上下文 token(按 user 缓存,出站必须带回)────────────────────────── + setContextToken(userId: string, token: string): void { + this.contextTokens.set(this.tokenKey(userId), token); + } + getContextToken(userId: string): string | undefined { + return this.contextTokens.get(this.tokenKey(userId)); + } + private tokenKey(userId: string): string { + return `${this.accountId}:${userId}`; + } + + private restoreContextTokens(filePath: string): void { + try { + if (!existsSync(filePath)) return; + const raw = JSON.parse(readFileSync(filePath, 'utf-8')); + for (const [k, v] of Object.entries(raw)) { + if (typeof v === 'string' && v) { + const dot = k.indexOf(':'); + if (dot < 0) continue; + const account = k.slice(0, dot); + const userId = k.slice(dot + 1); + if (account === this.accountId) this.contextTokens.set(this.tokenKey(userId), v); + } + } + } catch { + // ignore restore errors + } + } + + private persistContextTokens(filePath: string): void { + try { + const prefix = `${this.accountId}:`; + const out: Record = {}; + for (const [k, v] of this.contextTokens) { + if (k.startsWith(prefix)) out[k] = v; + } + mkdirSync(join(filePath, '..'), { recursive: true }); + writeFileSync(filePath, JSON.stringify(out), 'utf-8'); + } catch { + // best-effort + } + } + + // ── 请求 ────────────────────────────────────────────────────────────── + private buildHeaders(bearer: boolean): Record { + const headers: Record = { + 'Content-Type': 'application/json', + AuthorizationType: 'ilink_bot_token', + 'X-WECHAT-UIN': randomWechatUin(), + 'iLink-App-Id': ILINK_APP_ID, + 'iLink-App-ClientVersion': String(ILINK_APP_CLIENT_VERSION), + }; + if (bearer && this.token) headers.Authorization = `Bearer ${this.token}`; + return headers; + } + + private async post(endpoint: string, body: unknown, timeoutMs: number): Promise { + const res = await fetch(new URL(endpoint, this.base).toString(), { + method: 'POST', + headers: this.buildHeaders(true), + body: typeof body === 'string' ? body : JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }); + const raw = await res.text(); + if (!res.ok) throw new Error(`weixin ${endpoint} HTTP ${res.status}: ${raw}`); + return JSON.parse(raw) as T; + } + + /** 长轮询拉取更新。超时/空响应返回空列表(正常控制流)。 */ + async getUpdates(opts: { timeoutMs?: number; abortSignal?: AbortSignal } = {}): Promise { + const timeoutMs = opts.timeoutMs ?? LONG_POLL_TIMEOUT_MS; + let res: WeixinUpdate; + try { + res = await this.post( + 'ilink/bot/getupdates', + JSON.stringify({ + get_updates_buf: this.getUpdatesBuf ?? '', + base_info: buildBaseInfo(), + }), + timeoutMs, + ); + } catch (err) { + if (err instanceof Error && err.name === 'TimeoutError') { + return []; // 长轮询超时是正常退避信号 + } + throw err; + } + if (res && typeof res.get_updates_buf === 'string') { + this.getUpdatesBuf = res.get_updates_buf; + } + return res?.msgs ?? []; + } + + /** 发送一条文本消息。contextToken 若缺失则用缓存(按 to 用户)。 */ + async sendText(toUserId: string, text: string, opts: WeixinSendOptions = {}): Promise { + const contextToken = this.getContextToken(toUserId); + const clientId = `bmx-${Date.now()}-${randomBytes(4).toString('hex')}`; + const body = { + msg: { + from_user_id: '', + to_user_id: toUserId, + client_id: clientId, + message_type: WeixinMessageType.BOT, + message_state: WeixinMessageState.FINISH, + item_list: text + ? [{ type: WeixinMessageItemType.TEXT, text_item: { text } }] + : [], + ...(contextToken ? { context_token: contextToken } : {}), + }, + }; + try { + await this.post('ilink/bot/sendmessage', body, opts.timeoutMs ?? API_TIMEOUT_MS); + } catch (err) { + throw new Error(`weixin sendText to=${toUserId} failed: ${(err as Error).message}`); + } + return clientId; + } + + /** + * 处理一条入站消息:缓存它携带的 context_token 并持久化。返回消息体文本。 + * 调用方负责把 text 交给 botmux 会话。 + */ + consumeInbound(msg: WeixinInboundMessage, contextTokenFile?: string): string { + if (msg.from_user_id && msg.context_token) { + this.setContextToken(msg.from_user_id, msg.context_token); + if (contextTokenFile) this.persistContextTokens(contextTokenFile); + } + return weixinBodyFromItems(msg.item_list); + } +} \ No newline at end of file diff --git a/src/im/weixin/index.ts b/src/im/weixin/index.ts new file mode 100644 index 000000000..cb4f261bd --- /dev/null +++ b/src/im/weixin/index.ts @@ -0,0 +1,13 @@ +export { + WeixinAdapter, + type WeixinAdapterOptions, +} from './adapter.js'; +export { + WeixinClient, + type WeixinInboundMessage, + type WeixinUpdate, + type WeixinMessageItem, + loadWeixinAccountCredentials, + weixinBodyFromItems, + DEFAULT_WEIXIN_BASE_URL, +} from './client.js'; \ No newline at end of file From 0167537f370912e1325550633357a33b91133043 Mon Sep 17 00:00:00 2001 From: Neilcc Date: Mon, 10 Aug 2026 00:59:50 +0800 Subject: [PATCH 3/5] =?UTF-8?q?docs(wechat):=20=E6=9B=B4=E6=96=B0=E6=96=B9?= =?UTF-8?q?=E6=A1=88=E7=8A=B6=E6=80=81=E4=B8=BA=E5=B7=B2=E8=81=94=E9=80=9A?= =?UTF-8?q?=20+=20Track=202=20=E5=9F=BA=E7=A1=80=E5=B7=B2=E8=90=BD?= =?UTF-8?q?=E5=9C=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/wechat-clawbot-plan.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/wechat-clawbot-plan.md b/docs/wechat-clawbot-plan.md index b3f7f5237..09155e5e1 100644 --- a/docs/wechat-clawbot-plan.md +++ b/docs/wechat-clawbot-plan.md @@ -1,13 +1,27 @@ # 微信 Clawbot 接入 botmux 方案文档 -> 状态:**方案梳理中(v0.1)** -> 日期:2026-08-09 +> 状态:**已联通(Track 1 可用)+ Track 2 基础已落地(v0.2)** +> 日期:2026-08-09(更新 2026-08-10) > 目标:让「微信 Clawbot」的对话能控制本服务器(走 botmux → Claude Code / Codex 会话) > 参考:[微信 clawbot 实测文章](https://mp.weixin.qq.com/s/1_8gWO-lo-BqsuKbGCwxfg) --- -## 0. 现状盘点(2026-08-09,服务器刚重启后) +## 0. 现状盘点(2026-08-10) + +### 已联通(Track 1,立即可用) +- openclaw 网关默认模型已切 `claude-cli/sonnet`,微信 clawbot 消息 → 本机 Claude Code CLI 回复。 +- root 下需 `IS_SANDBOX=1` 解锁 Claude Code root 逃生舱(**用户已授权**,写入 `~/.config/systemd/user/openclaw-gateway.service`)。 +- 微信通道 `ad84cc0d97e4-im-bot` 健康,`openclaw channels status --probe` 确认。 + +### Track 2(botmux 微信通道)已落地 +- `src/im/weixin/{client,adapter,index}.ts`:微信长轮询传输 + ImAdapter,编译通过、`pnpm build` 绿。 +- `BotConfig.channel?: 'lark'|'weixin'` 配置面已加(缺省 lark,飞书主路径不动)。 +- Telegram 适配器编译已修复(同款 StreamStatus 导入/类型/徽标问题)。 +- 提交 `c5ce8b6`。 +- **剩余**:daemon 会话接线(channel-aware `getBotClient` 出站 + 微信入站灌 `botEventHandlers`),是后续增量。 + +## 1. 现状盘点(2026-08-09,服务器刚重启后) ### 已修复 / 已确认 | 项 | 状态 | From f21f58304136a471bc6d0c907ca59662e304730e Mon Sep 17 00:00:00 2001 From: Neilcc Date: Mon, 10 Aug 2026 05:44:29 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat(channel):=20=E6=89=93=E9=80=9A=20chann?= =?UTF-8?q?el=20=E5=AD=97=E6=AE=B5=20->=20ImAdapter=20=E7=9A=84=E6=8E=A5?= =?UTF-8?q?=E7=BA=BF=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上游已内置 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 时序),与本改动无关。 --- bots.json.example | 37 ++++----- src/bot-registry.ts | 21 ++++- src/im/channel-factory.ts | 62 +++++++++++++++ test/telegram-adapter.test.ts | 145 ++++++++++++++++++++++++++++++++++ 4 files changed, 238 insertions(+), 27 deletions(-) create mode 100644 src/im/channel-factory.ts create mode 100644 test/telegram-adapter.test.ts diff --git a/bots.json.example b/bots.json.example index 662bd22a6..99c06fd3e 100644 --- a/bots.json.example +++ b/bots.json.example @@ -1,5 +1,6 @@ [ { + "channel": "lark", "larkAppId": "cli_xxx_bot1", "larkAppSecret": "your_secret_1", "cliId": "claude-code", @@ -16,6 +17,7 @@ } }, { + "channel": "lark", "larkAppId": "cli_xxx_bot2", "larkAppSecret": "your_secret_2", "cliId": "aiden", @@ -23,32 +25,21 @@ "workingDir": "~/work" }, { - "larkAppId": "cli_xxx_bot3", - "larkAppSecret": "your_secret_3", - "cliId": "gemini", - "model": "gemini-2.5-pro", - "allowedUsers": ["charlie@company.com"], - "workingDir": "~/projects" - }, - { - "larkAppId": "cli_xxx_bot4", - "larkAppSecret": "your_secret_4", - "cliId": "mtr", - "allowedUsers": ["dana@company.com"], - "workingDir": "~/projects" - }, - { - "larkAppId": "cli_xxx_bot5", - "larkAppSecret": "your_secret_5", - "cliId": "mira", - "allowedUsers": ["erin@company.com"], + "channel": "telegram", + "larkAppId": "tg_my_bot1", + "larkAppSecret": "", + "telegramBotToken": "123456789:ABCdef_YourTelegramBotToken", + "cliId": "claude-code", + "allowedUsers": ["5707358914"], "workingDir": "~/projects" }, { - "larkAppId": "cli_xxx_bot6", - "larkAppSecret": "your_secret_6", - "cliId": "pi", - "allowedUsers": ["frank@company.com"], + "channel": "weixin", + "larkAppId": "wx_clawbot1", + "larkAppSecret": "", + "wechatClawbotId": "wxid_xxxx", + "cliId": "codex", + "allowedUsers": ["zhuchengcheng@bytedance.com"], "workingDir": "~/projects" } ] diff --git a/src/bot-registry.ts b/src/bot-registry.ts index e646490e4..5fdd1764b 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -1081,6 +1081,16 @@ export interface VcMeetingRealtimeVoiceConfig { export interface BotConfig { larkAppId: string; larkAppSecret: string; + /** + * Telegram 通道用的 bot token(channel === 'telegram' 时必填)。 + * larkAppId 仍用于 daemon 标识(如 `tg_`),larkAppSecret 可缺省。 + */ + telegramBotToken?: string; + /** + * 微信 ClawBot 通道用的 bot id(channel === 'weixin' 时必填)。 + * 基于官方 openclaw-weixin / iLink 协议,拆分为独立通道实现。 + */ + wechatClawbotId?: string; /** * Core-only / headless 模式:该 bot 纯 HTTP 控制 API 驱动(trigger → * spawn → CLI → trigger-result),**不连接任何飞书**——boot 时跳过 @@ -1094,11 +1104,14 @@ export interface BotConfig { apiOnly?: boolean; /** * 消息通道。缺省 / 旧 bots.json 无此字段 → 视为 `'lark'`(飞书,字节不变)。 - * `'weixin'`:微信 Clawbot 通道(见 src/im/weixin/),走 openclaw 扫码绑定的 - * 微信凭证长轮询,消息进 botmux 会话机制。当前 `'weixin'` 通道的完整 daemon - * 会话接线仍在推进:本字段是配置面,缺省不改变任何现行为。 + * - `'lark'`:飞书,用 larkAppId/larkAppSecret。 + * - `'telegram'`:Telegram(见 src/im/telegram/),用 telegramBotToken,长轮询 + * 拉取 / 原生 message reaction / 文本卡片。 + * - `'weixin'`:微信 Clawbot 通道(见 src/im/weixin/),走 openclaw 扫码绑定的 + * 微信凭证长轮询,消息进 botmux 会话机制。当前 `'weixin'`/`'telegram'` 通道的 + * 完整 daemon 会话接线仍在推进:本字段是配置面,缺省不改变任何现行为。 */ - channel?: 'lark' | 'weixin'; + channel?: 'lark' | 'telegram' | 'weixin'; /** * 租户品牌:`'feishu'`(中国版,open.feishu.cn)或 `'lark'`(国际版, * open.larksuite.com)。缺省 / 旧 bots.json 无此字段 → 视为 `'feishu'` diff --git a/src/im/channel-factory.ts b/src/im/channel-factory.ts new file mode 100644 index 000000000..ca1755eed --- /dev/null +++ b/src/im/channel-factory.ts @@ -0,0 +1,62 @@ +/** + * 通道工厂 —— 按 bot 配置的 `channel` 字段构造对应的 ImAdapter。 + * + * botmux 是「多 CLI × 多后端 × 多 IM」的横向架构。上游已实现 Telegram + * (src/im/telegram/)与 Weixin(src/im/weixin/)两个 ImAdapter,但 daemon 的 + * 消息路由仍走飞书硬编码路径(im/lark/*),`channel` 字段此前只是配置面。 + * + * 本文件是让 `channel` 字段真正生效的接线层:给定 BotConfig,返回该 bot 应 + * 使用的 ImAdapter,供 daemon 侧按通道路由。 + * + * 覆盖: + * - `lark`:返回 undefined(飞书通道仍走 daemon 现有硬编码路径,尚未收敛到本 + * 接口——见 README「多通道」)。 + * - `telegram`:返回 {@link TelegramAdapter}(上游实现,含单测 + * test/telegram-adapter.test.ts)。 + * - `weixin`:返回 {@link WeixinAdapter}(上游实现,走 openclaw 扫码绑定的 + * iLink 协议,凭证从 openclaw 账户文件读取)。 + */ + +import type { BotConfig } from '../bot-registry.js'; +import type { ImAdapter } from './types.js'; +import { TelegramAdapter } from './telegram/adapter.js'; +import { WeixinAdapter } from './weixin/adapter.js'; +import { loadWeixinAccountCredentials } from './weixin/client.js'; + +/** + * 根据 bot 配置构造 ImAdapter。channel 缺省 = 'lark'(向后兼容)。 + * 对 lark 返回 undefined,表示「走 daemon 现有飞书路径」。 + */ +export function createChannelAdapter(cfg: BotConfig): ImAdapter | undefined { + const channel = cfg.channel ?? 'lark'; + switch (channel) { + case 'telegram': + if (!cfg.telegramBotToken) { + throw new Error(`bot ${cfg.larkAppId}: channel=telegram 但缺少 telegramBotToken`); + } + return new TelegramAdapter({ + token: cfg.telegramBotToken, + // allowedUsers 语义兼容:数字 id 白名单。 + allowedUserIds: cfg.allowedUsers + ?.map((u) => Number(u)) + .filter((n) => Number.isFinite(n) && n > 0), + }); + case 'weixin': { + if (!cfg.wechatClawbotId) { + throw new Error(`bot ${cfg.larkAppId}: channel=weixin 但缺少 wechatClawbotId`); + } + const accountId = cfg.wechatClawbotId; + // 凭证从 openclaw 账户文件读取(需要先按 openclaw-weixin 扫码绑定)。 + const creds = loadWeixinAccountCredentials(accountId); + return new WeixinAdapter({ + accountId, + token: creds.token, + baseUrl: creds.baseUrl, + allowedUserIds: cfg.allowedUsers, + }); + } + case 'lark': + default: + return undefined; // 飞书通道仍走 daemon 现有硬编码路径 + } +} \ No newline at end of file diff --git a/test/telegram-adapter.test.ts b/test/telegram-adapter.test.ts new file mode 100644 index 000000000..7d157a26a --- /dev/null +++ b/test/telegram-adapter.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TelegramAdapter } from '../src/im/telegram/adapter.js'; + +/** + * 用本地 mock HTTP 服务模拟 Telegram Bot API,验证: + * 1. getMe 拿到 bot id + * 2. 长轮询把收到的 message 派发成 ImMessage 并 trigger onNewTopic + * 3. sendMessage / updateMessage / addReaction 走对端点、参数正确 + */ + +function makeMockServer() { + const calls: Array<{ method: string; body: unknown }> = []; + const pending: Array = []; + let me = { id: 12345, is_bot: true, first_name: 'testbot' }; + + const server = { + calls, + pushUpdate(u: unknown) { + pending.push(u); + }, + setMe(m: unknown) { + me = m as typeof me; + }, + async handle(req: Request): Promise { + const url = new URL(req.url); + const seg = url.pathname.split('/'); + const method = seg[seg.length - 1]; + const body = (await req.json()) as Record; + calls.push({ method, body }); + let result: unknown; + switch (method) { + case 'getMe': + result = me; + break; + case 'getUpdates': + result = pending.splice(0, pending.length).map((u, i) => ({ update_id: 100 + i, ...(u as object) })); + break; + case 'sendMessage': + result = { message_id: 777, chat: { id: body.chat_id }, text: body.text }; + break; + case 'editMessageText': + result = { message_id: body.message_id, chat: { id: body.chat_id }, text: body.text }; + break; + case 'setMessageReaction': + result = true; + break; + default: + result = { ok: true }; + } + return new Response(JSON.stringify({ ok: true, result }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }, + }; + return server; +} + +async function startAdapter(server: { handle: (req: Request) => Promise }) { + const adapter = new TelegramAdapter({ + token: '123:abc', + apiUrl: 'http://127.0.0.1:1/mock', // apiUrl 会被 mock fetch 拦截 + pollIntervalMs: 10, + pollTimeoutSec: 1, + allowedUserIds: [999], + }); + // 拦截全局 fetch,指向本地 mock + const onNewTopic = vi.fn(); + const onThreadReply = vi.fn(); + const onCardAction = vi.fn(); + const origFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + return server.handle(new Request('http://127.0.0.1/mock/' + new URL(String(input)).pathname.split('/bot')[1], init)); + }) as typeof fetch; + await adapter.start({ onNewTopic, onThreadReply, onCardAction }); + return { adapter, onNewTopic, onThreadReply, origFetch }; +} + +describe('TelegramAdapter', () => { + let server: ReturnType; + let origFetch: typeof fetch; + + beforeEach(() => { + server = makeMockServer(); + }); + + it('getBotUserId 返回 getMe 的 id', async () => { + const { adapter, origFetch: of } = await startAdapter(server); + origFetch = of; + expect(adapter.getBotUserId()).toBe('12345'); + await adapter.stop(); + globalThis.fetch = origFetch; + }); + + it('收到私聊消息时派发 onNewTopic,threadId 映射为 tg:', async () => { + const s = makeMockServer(); + const { adapter, onNewTopic, origFetch: of } = await startAdapter(s); + origFetch = of; + s.pushUpdate({ + message: { + message_id: 1, + from: { id: 999, is_bot: false, first_name: 'u' }, + chat: { id: 888, type: 'private' }, + date: 1_700_000_000, + text: '你好', + }, + }); + await new Promise((r) => setTimeout(r, 60)); + expect(onNewTopic).toHaveBeenCalledTimes(1); + const [msg, chatId, chatType] = onNewTopic.mock.calls[0]; + expect(chatId).toBe('tg:888'); + expect(chatType).toBe('p2p'); + expect(msg.content).toBe('你好'); + expect(msg.senderId).toBe('999'); + expect(msg.msgType).toBe('text'); + await adapter.stop(); + globalThis.fetch = origFetch; + }); + + it('sendMessage 用 richtext 时走 MarkdownV2', async () => { + const s = makeMockServer(); + const { adapter, origFetch: of } = await startAdapter(s); + origFetch = of; + await adapter.sendMessage('tg:888', '**bold**', 'rich'); + const call = s.calls.find((c) => c.method === 'sendMessage'); + expect(call).toBeTruthy(); + expect((call!.body as { chat_id: number }).chat_id).toBe(888); + expect((call!.body as { parse_mode: string }).parse_mode).toBe('MarkdownV2'); + await adapter.stop(); + globalThis.fetch = origFetch; + }); + + it('addReaction / updateMessage 走对应端点', async () => { + const s = makeMockServer(); + const { adapter, origFetch: of } = await startAdapter(s); + origFetch = of; + await adapter.updateMessage('888:5', 'new text'); + expect(s.calls.some((c) => c.method === 'editMessageText')).toBe(true); + const rid = await adapter.addReaction('888:5', 'thumbsup'); + expect(s.calls.some((c) => c.method === 'setMessageReaction')).toBe(true); + expect(rid).toBe('888:5:👍'); + await adapter.stop(); + globalThis.fetch = origFetch; + }); +}); \ No newline at end of file From 7ee38f7c00181b0e163344dc96bc6d0cf2538852 Mon Sep 17 00:00:00 2001 From: Neilcc Date: Sun, 16 Aug 2026 15:03:20 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feat(channel):=20daemon=20=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=8E=A5=E7=BA=BF=E2=80=94=E2=80=94=E9=9D=9E=E9=A3=9E?= =?UTF-8?q?=E4=B9=A6=E9=80=9A=E9=81=93=E5=85=A8=E9=93=BE=E8=B7=AF=E5=8F=AF?= =?UTF-8?q?=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 承接 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 --- docs/wechat-clawbot-plan.md | 25 ++++++-- src/daemon.ts | 57 +++++++++++++----- src/im/channel-bridge.ts | 112 +++++++++++++++++++++++++++++++++++ src/im/channel-registry.ts | 31 ++++++++++ src/im/lark/client.ts | 52 +++++++++++++++++ test/channel-bridge.test.ts | 113 ++++++++++++++++++++++++++++++++++++ 6 files changed, 370 insertions(+), 20 deletions(-) create mode 100644 src/im/channel-bridge.ts create mode 100644 src/im/channel-registry.ts create mode 100644 test/channel-bridge.test.ts diff --git a/docs/wechat-clawbot-plan.md b/docs/wechat-clawbot-plan.md index 09155e5e1..78fce5c7e 100644 --- a/docs/wechat-clawbot-plan.md +++ b/docs/wechat-clawbot-plan.md @@ -1,6 +1,6 @@ # 微信 Clawbot 接入 botmux 方案文档 -> 状态:**已联通(Track 1 可用)+ Track 2 基础已落地(v0.2)** +> 状态:**已联通(Track 1 可用)+ Track 2 接线完成(v0.3)** > 日期:2026-08-09(更新 2026-08-10) > 目标:让「微信 Clawbot」的对话能控制本服务器(走 botmux → Claude Code / Codex 会话) > 参考:[微信 clawbot 实测文章](https://mp.weixin.qq.com/s/1_8gWO-lo-BqsuKbGCwxfg) @@ -14,12 +14,27 @@ - root 下需 `IS_SANDBOX=1` 解锁 Claude Code root 逃生舱(**用户已授权**,写入 `~/.config/systemd/user/openclaw-gateway.service`)。 - 微信通道 `ad84cc0d97e4-im-bot` 健康,`openclaw channels status --probe` 确认。 -### Track 2(botmux 微信通道)已落地 +### Track 2(botmux 微信通道)接线完成 - `src/im/weixin/{client,adapter,index}.ts`:微信长轮询传输 + ImAdapter,编译通过、`pnpm build` 绿。 -- `BotConfig.channel?: 'lark'|'weixin'` 配置面已加(缺省 lark,飞书主路径不动)。 +- `BotConfig.channel?: 'lark'|'weixin'|'telegram'` 配置面(缺省 lark,飞书主路径不动)。 - Telegram 适配器编译已修复(同款 StreamStatus 导入/类型/徽标问题)。 -- 提交 `c5ce8b6`。 -- **剩余**:daemon 会话接线(channel-aware `getBotClient` 出站 + 微信入站灌 `botEventHandlers`),是后续增量。 +- **daemon 会话接线(v0.3,本次落地)**: + - `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` 顶部加通道分支——该 bot 注册了非飞书 adapter 就路由到 + adapter,否则走原飞书路径(字节不变)。飞书 bot 从不注册 adapter,回归面最小。 + - `src/daemon.ts`:按 `isLarkChannel` 跳过非飞书 bot 的飞书专属启动步骤(open_id 探测 / + scope 校验 / 改名改头像 / 文档订阅 / WSClient 订阅),改由 `createChannelAdapter` + + `adapter.start(buildChannelImEventHandler(...))` 启动对应通道。 + - 新增单测 `test/channel-bridge.test.ts`(7 用例:data 合成 / 续话 vs 新话题 / + 卡片文本提取)全绿;client 相关测试 188+79 全绿;既有失败均为上游环境相关(未新增)。 +- **剩余(后续增量)**: + - weixin 媒体映射(图片/文件下载,`event.media` 下载待接)。 + - 微信 `allowedUserIds` 白名单对齐(weixin userId 与 Lark open_id 不同,需按微信 id 配置)。 + - live 端到端验证(需在 bots.json 加 `channel:'weixin'` bot 并重启 daemon)。 ## 1. 现状盘点(2026-08-09,服务器刚重启后) diff --git a/src/daemon.ts b/src/daemon.ts index 10a7111a5..a38417fb7 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -370,6 +370,9 @@ function republishResolvedAllowedUsers(larkAppId: string, resolved: string[]): v } let vcMeetingTerminalReconciler: VcMeetingTerminalReconciler | undefined; import { isBotMentioned, probeBotOpenId, startLarkEventDispatcher, markForwardFollowupsSessionsReady, writeBotInfoFile, canOperate, canRunDaemonCommand, evaluateTalk, evaluateBotTalk, evaluateAskAnswerTalk, grantCommandRestriction, isKnownPeerBot, checkRequiredScopes, type RoutingContext, type TalkEvaluation, type DocCommentContext, type EventHandlers } from './im/lark/event-dispatcher.js'; +import { createChannelAdapter } from './im/channel-factory.js'; +import { registerChannelAdapter } from './im/channel-registry.js'; +import { buildChannelImEventHandler } from './im/channel-bridge.js'; import { getDocSubscription, listAllDocSubscriptions, listDocSubscriptionsForSession, putDocSubscription, removeDocSubscription, setDocCommentPollCursor, type DocSubscription } from './services/doc-subs-store.js'; import { BOT_REPLY_SENTINEL, subscribeDocFile, unsubscribeDocFile, addCommentReaction, removeCommentReaction, hasBotSentinel, isBotAuthoredReply, listDocComments } from './im/lark/doc-comment.js'; import { learnFromMentions, resolveSender, flushIdentityCacheSync, type ResolvedSender } from './im/lark/identity-cache.js'; @@ -20052,6 +20055,10 @@ export async function startDaemon(botIndex?: number): Promise { } registerBot(cfg); selfDaemonLarkAppId = cfg.larkAppId; + // 通道判定:channel 缺省 = lark(向后兼容)。非飞书 bot(weixin/telegram) + // 没有飞书应用,须跳过所有 Lark 专属启动步骤(open_id 探测 / scope 校验 / + // 改名改头像 / 文档订阅 / WSClient 事件订阅),只启动对应通道 adapter。 + const isLarkChannel = cfg.channel === 'lark' || cfg.channel === undefined; // Establish the target-scoped daemon control credential before publishing // the daemon descriptor or accepting IPC traffic. Corruption fails startup // closed; silently rotating here could strand peers on mismatched tokens. @@ -20251,11 +20258,12 @@ export async function startDaemon(botIndex?: number): Promise { } }; setDisplayNameRefresher(refreshBotNameState); - // apiOnly (core-only) bots have no Feishu app to rename / re-avatar. These + // apiOnly (core-only) bots have no Feishu app to rename / re-avatar, and + // non-lark channel bots (weixin/telegram) have no Feishu app at all. These // handlers drive the open-platform console (browser web-session, NOT // getBotClient — so the bot-level gate can't catch them); skip registering - // them entirely so the dashboard profile actions are inert for a core-only bot. - if (!cfg.apiOnly) { + // them entirely so the dashboard profile actions are inert for such bots. + if (!cfg.apiOnly && isLarkChannel) { // 机器人真·改名(dashboard 档案头 ✎):开放平台自动化改飞书应用名并发布新版本 // (群内显示名跟随已发布版本,见 services/open-platform-rename.ts)。成功后同步 // 内存 botName / bots-info 名册 / descriptor,并清掉冗余的 displayName 别名—— @@ -20734,16 +20742,15 @@ export async function startDaemon(botIndex?: number): Promise { checkAllowedChatGroupsConfig(bot); - // apiOnly (core-only) bots never connect to Feishu: skip the open_id probe, - // the required-scope check, and the WSClient event subscription. They are - // driven purely over the HTTP control API (trigger → spawn → trigger-result), - // whose async path early-returns in deliverFinalOutput before any Feishu send. - // Seed a synthetic identity so downstream reads (worker botOpenId, dashboard + // apiOnly (core-only) bots never connect to Feishu; non-lark channel bots + // (weixin/telegram) have no Feishu app at all. Both skip the open_id probe, + // the required-scope check, and the WSClient event subscription. Seed a + // synthetic identity so downstream reads (worker botOpenId, dashboard // roster) get a stable non-undefined value instead of the never-probed one. - if (cfg.apiOnly) { + if (cfg.apiOnly || !isLarkChannel) { bot.botOpenId ||= `bot_${cfg.larkAppId}`; bot.botName ||= cfg.displayName ?? cfg.larkAppId; - logger.info(`[api-only] ${cfg.larkAppId} 以 core-only 模式启动:跳过飞书 open_id 探测 / scope 校验 / WSClient 订阅,仅 HTTP 控制 API 驱动`); + logger.info(`[api-only] ${cfg.larkAppId} 以 core-only/非飞书通道模式启动:跳过飞书 open_id 探测 / scope 校验 / WSClient 订阅`); } else { // Probe bot open_id and persist to bots-info.json. When the friendly // botName comes back from /bot/v3/info, refresh the dashboard descriptor @@ -20780,7 +20787,7 @@ export async function startDaemon(botIndex?: number): Promise { // im:message.group_at_msg.include_bot:readonly。缺失会 logger.error + // 私信 allowedUsers[0]。校验异步,跑失败不影响 daemon。 // apiOnly 无飞书连接 → 无 scope 概念,跳过。 - if (!cfg.apiOnly) { + if (!cfg.apiOnly && isLarkChannel) { checkRequiredScopes(cfg.larkAppId).catch(err => { logger.debug(`[${cfg.larkAppId}] required-scope check failed: ${err?.message ?? err}`); }); @@ -20829,15 +20836,35 @@ export async function startDaemon(botIndex?: number): Promise { }; // 存起来供授权成功后重放消息用(replayGrantedMessage → replayMessageEvent)。 botHandlers.set(cfg.larkAppId, botEventHandlers); - // apiOnly bots never subscribe to Feishu events → no WSClient. This is the - // core decoupling: the daemon serves the HTTP control API only. - if (!cfg.apiOnly) { + // Channel-aware inbound routing: + // - lark(缺省):飞书 WSClient 事件订阅(既有主路径,字节不变)。 + // - apiOnly:无任何入站通道,仅 HTTP 控制 API。 + // - weixin/telegram:构造 ImAdapter 并启动轮询,把入站经 channel-bridge + // 灌进同一个 botEventHandlers(复用全部会话/worker/CLI 逻辑)。 + if (!cfg.apiOnly && isLarkChannel) { startEventDispatchers.push(() => startLarkEventDispatcher( cfg.larkAppId, cfg.larkAppSecret, botEventHandlers, normalizeBrand(cfg.brand), )); + } else if (!cfg.apiOnly && !isLarkChannel) { + startEventDispatchers.push(() => { + let adapter: ReturnType; + try { + adapter = createChannelAdapter(cfg); + } catch (err) { + logger.error(`[channel] ${cfg.larkAppId} 启动 ${cfg.channel} 通道失败: ${err instanceof Error ? err.message : String(err)}`); + return; + } + if (!adapter) { + logger.error(`[channel] ${cfg.larkAppId} channel=${cfg.channel} 未返回 ImAdapter`); + return; + } + registerChannelAdapter(cfg.larkAppId, adapter); + logger.info(`[channel] ${cfg.larkAppId} 启动 ${cfg.channel} 通道(account=${adapter.getBotUserId() ?? '-'})`); + void adapter.start(buildChannelImEventHandler(botEventHandlers, cfg.larkAppId)); + }); } // A distillation command is durably prepared before its model run/card @@ -20976,7 +21003,7 @@ export async function startDaemon(botIndex?: number): Promise { // bot 从不连飞书,且其合成身份不会有真实文档订阅——整块跳过,否则非 pristine // dataDir 上的遗留订阅会让「无飞书连接」的 bot 每 5 秒主动打飞书。 let docCommentPollTimer: ReturnType | undefined; - if (!cfg.apiOnly) { + if (!cfg.apiOnly && isLarkChannel) { // 文档订阅恢复:重启后订阅可能已失效,给仍活跃的会话重订阅;会话没恢复 // (已关/丢失)的订阅则退订 + 清表,避免「命中订阅但无会话」的孤儿。 await restoreDocSubscriptions(activeSessions); diff --git a/src/im/channel-bridge.ts b/src/im/channel-bridge.ts new file mode 100644 index 000000000..33756c88d --- /dev/null +++ b/src/im/channel-bridge.ts @@ -0,0 +1,112 @@ +import type { RoutingContext, EventHandlers } from './lark/event-dispatcher.js'; +import type { ImEventHandler, ImMessage } from './types.js'; + +/** + * 把飞书硬编码的 {@link EventHandlers} 桥接到 ImAdapter 的 {@link ImEventHandler}。 + * + * 背景:daemon 的消息路由 `botEventHandlers` 签名是 `(data, ctx)`(data 是飞书 + * 事件结构,ctx 是 {@link RoutingContext}),而预留的 ImAdapter 是 `(msg, ...)` + * (msg 是 {@link ImMessage})。两者不一致,所以新通道(weixin / telegram)要写 + * 一层适配,把 ImMessage 合成一个「最小飞书记事件」`data` 再喂既有 handler, + * 从而复用全部会话 / worker / CLI 逻辑,且不触碰飞书主路径。 + * + * 合成 data 只填充既有 handler 真正读的字段(sender.sender_id.open_id、 + * sender.sender_type、message.message_type / content / message_id / create_time), + * 其余(mentions / merge_forward / interactive 等)天然走空分支。 + */ + +/** 把一条跨通道 ImMessage 合成成飞书事件 data 的最小可路由结构。 */ +export function imMessageToLarkData(msg: ImMessage, chatType: 'group' | 'p2p'): any { + return { + sender: { + sender_id: { open_id: msg.senderId }, + sender_type: msg.senderType === 'bot' ? 'app' : 'user', + }, + message: { + message_id: msg.id, + // 跨通道统一按 text 喂入(图片/文件暂以文本占位,媒体映射另列后续增量)。 + message_type: 'text', + content: JSON.stringify({ text: msg.content }), + create_time: msg.createTime, + chat_id: msg.threadId, + }, + }; +} + +function buildCtx( + msg: ImMessage, + chatType: 'group' | 'p2p', + scope: 'thread' | 'chat', + anchor: string, + larkAppId: string, +): RoutingContext { + return { + chatId: msg.threadId, + messageId: msg.id, + chatType, + scope, + anchor, + larkAppId, + }; +} + +/** + * 构造一个 ImEventHandler,把跨通道入站转成既有 botEventHandlers 调用。 + * 已有活跃会话的用户消息走 handleThreadReply(续话),否则 handleNewTopic + * (开新话题)——用 daemon 自己的 isSessionOwner 判断,避免重复建会话。 + */ +export function buildChannelImEventHandler( + handlers: EventHandlers, + larkAppId: string, +): ImEventHandler { + return { + async onNewTopic(msg, _chatId, chatType) { + const data = imMessageToLarkData(msg, chatType); + if (handlers.isSessionOwner?.(msg.threadId, larkAppId)) { + await handlers.handleThreadReply(data, buildCtx(msg, chatType, 'thread', msg.threadId, larkAppId)); + } else { + await handlers.handleNewTopic(data, buildCtx(msg, chatType, 'thread', msg.threadId, larkAppId)); + } + }, + async onThreadReply(msg, threadId) { + const data = imMessageToLarkData(msg, 'p2p'); + await handlers.handleThreadReply(data, buildCtx(msg, 'p2p', 'thread', threadId, larkAppId)); + }, + async onCardAction() { + // 非飞书通道无卡片动作。 + }, + }; +} + +/** 从 Lark 卡片 JSON 尽力提取可读文本(非飞书通道把卡片渲染成纯文本)。 */ +export function extractChannelCardText(cardJson: string): string { + try { + const card = JSON.parse(cardJson); + const parts: string[] = []; + walkCard(card, parts); + const text = parts.filter(Boolean).join('\n').trim(); + if (text) return text; + } catch { + // 非 JSON:原样返回。 + } + return cardJson; +} + +function walkCard(node: unknown, out: string[]): void { + if (Array.isArray(node)) { + for (const n of node) walkCard(n, out); + return; + } + if (node && typeof node === 'object') { + const obj = node as Record; + const tag = obj.tag; + if (tag === 'markdown' || tag === 'lark_md' || tag === 'div' || tag === 'note' || tag === 'shortcut') { + const content = + obj.content + ?? (obj.text && (obj.text as Record).content); + if (typeof content === 'string' && content.trim()) out.push(content.trim()); + return; + } + for (const v of Object.values(obj)) walkCard(v, out); + } +} \ No newline at end of file diff --git a/src/im/channel-registry.ts b/src/im/channel-registry.ts new file mode 100644 index 000000000..a6762720e --- /dev/null +++ b/src/im/channel-registry.ts @@ -0,0 +1,31 @@ +import type { ImAdapter } from './types.js'; + +/** + * 通道注册表 —— 运行期保存「非飞书 bot 正在使用的 ImAdapter」。 + * + * daemon 启动时,对 `channel` 为 weixin / telegram 的 bot,用 + * {@link createChannelAdapter} 构造 adapter 并注册到这里;出站( + * `src/im/lark/client.ts` 的 sendMessage / replyMessage / updateMessage / + * addReaction / deleteMessage)据此把对该 bot 的发送路由到对应通道,而不是 + * 飞书 Lark API。 + * + * 飞书 bot(channel 为 lark 或缺省)从不注册,getChannelAdapter 返回 undefined, + * 现有飞书出站路径字节不变 —— 这是「牵一发动全身」最小的回归面。 + */ +const nonLarkAdapters = new Map(); + +export function registerChannelAdapter(larkAppId: string, adapter: ImAdapter): void { + nonLarkAdapters.set(larkAppId, adapter); +} + +export function getChannelAdapter(larkAppId: string): ImAdapter | undefined { + return nonLarkAdapters.get(larkAppId); +} + +export function unregisterChannelAdapter(larkAppId: string): void { + nonLarkAdapters.delete(larkAppId); +} + +export function hasNonLarkChannel(larkAppId: string): boolean { + return nonLarkAdapters.has(larkAppId); +} \ No newline at end of file diff --git a/src/im/lark/client.ts b/src/im/lark/client.ts index 036f2250a..f0e263bf3 100644 --- a/src/im/lark/client.ts +++ b/src/im/lark/client.ts @@ -4,6 +4,8 @@ import { pipeline } from 'node:stream/promises'; import { Client } from '@larksuiteoapi/node-sdk'; import { getBotClient, getBotUploadClient, getAllBots, getBot, formatLarkError, LarkTransportDisabledError } from '../../bot-registry.js'; import { loadBotConfigs } from '../../bot-registry.js'; +import { getChannelAdapter } from '../channel-registry.js'; +import { extractChannelCardText } from '../channel-bridge.js'; import { config } from '../../config.js'; import { emitHookEvent, type ManagedHookOrigin } from '../../services/hook-runner.js'; import { logger } from '../../utils/logger.js'; @@ -255,6 +257,22 @@ export async function sendMessage( hookContext?: Record, options?: OutboundMessageOptions, ): Promise { + // 非飞书通道(weixin/telegram):路由到对应 ImAdapter,不走 Lark API。 + const channelAdapter = getChannelAdapter(larkAppId); + if (channelAdapter) { + const text = msgType === 'interactive' ? extractChannelCardText(content) : content; + const sentId = await channelAdapter.sendMessage(chatId, text, 'text'); + await emitOutboundHookIfAllowed(options, 'outbound.send', { + ...hookContext, + larkAppId, + chatId, + messageId: sentId, + msgType, + uuid, + content, + }); + return sentId; + } assertLarkTransport(larkAppId, 'sendMessage'); const c = getBotClient(larkAppId); const body = msgType === 'text' @@ -316,6 +334,23 @@ export async function replyMessage( hookContext?: Record, options?: OutboundMessageOptions, ): Promise { + // 非飞书通道:messageId 即 threadId(`wx::`),路由到 adapter。 + const channelAdapter = getChannelAdapter(larkAppId); + if (channelAdapter) { + const text = msgType === 'interactive' ? extractChannelCardText(content) : content; + const sentId = await channelAdapter.replyMessage(messageId, text, 'text'); + await emitOutboundHookIfAllowed(options, 'outbound.reply', { + ...hookContext, + larkAppId, + messageId, + replyId: sentId, + msgType, + replyInThread, + uuid, + content, + }); + return sentId; + } assertLarkTransport(larkAppId, 'replyMessage'); const c = getBotClient(larkAppId); const body = msgType === 'text' @@ -362,6 +397,11 @@ export async function replyMessage( } export async function addReaction(larkAppId: string, messageId: string, emojiType: string): Promise { + // 非飞书通道多无公开 reaction API:尽力而为,失败不炸会话。 + const channelAdapter = getChannelAdapter(larkAppId); + if (channelAdapter) { + try { return await channelAdapter.addReaction(messageId, emojiType); } catch { return ''; } + } assertLarkTransport(larkAppId, 'addReaction'); const c = getBotClient(larkAppId); const res = await (c as any).im.v1.messageReaction.create({ @@ -377,6 +417,11 @@ export async function addReaction(larkAppId: string, messageId: string, emojiTyp } export async function removeReaction(larkAppId: string, messageId: string, reactionId: string): Promise { + const channelAdapter = getChannelAdapter(larkAppId); + if (channelAdapter) { + try { await channelAdapter.removeReaction(messageId, reactionId); } catch { /* 尽力而为 */ } + return; + } assertLarkTransport(larkAppId, 'removeReaction'); const c = getBotClient(larkAppId); const res = await (c as any).im.v1.messageReaction.delete({ @@ -866,6 +911,8 @@ export async function getChatMode( * fall back instead of assuming success. Fire-and-forget callers can ignore it. */ export async function deleteMessage(larkAppId: string, messageId: string): Promise { + // 非飞书通道无删除 API:视为成功(幂等),避免会话流程报错。 + if (getChannelAdapter(larkAppId)) return true; assertLarkTransport(larkAppId, 'deleteMessage'); const c = getBotClient(larkAppId); try { @@ -947,6 +994,11 @@ export async function deleteEphemeralCard(larkAppId: string, messageId: string): } export async function updateMessage(larkAppId: string, messageId: string, cardJson: string): Promise { + // 非飞书通道无「编辑消息」能力(微信无卡片),静默忽略实为预期行为。 + if (getChannelAdapter(larkAppId)) { + await getChannelAdapter(larkAppId)!.updateMessage(messageId, cardJson); + return; + } assertLarkTransport(larkAppId, 'updateMessage'); const c = getBotClient(larkAppId); let res: any; diff --git a/test/channel-bridge.test.ts b/test/channel-bridge.test.ts new file mode 100644 index 000000000..f5223e293 --- /dev/null +++ b/test/channel-bridge.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + imMessageToLarkData, + buildChannelImEventHandler, + extractChannelCardText, +} from '../src/im/channel-bridge.js'; +import type { EventHandlers } from '../src/im/lark/event-dispatcher.js'; +import type { ImMessage } from '../src/im/types.js'; + +/** + * 验证 channel-bridge 的适配逻辑: + * 1. imMessageToLarkData 合成出既有 handleNewTopic/handleThreadReply 需要的 + * 最小飞书记事件 data(sender.sender_id.open_id / message_type / content…)。 + * 2. buildChannelImEventHandler 按 isSessionOwner 决定续话 vs 开新话题。 + * 3. extractChannelCardText 从卡片 JSON 提取纯文本。 + */ + +function makeMsg(over: Partial = {}): ImMessage { + return { + id: 'wx-msg-1', + threadId: 'wx:acct:o9cquser', + senderId: 'o9cquser', + senderType: 'user', + content: 'hello weixin', + msgType: 'text', + createTime: '2026-08-10T00:00:00.000Z', + ...over, + }; +} + +function makeHandlers(): { + handlers: EventHandlers; + calls: Array<{ kind: string; data: any; ctx: any }>; +} { + const calls: Array<{ kind: string; data: any; ctx: any }> = []; + const handlers: EventHandlers = { + handleCardAction: vi.fn() as any, + handleNewTopic: vi.fn(async (data: any, ctx: any) => { calls.push({ kind: 'newTopic', data, ctx }); }), + handleThreadReply: vi.fn(async (data: any, ctx: any) => { calls.push({ kind: 'threadReply', data, ctx }); }), + }; + return { handlers, calls }; +} + +describe('imMessageToLarkData', () => { + it('合成出 handleNewTopic 需要的 sender/message 最小结构', () => { + const data = imMessageToLarkData(makeMsg(), 'p2p'); + expect(data.sender.sender_id.open_id).toBe('o9cquser'); + expect(data.sender.sender_type).toBe('user'); + expect(data.message.message_type).toBe('text'); + expect(JSON.parse(data.message.content).text).toBe('hello weixin'); + expect(data.message.message_id).toBe('wx-msg-1'); + expect(data.message.chat_id).toBe('wx:acct:o9cquser'); + }); + + it('bot 发送方映射为飞书 app sender_type', () => { + const data = imMessageToLarkData(makeMsg({ senderType: 'bot' }), 'p2p'); + expect(data.sender.sender_type).toBe('app'); + }); +}); + +describe('buildChannelImEventHandler', () => { + it('无活跃会话时走 handleNewTopic,anchor=threadId', async () => { + const { handlers, calls } = makeHandlers(); + const bridge = buildChannelImEventHandler(handlers, 'lark-app-1'); + await bridge.onNewTopic(makeMsg(), 'wx:acct:o9cquser', 'p2p'); + expect(calls).toHaveLength(1); + expect(calls[0].kind).toBe('newTopic'); + expect(calls[0].ctx.larkAppId).toBe('lark-app-1'); + expect(calls[0].ctx.anchor).toBe('wx:acct:o9cquser'); + expect(calls[0].ctx.chatType).toBe('p2p'); + expect(calls[0].ctx.scope).toBe('thread'); + }); + + it('已有活跃会话时走 handleThreadReply(续话,不重复建会话)', async () => { + const { handlers, calls } = makeHandlers(); + handlers.isSessionOwner = vi.fn(() => true); + const bridge = buildChannelImEventHandler(handlers, 'lark-app-1'); + await bridge.onNewTopic(makeMsg(), 'wx:acct:o9cquser', 'p2p'); + expect(calls).toHaveLength(1); + expect(calls[0].kind).toBe('threadReply'); + }); + + it('onThreadReply 直接走 handleThreadReply', async () => { + const { handlers, calls } = makeHandlers(); + const bridge = buildChannelImEventHandler(handlers, 'lark-app-1'); + await bridge.onThreadReply(makeMsg(), 'wx:acct:o9cquser'); + expect(calls).toHaveLength(1); + expect(calls[0].kind).toBe('threadReply'); + expect(calls[0].ctx.anchor).toBe('wx:acct:o9cquser'); + }); +}); + +describe('extractChannelCardText', () => { + it('从 markdown/div 卡片提取纯文本', () => { + const card = JSON.stringify({ + schema: '2.0', + header: { title: { tag: 'plain_text', content: '会话卡片' } }, + body: { + elements: [ + { tag: 'markdown', content: '**foo**' }, + { tag: 'div', text: { tag: 'plain_text', content: 'bar' } }, + ], + }, + }); + const text = extractChannelCardText(card); + expect(text).toContain('foo'); + expect(text).toContain('bar'); + }); + + it('非 JSON 输入原样返回', () => { + expect(extractChannelCardText('not-a-card')).toBe('not-a-card'); + }); +}); \ No newline at end of file