GPU_GUARD_MONOREPO/docs/superpowers/specs/2026-05-08-weixin-group-channel-design.md
2026-05-20 21:39:12 +08:00

76 KiB
Raw Permalink Blame History

title: Weixin 群聊渠道接入设计 created: 2026-05-08 updated: 2026-05-12 status: 方案 5 (WCDB DB 读+SendInput) 已被选为最终方案(2026-05-12 更新);原 ClawBot/WCF/WeCom 讨论保留作为决策记录

📌 2026-05-12 最终结论:

  1. 原 2026-05-08 Phase 1-6 实施的所有与传输层无关的代码(chat_scope / errors / group entity / agent_channel_group service+controller / agent_executor chatScope / 前端 UI)全部保留,复用率 ~90%。
  2. 传输层从 "iLink ClawBot HTTP" 切换到 "WCDB SQLCipher DB 读取 + Win32 SendInput 回复"(新渠道类型 weixin-db)。
  3. 2026-05-09 的 UIA 方案(spec 2026-05-09-wechat-uia-channel-design.md)因 Weixin 4.x 用 Qt 自绘 UI(UIA 树空)作废
  4. 已验证:Weixin 4.1.8.107 的 message_0.db 可完整解密(见 docs/superpowers/specs/2026-05-11-weixin-4x-db-decrypt-progress.md)。

本文档的历史方案分析(方案 1-4)保留作为决策证据;方案 5 的实施规范写在本文档末尾。请优先阅读文档末尾 "方案 5 · 落地规范" 章节。

Weixin 群聊渠道接入设计

Context

⚠️ 下文 "Context" 到 "方案 4 分析" 为 2026-05-08 原始设计讨论 + 2026-05-08 "后记"的历史记录
请优先阅读文档顶部的 2026-05-12 更新摘要 + 文档末尾的 "方案 5 · 落地规范"。
原文保留为决策证据;下文任何与方案 5 冲突的细节以方案 5 为准。

当前 Neta 的微信渠道(packages/backend/src/modules/netaclaw/service/agent_channel.ts)在 routeInboundMessage 里遇到带 room_id 的消息直接 return,彻底屏蔽了群聊。业务场景需要把同一个绑定 bot 的 agent 同时服务于私聊与群聊,典型场景例如客服群、项目研发群、家人群。

参考实现hermes-agent 的 weixin 适配器已经支持群聊,核心是"三档策略 + chat_type 区分 + sessionId 多维隔离",但 hermes 的群准入靠人类去另一个地方粘贴 room_id 白名单UX 较差。本设计采用"被动发现 + 默认禁用 + 前端 toggle 启用"的方式,兼顾安全与便利。

接入群之后必须解决:

  1. 如何加入群:没有 iLink 侧的 join API全靠人类在手机微信里把 bot 拉进群;代码侧做"被动发现 + 可见清单"。
  2. 何时回话:群里每条消息都回答会造成风控+噪音,必须支持 "@机器人 / 前缀 / 所有消息" 三档策略,按群独立配置。
  3. 日志在哪看:复用现有 agent 对话页的会话列表 + session tree不新建页面。

总体架构

微信群消息
  └─ weixin.ts.getUpdates
       └─ agent_channel.ts.routeInboundMessage同步、零 DB I/O
             ├─ decideChatScope(message, accountId) → dm / group
             ├─ dm 路径:先查 pendingClarify[dm key],命中则同步 resolve否则入 DM senderQueue
             └─ group 路径:
                  ① 同步:先查 pendingClarify[group key=(cid,roomId,senderId)],命中则 consumePendingClarifyReply 同步 resolve不入队
                  ② 否则构造 chatScope按 chat scope 选 senderQueue key
                       · DM key  = channel:<cid>:weixin:<senderId>
                       · 群 key  = channel:<cid>:weixin:group:<roomId>   ← 群级共享,所有人串行
                  ③ fire-and-forget 入 senderQueue
             └─ senderQueue 异步链(首位是群路径专属预处理):
                   ① ChannelGroupService.upsertOnInbound被动发现默认 disabled
                   ② 读 group entity → decideGroupAcceptancestatus + triggerMode 判定)
                   ③ 拒绝 → 丢弃并日志reject 走 debug 级别,避免群活跃刷屏)
                   ④ 通过 → stripLeadingMention/stripLeadingPrefix 清洗文本
                   ⑤ handleInboundMessage(channel, state, scope, cleanedText)
                         ├─ agentExecutor.execute({ chatScope, message, onClarifyRequest, ... })
                         │     ├─ beforeToolCallchatScope==='group' && detectToolRisk 命中 → throw GroupInteractionDeclineError
                         │     ├─ onClarifyRequest群路径发文本选项到群 + pendingClarify[group key] + 300s timeout
                         │     ├─ runner 主体 catch (isInteractionDecline) → finalContent=declineMessage short-circuit
                         │     └─ 写入 assistant entry + metadata
                         ├─ ChannelGroupService.touchActive仅群路径
                         └─ weixinService.sendText(to=replyTarget, result.content)
                                · DM replyTarget = senderId
                                · 群 replyTarget = roomId拒绝文字 / clarify 选项 / 正常回复走同一路径)
  • 关键不变量routeInboundMessage 严格同步、零 DB I/O与上一轮死锁修复保持一致。所有 DB 写(upsertOnInbound / touchActive)都发生在 senderQueue 异步链路里。
  • 群级串行P0-1 决策):群 sessionId 全 sender 共享同一个 senderQueue保证 session tree 写入有序,LLM 看到的群对话是连贯的多人线程而非 fork 出的孤岛分支。代价是 A 在等 agent 跑期间 B 的群消息要排队,群机器人可接受。
  • 群内风险确定性拒绝:命中 detectToolRisk 的工具在群里不走确认流程,直接抛 GroupInteractionDeclineErrorrunner short-circuit 把拒绝文字当作 finalContent 返回,agent_channel 把它发到群里。不依赖 LLM 自由发挥。
  • 群内 Clarify 受约束地支持Clarify 无害群里允许pendingClarify key 含 senderId 确保只收发起者回复Promise 加 300 秒超时避免 senderQueue 被卡住;超时用同一个 GroupInteractionDeclineError short-circuit。
  • sessionId 规则:
    • DMchannel:<cid>:weixin:<senderId>(保持兼容)
    • 群:channel:<cid>:weixin:group:<roomId> —— 一群一会话(真共享)
  • 前端不新增对话页面;channel-management.vue群聊管理 抽屉agent 对话页列表自动显示群会话。

数据模型

新增表 netaclaw_agent_channel_group

字段 类型 说明
id bigint PK auto 自增主键
channelId bigint, FK logical 指向 netaclaw_agent_channel.id(项目规范不用 FK 约束service 层显式级联)
roomId varchar(255) iLink room_id12345@chatroom255 兜底未知上限
roomName varchar(256) nullable 群名,首次可空,可刷新
status tinyint default 0 0 = disabled默认 / 1 = enabled
triggerMode varchar(32) default at_mention at_mention / prefix / all
triggerPrefix varchar(64) nullable triggerMode=prefix 时必填
firstSeenAt datetime 首次发现时间
lastSeenAt datetime 最近一次群消息触达
lastActiveAt datetime nullable 最近一次 bot 真正回复的时间
createTime / updateTime datetime 标准字段

索引:UNIQUE(channelId, roomId)

级联清理P1-4channel 删除路径必须显式 groupRepo.delete({ channelId });不依赖 FK ON DELETE。

并发 upsertP1-3upsertOnInboundfindOne + update(已存在)/ repo.upsert(不存在 + race 时 catch + 重查 + update lastSeenAt的组合实现既保留 firstSeenAt 不被覆盖TypeORM upsert 不支持条件更新),又规避并发竞态。返回 { created: boolean } 让上层决定是否打"群被发现"日志。

netaclaw_agent_channel.config 扩展Json

interface NetaClawAgentChannelConfig {
  // …existing…
  group?: {
    /** bot 在群里显示的昵称,用于 @ 匹配的文本回落P1-1 强校验项) */
    botAlias?: string;
  };
}

不加全局 groupPolicy 白名单字段——准入已经落到 netaclaw_agent_channel_group.status 表里,实现等价 allowlist 语义但列表由后端被动发现填充,无需人工粘贴 roomId。

会话 / dispatch key 规则

export function buildDmSessionId(channelId: number, senderId: string): string {
  return `channel:${channelId}:weixin:${senderId}`;
}
export function buildGroupSessionId(channelId: number, roomId: string): string {
  return `channel:${channelId}:weixin:group:${roomId}`;
}
export function buildDmDispatchKey(channelId: number, senderId: string): string {
  return `channel:${channelId}:weixin:${senderId}`;
}
export function buildGroupDispatchKey(channelId: number, roomId: string): string {
  return `channel:${channelId}:weixin:group:${roomId}`;  // 群级共享,不含 senderId
}
export function buildDmClarifyKey(channelId: number, senderId: string): string {
  return `channel:${channelId}:weixin:${senderId}`;
}
export function buildGroupClarifyKey(channelId: number, roomId: string, senderId: string): string {
  return `channel:${channelId}:weixin:group:${roomId}:${senderId}`;  // 群里 clarify 仍按发起者收答
}

senderQueues 使用 dispatch key 作为队列 key

场景 dispatch key 并发行为
私聊 A channel:1:weixin:A A 自己消息按到达顺序串行;与 B 并发
私聊 B channel:1:weixin:B 与 A 并发
群 R 内 A channel:1:weixin:group:R A、B、C 在群 R 的消息全部串行,按到达顺序排队
群 R 内 B channel:1:weixin:group:R 同上(共享队列)
群 R2 内任何人 channel:1:weixin:group:R2 与群 R 并发
私聊 A channel:1:weixin:A 与群路径并发,私聊不被群里堵塞

pendingClarify 在群里仍然支持(详见"风险拒绝与 Clarify"节)。两套 key 系统并存:

  • senderQueues 用 dispatch key群级共享保证群内多人消息严格按到达顺序串行进 agent。
  • pendingClarify 用 clarify key含 senderId保证 clarify 只接受发起者本人的回复,群里其他人乱回的"1"会被忽略走 trigger 判定。

routeInboundMessage 在群路径里先做 pending reply 短路

if pendingClarify.has(buildGroupClarifyKey(cid, roomId, senderId)):
    consumePendingClarifyReply(...)  # 同步 resolve不入 senderQueue
    return

这样发起者的回复永远走"答复" 路径,不会被解析为新消息再次触发 trigger 判定。

触发策略与风险处理

准入判定

function decideGroupAcceptance(
  group: NetaClawAgentChannelGroupEntity,
  message: WeixinInboundMessage,
  text: string,                   // routeInboundMessage 已用 weixinService.extractText 提取过的文本
  channelConfig: NetaClawAgentChannelConfig,
  credential: WeixinCredential,
): { accept: boolean; cleanedText?: string; reason?: string }

关键text 由调用方一次性提取并传入,避免 chat_scope.ts 的纯函数版与 weixinService.extractText 的业务版双源不一致P0-1 修订)。detectAtMention 同样接收 text 入参。

流程:

  1. group.status !== 1 → reject group_disabled
  2. triggerMode 分流text 由调用方提供):
    • all:无条件 acceptcleanedText = text
    • prefixtext 起始匹配 group.triggerPrefix → accept 并 stripLeadingPrefix;否则 reject
    • at_mention(默认):协议字段优先 → 文本回落 → accept 并 stripLeadingMention;否则 reject

detectAtMention 双层检测

签名:detectAtMention(message, text, aliases, accountIds): booleantext 由调用方提供,与 decideGroupAcceptance 同步。

  1. 协议层:先对 message / item_list[*] 扫描常见字段名 at_user_list / mention_list / at_info(开发首日先打 raw payload 日志,确认 iLink 真实字段名后再固化代码——见 P1-7。命中条件list 中含 credential.accountIdcredential.userId

  2. 文本回落:构造别名集合 aliases = [config.group.botAlias, credential.nickname].filter(Boolean)。如果两者都为空,文本回落直接判 false前端在保存触发策略时会强校验避免出现这种状态——见 P1-1

    匹配正则要求 alias 是完整 token,且 @ 前必须是空白或字符串开头(防止 email@小神.com 这类邮箱地址误命中——P2-6 修订):

    // @ 前必须是 字符串开头 / 空白 / 中文标点 / U+2005 / U+3000
    // alias 后必须紧跟 空白 / U+2005 / U+3000 / 标点 / 字符串结束
    const trailing = `(?=$|[\\s\\u2005\\u3000\\p{P}])`;
    const leading = `(?<=^|[\\s\\u2005\\u3000])`;
    const atRegex = new RegExp(`${leading}@(?:${aliases.map(escape).join('|')})${trailing}`, 'u');
    

    \p{P} 覆盖中英标点(需 ES2018+ Unicode property escapes

误伤测试用例(见测试节):

  • @小神 你好 → 命中alias=小神)
  • @小神同学 你好 → 不命中alias 后紧跟汉字)
  • 今天 @小神 帮看下 → 命中(中段也算 @ 命中——agent 拿完整文本trigger 只是开关)
  • @小神聊天群 大家好 → 不命中("群"字接在 alias 后无分隔符)
  • 我的邮箱 email@小神.com → 不命中(@ 前是字母,不是空白——新增 leading 约束)
  • foo@小神 帮我查 → 不命中(同上)
  • @小神,你好 → 命中alias 后紧跟中文逗号,\p{P} 覆盖)

清洗文本

  • stripLeadingMention(text, aliases)仅移除文本起首处@<alias> 与随后空白;中段的 @ 保留(可能是 @ 群里其他人)。

    const leading = new RegExp(`^@(?:${aliases.map(escape).join('|')})[\\s\\u2005\\u3000]*`, 'u');
    return text.replace(leading, '');
    
  • stripLeadingPrefix(text, prefix):去除起首处前缀与随后空白。

清洗后的文本作为 agentExecutor.execute({ message }),避免污染 agent context。

风险工具:群内必须显式回复"已拒绝"

群里风险工具不能"静默 block"——必须确定性把拒绝消息发到群里,告知用户为什么没动作。不能依赖 LLM 自由发挥是否说明

实现机制:自定义错误 + runner short-circuit。

// runtime/errors.ts新增
export class GroupInteractionDeclineError extends Error {
  readonly isInteractionDecline = true;
  constructor(public readonly declineMessage: string) {
    super(declineMessage);
    this.name = 'GroupInteractionDeclineError';
  }
}

agent_executor runner 的 beforeToolCall

const risk = detectToolRisk(name, runtimeArgs);
if (risk) {
  if (params.chatScope === 'group') {
    throw new GroupInteractionDeclineError(
      `⚠️ 群内禁止高风险操作:${toolLabel || name}\n原因${risk}\n👉 请点我头像私聊 bot 继续`
    );
  }
  // dm 路径继续走 onRiskConfirmRequest
}

runner 主体外层 try/catch 识别此错误并 short-circuit

try {
  runResult = await this.agentRunner({ ... });
} catch (err: any) {
  if (err?.isInteractionDecline) {
    return {
      finalContent: err.declineMessage,
      thinking: thinkingText || undefined,
      usage: undefined,
      toolCallCount: toolExecutions.length,
      metadata: buildToolExecutionMetadata(toolExecutions),
    };
  }
  // 原有错误处理(标记 running tool 为 error、加 runtimeMetadata 等)
  throw err;
}

效果:

  • chat_orchestrator.finalizeAssistantEntry 拿到 finalContent 写入 session。
  • agent_channel.handleInboundMessage 现有路径 weixinService.sendText(replyTarget, result.content) 直接把这条拒绝文字发到群里,无需额外分支。
  • assistant entry 的 metadata.skillExecutions 仍包含被 block 的 tool 条目(带 status='error'、reason前端对话页能看到完整决策轨迹。

Clarify 在群内允许,但有三条约束

Clarifyagent 主动问选择题)本身不危险,群里允许使用,但要解决"谁来回答"和"会不会卡住"。

约束 1 · 答复者必须是发起者pendingClarify key 含 senderId见 key 规则节)。其他人发的"1/2"不会被识别为答复,继续走 trigger 判定。

约束 2 · routeInboundMessage 先做 pending 短路:群消息先查 pendingClarify[group clarify key],命中即 consumePendingClarifyReply 同步 resolve不入 senderQueue、不再走 trigger 判定。避免发起者的"1"被当成新消息触发新对话。

约束 3 · 强制超时:发起者长期不回复会让群 senderQueue 被该 await 占住,后续群消息全部排队。群路径的 clarify Promise 加默认 300 秒超时:

function withClarifyTimeout<T>(promise: Promise<T>, timeoutMs = 300_000): Promise<T> {
  return Promise.race([
    promise,
    new Promise<T>((_, reject) =>
      setTimeout(
        () => reject(new GroupInteractionDeclineError('⏱️ 等待 5 分钟未收到回复,已自动取消本次询问')),
        timeoutMs,
      ),
    ),
  ]);
}

超时复用同一个 GroupInteractionDeclineErrorrunner short-circuit 后 finalContent 自动发到群pendingClarify 在 finally 里清理senderQueue 释放。

agent_channel.ts 群路径下 onClarifyRequest 实现:

onClarifyRequest: async (question, choices) => {
  let msg = `❓ ${question}`;
  if (choices?.length) {
    msg += '\n' + choices.map((c, i) => `${i + 1}. ${c}`).join('\n');
    msg += `\n\n仅 @${botAlias || '机器人'} 的发起者本人可回复5 分钟超时)`;
  }
  await this.weixinService.sendText(credential, roomId, msg, contextToken);
  const clarifyKey = buildGroupClarifyKey(channelId, roomId, senderId);
  const promise = new Promise<string>(resolve => {
    this.pendingClarify.set(clarifyKey, { resolve, choices });
  });
  return withClarifyTimeout(promise, 300_000)
    .finally(() => this.pendingClarify.delete(clarifyKey));
}

DM 路径 onClarifyRequest 不加超时(私聊场景没有"卡住整个群"的问题),保持现状。

风险确认仅在 DM 提供

群路径不传 onRiskConfirmRequest。即使误传,前置 chatScope==='group' 分支已 throw GroupInteractionDeclineError,不会走到回调。

持久化钩子

  • upsertOnInbound(channelId, roomId, roomName?) -> { created: boolean }:先 findOne 查询;命中只 update lastSeenAt(与可选 roomName),返回 { created: false };未命中走 repo.upsertfirstSeenAt + lastSeenAt + status=0 + triggerMode=at_mention,返回 { created: true }。upsert 抛 race 异常时 catch + 重查 + update lastSeenAt 兜底,仍返回 { created: false }created: true 触发上层打 group discovered info 日志。
  • touchActive(channelId, roomId):在 handleInboundMessagesendText 成功后调用,更新 lastActiveAt

前端 UX

频道卡片(channel-management.vue

  • 元数据行加 群聊 X/Y 徽标(X=enabled, Y=discovered)。已连接的微信频道才显示。
  • 操作栏加按钮:群聊管理
  • 频道编辑 drawer 增加字段:微信机器人昵称(绑到 config.group.botAlias)。

群聊管理抽屉(新组件 channel-group-panel.vue

┌─ 群聊管理 · 老板微信 ─────────────────────[x]─┐
│                                              │
│ 共发现 5 个群 · 已启用 2 个     [刷新]         │
│                                              │
│ 💡 在手机微信把本账号拉进群即可自动发现。     │
│    新群默认禁用,请在下方逐个启用。            │
│                                              │
│ ┌──────────────────────────────────────┐     │
│ │ 产品研发群           [●启用]           │     │
│ │ roomId: 12345@chatroom              │     │
│ │ 首次发现 4 天前 · 最近消息 12 分钟前 │     │
│ │                                      │     │
│ │ 触发策略  (●) @机器人                 │     │
│ │           ( ) 前缀                    │     │
│ │           ( ) 所有消息                │     │
│ │ 前缀     [___________]  (仅前缀模式) │     │
│ │                                      │     │
│ │         [查看对话记录]  [保存]        │     │
│ └──────────────────────────────────────┘     │
│                                              │
│ ┌──────────────────────────────────────┐     │
│ │ 家人群  [○禁用]  ...                 │     │
│ └──────────────────────────────────────┘     │
│                                              │
│ 📌 禁用后 bot 不再回复该群消息,但仍留在群内。│
│    如需让 bot 离开群,请在手机微信中移除。     │
└──────────────────────────────────────────────┘
  • 状态 switch即点即切toggle API
  • 触发策略 / 前缀:必须点 保存 才提交,避免误触。
  • 前端强校验P1-1:保存触发策略时,若选 at_mention 但 channel.config.group.botAlias 为空,弹警告"请先回到频道编辑页填写 bot 昵称,否则群里永远不会被识别为 @机器人"。
  • 查看对话记录 按钮 → 跳 /agent?sessionId=channel:<cid>:weixin:group:<roomId>&agentId=<bound>

agent 对话页群会话识别

  • store/chat.tsbuildFallbackTitle 扩展:
    • sessionId 以 channel:<cid>:weixin:group: 起头 → 前缀 微信群 · (取 roomName 或 roomId 后 8 位)
    • sessionId 以 channel:<cid>:weixin: 起头但非 group → 前缀 微信 ·
  • 依赖 sessionId 字符串模式判别 chat kind,不写专门的 metadata.chat 字段——sessionId 模板已经携带足够信息channelId / kind / roomId-or-senderId增加一份冗余 metadata 没有额外收益。前端渲染 icon 时用同一份正则判定。
  • chat.vue mounted 检测 route.query.sessionId / agentId,自动切到对应 session。鲁棒性P2-fagentId 必须是数字字符串,否则忽略 query 走默认逻辑。

代码模块与接口

后端新增

文件 职责
entity/agent_channel_group.ts TypeORM Entity
service/agent_channel_group.ts upsertOnInbound / toggle / updatePolicy / rename / touchActive / list / cascadeDeleteByChannel
controller/admin/agent_channel_group.ts @CoolController + 自定义 list/toggle/updatePolicy/rename/delete
runtime/chat_scope.ts 全部纯函数decideChatScope / buildXxxSessionId / buildXxxDispatchKey / detectAtMention / stripLeadingMention / stripLeadingPrefix / decideGroupAcceptance

后端修改

文件 改动
service/agent_channel.ts routeInboundMessage 按 chat_scope 分流;群路径先查 pendingClarify 短路senderQueues key 改为 dispatch key群级共享upsert/decideGroupAcceptance 移到 senderQueue 异步链;群路径 onClarifyRequest 发群消息 + 300s 超时
service/agent_executor.ts execute() 入参加 chatScopebeforeToolCall 命中风险时群路径抛 GroupInteractionDeclineErrorrunner 外层识别该错误 short-circuit 为 finalContent
runtime/errors.ts(新增) GroupInteractionDeclineError 自定义错误 + withClarifyTimeout helper
service/agent_channel.tsdelete() 路径 agentChannelGroupService.cascadeDeleteByChannel(id)
configuration.ts / 入口 注册 NetaClawAgentChannelGroupEntity
(可选)service/weixin.ts 增加 sendGroupText 语义别名,实质复用 sendText

后端 REST 新增

POST /admin/netaclaw/channel/group/list          { channelId } → list
POST /admin/netaclaw/channel/group/toggle        { id, status }
POST /admin/netaclaw/channel/group/updatePolicy  { id, triggerMode, triggerPrefix? }
POST /admin/netaclaw/channel/group/rename        { id, roomName }
POST /admin/netaclaw/channel/group/delete        { ids }

服务端校验:

  • updatePolicytriggerMode='prefix' 时必须携带非空 triggerPrefix
  • updatePolicytriggerMode='at_mention' 时校验所属 channel.config.group.botAlias 非空,否则返回 4xx 提示前端补 botAlias。

前端新增

文件 职责
components/channel-group-panel.vue 群列表抽屉
api/channel_group.ts list/toggle/updatePolicy/rename/delete 封装

前端修改

文件 改动
views/channel-management.vue 卡片徽标 + "群聊管理" 按钮 + botAlias 表单字段
store/chat.ts buildFallbackTitle 扩展route.query 自动切 session鲁棒性
views/chat.vue 渲染群/DM iconmounted 里处理 ?sessionId=

测试与验证

单元测试

文件 覆盖
test/modules/netaclaw/runtime/chat_scope.test.ts decideChatScope 四形态sessionId/dispatchKey 构造detectAtMention 协议命中 / 文本命中 / 误伤("@小神同学"、"@小神聊天群"stripLeadingMention 处理 U+2005、U+3000stripLeadingPrefix中段 @ 不被剥除
test/modules/netaclaw/service/agent_channel_group.test.ts upsertOnInbound 首次 insert 默认 disabledexisting 仅更新 lastSeenAt并发 upsert 不抛 duplicate keytoggle/updatePolicy 非法入参prefix 模式缺 prefix、at_mention 模式缺 botAlias拒绝cascadeDeleteByChanneltouchActive
test/modules/netaclaw/service/agent_channel.group.test.ts routeInboundMessage 端到端:群被动发现默认不 dispatchenable+at_mention 下 @bot 通过、普通消息拒triggerMode=all 全部通过;同群多人消息按到达顺序串行A 发起后 B 紧跟也排队,不并发开 session不同群间并发群路径 pendingClarify 命中短路(发起者回"1"直接 resolve 不入 senderQueue非发起者回"1"不被当作答复,走常规 trigger 判定
test/modules/netaclaw/service/agent_executor.test.ts(扩展) chatScope='group' + 风险命中 → runner 抛 GroupInteractionDeclineError,外层 short-circuit 为 finalContentonRiskConfirmRequest 未被调用finalContent 含拒绝说明文字且 metadata.skillExecutions 含被 block 的条目;chatScope='group' + Clarify 正常onClarifyRequest 调用后 resolve 正常返回;Clarify 超时 pathpendingClarify 长期不 resolve → withClarifyTimeout 抛 GroupInteractionDeclineError → runner short-circuit 为超时文案dm 保持现状

手工验证

  1. 后端 pnpm --filter @neta/backend dev、前端 pnpm --filter @neta/frontend dev
  2. 在频道编辑页填写 bot 昵称botAlias
  3. 手机微信把 bot 拉进测试群
  4. 群里发一条普通消息 → 后端日志 group discoveredbot 不回,前端卡片徽标 群聊 0/1
  5. 群聊管理 → 看到该群,禁用状态
  6. 启用 + 选 @机器人
  7. 群里发 你好 → bot 不回;发 @<alias> 你好 → bot 回复
  8. 测误伤:群里发 @<alias>同学 在吗 → bot 不回(保护 token 不被前缀匹配误触)
  9. 查看对话记录 → 跳 agent 对话页session 标题 微信群 · ...assistant 气泡含完整工具卡片/thinking
  10. 群里发 @<alias> rm -rf /tmp/foo → bot 在群里明确回复 ⚠️ 群内禁止高风险操作bash\n原因包含文件删除命令\n👉 请点我头像私聊 bot 继续不弹确认
  11. 私聊 bot 发同样指令 → bot 发 1. 确认执行 / 2. 不执行;回 2 → 拒绝
  12. 群切 所有消息 → 群里任意消息 bot 都回
  13. 禁用 → bot 不回;卡片徽标计数更新
  14. 并发回归:两个群同时 @bot + 一个 DM 并发 → 三条任务并行进 agent群内消息顺序串行不乱
  15. 删除 channel → DB 检查 group 记录已被级联清除
  16. 群内 Clarify 正常流:拉 bot 进群,配置一个会触发 clarify 的 skill或 agent 自然需要澄清问题的对话。A @bot 问句 → bot 群里发" 问题\n1. ...\n2. ...\n仅 @ 的发起者本人可回复5 分钟超时)"。
    • A 回 1 → agent 继续执行
    • 同时 B 回 1 → 被忽略B 的消息走 trigger 判定(若未 @bot 就丢弃)
  17. 群内 Clarify 超时:发起 clarify 后不回复等 5 分钟 → bot 在群里发 ⏱️ 等待 5 分钟未收到回复,已自动取消本次询问pendingClarify 被清理,群 senderQueue 恢复接受新消息

可观测性

本次新增日志:

  • [AgentChannel] group discovered channelId=%s roomId=%sinfo 级,仅首次发现)
  • [AgentChannel] group dispatch channelId=%s roomId=%s sender=%s trigger=%s accept=%s reason=%saccept=true 走 infoaccept=false 走 debug避免群活跃时刷屏
  • [AgentChannel] group risk declined channelId=%s roomId=%s tool=%s reason=%swarn 级,表示群里因 detectToolRisk 命中而回复拒绝消息)
  • [AgentChannel] group clarify timeout channelId=%s roomId=%s senderId=%sinfo 级Clarify 300s 超时)

迁移路径

  • DB schema本地 dev 走 TypeORM synchronize: true 自动建表。测试 / 生产环境的 DDL 通过 MCP MySQL 工具直接执行mcp__mysql__execute / mcp__mysql__describe_table / mcp__mysql__list_tables),不再产出 .sql 文件随 release 包发布——按 CLAUDE.md 项目规约统一处理。具体执行步骤见 plan Task 20。
  • 老 channel 升级:现存 channel 的 config.group 为 undefined → 任何群消息都会进入 disabled 状态bot 不会乱回,平滑过渡。
  • 老 sessionId 兼容DM sessionId 模板未变,已有私聊会话不受影响。

非目标 / 不在本 spec 范围

  • 主动加入/退出群的 APIiLink 未提供,且违反微信平台条款,永远不做。
  • 群内风险确认降级:安全考虑,命中 detectToolRisk 的工具在群里一律拒绝(带回执文字),不做"仅发起者能确认"的复杂流程。Clarify 不属于此范畴——无害,群里允许使用。
  • 每群 botAlias override:当前 botAlias 落在 channel 级别,假定一个 bot 在所有群里同名。如果未来需要群级覆盖,加 netaclaw_agent_channel_group.botAliasOverride 字段即可,不破坏现有结构。
  • 企业微信 / 飞书群hermes 的 wecom/feishu 适配器是另一体系;本 spec 仅覆盖个人微信iLink Bot API。sessionId 模板里 weixin 段绑 channel.type未来加新平台用各自段名channel:<cid>:wecom:group:...)不冲突。
  • agent 切换语义channel.agentId 改后已存在 group session 的历史保留,新消息按当前 channel.agentId 处理(沿用 DM 行为)。
  • 跨群 / 跨 channel 的会话合并:每群一会话,不做跨群的全局人物志合并。
  • 独立的 metadata.chat 字段session 实体刻意不{ kind, channelId, roomId?, senderId? } 这类冗余 metadata。sessionId 模板 channel:<cid>:weixin:[group:]<chatId> 已经携带所有分类信息,前端做一次 regex 判别即可渲染 icon / fallback title。多一份 metadata 增加双源一致性负担无显著收益。P1-5 修订决策)
  • 群内消息主动推送:只实现被触发后的回复,不做 bot 主动在群内发起话题。

本节是在前述设计与代码20 个 task / 117 单测)实施完成后追加的现状报告。 结论先行:当前实现的群聊路径在生产环境无法被触发,因为 iLink ClawBot 协议本身不允许 bot 接收群消息。下文记录事实、当前代码的处置建议、以及两条候选迁移路径WeChatFerry 逆向 / 企业微信 WeCom的改造难度。

之前的设计基于"hermes weixin 适配器能收到群消息"这个假设,由此推导出"被动发现群 + 三档触发策略"的整套架构。这个假设错了。

通过查阅 iLink Bot 协议拆解资料(openclaw-weixin/weixin-bot-api.mdx1ah/wechat-ilink-demo微信开放社区 Q&A)确认的事实:

  • iLink ClawBot 是腾讯官方"机器人账号":扫码后通过 bot_type=3 在 iLink 平台生成一个独立 Bot ID每次扫码 Bot ID 都会变化)。它不是用户自己的微信号,只是与之"关联"。
  • ClawBot 不能被人拉进任何微信群:因为它不是普通微信号,不在好友/群成员选择列表里。
  • 群消息推送当前未开放:协议层面 room_id / group_id 字段存在,但官方明确"群聊可能需要额外权限",目前默认状态下 iLink 不会向 ClawBot 推送群消息。
  • 腾讯官方对"个人微信号 + 群聊多人共享 Bot"的定位建议改用微信对话开放平台chatbot.weixin.qq.com或企业微信而非 iLink Bot API。

hermes 文档里那句 "personal WeChat accounts may be in many groups" 是基于错误前提的描述,他们的群代码在 iLink 路径下也不会被触发。

2. 企业微信 (WeCom) 也无法接入"已有的个人微信群"

有人会自然想到"那转 WeCom 总行了吧"——也不行。腾讯封死了这条路:

  • 个人微信群(已存在的)→ 企业微信账号 协议禁止加入。

    "原有微信群,不支持邀请企业微信联系人加入。如果要让企业微信账号进入个人微信群,通常需要在创建群聊时就同时拉入企业微信账号。"——企客宝 SCRM

  • 企业微信新建群 → 同时拉入个人微信用户 但是这是企业侧主动建群("客户群"/"互通群"),不是接入用户已有的个人微信群。
  • 企业微信的 SCRM/客户群路径适合"客服/销售"等场景,不能解决"机器人加入老板已有的家人群、运营群、技术群"这类需求。

3. 三条路径的可行性矩阵

路径 加入"已有个人微信群" 加入"新建群(同账号建)" 协议风险 已有代码复用度
iLink ClawBot(当前) Bot 是独立账号 同上 合规 100%DM 私聊有效)
企业微信 WeCom 协议禁止 ⚠️ 仅"企业建群拉个人微信好友"的客户群 合规 60%(架构复用,渠道层重写)
WeChatFerry / wxhelperPC Hook 代理用户真实个人微信号 违反《微信个人账号使用规范》、有封号风险 70%(架构复用,传输层重写)

4. 当前代码的处置建议

实施完成的 20 个 task / 117 单测在以下层面仍然有价值

  • chat_scope.tsdecideChatScope / 三档准入 / detectAtMention / strippers / decideGroupAcceptance—— 与传输层无关的纯函数,任何"个人微信群消息接入"方案都能直接复用
  • runtime/errors.tsGroupInteractionDeclineError + withClarifyTimeout—— 通用错误模型,无需修改。
  • agent_channel_group.{entity,service,controller}.ts —— 群发现、状态、策略、级联删除都是渠道无关的元数据层。
  • agent_executor.tschatScope 参数 + 群内风险 short-circuit —— 这是 agent 层语义,与微信底层协议无关,可直接保留。
  • routeInboundMessage 群分流 + senderQueue 群级共享 + clarify 短路 —— 入站消息处理的通用模式。
  • 前端"群聊管理"抽屉、卡片徽标、深链接跳转、buildFallbackTitle —— UX 层完全可复用。

仅在以下层面是空跑

  • service/weixin.tsiLink getUpdates / sendText当前永远收不到 room_id 不为空的消息。所以 routeInboundMessage 的 group 分支理论上不会被触发;只有 DM 路径在生产中工作。
  • 群消息回复 weixinService.sendText(credential, roomId, ...) 同样发不出去——iLink 服务端不接受 to_user_id = @chatroom

5. 处置建议(按时间维度)

短期建议(本次 PR

  • 保留 Phase 1纯函数 chat_scope + errors+ Phase 2数据层+ Phase 3agent_executor chatScope+ Phase 4 的 routeInboundMessage 分流框架——这些是平台无关的能力,未来重构成本低且对当前 DM 路径有保护性(明确隔离 DM 与 group 处理路径)。
  • ⚠️ 暂时禁用 Phase 5/6 的群聊管理 UI频道页"群聊管理"按钮、群聊 X/Y 徽标、群聊面板)——避免误导用户以为已经能用。可以保留组件代码,但前端入口加 v-if="false"feature flag 隐藏。
  • 保留前端的 ClawBot 文案修订("扫码登录" → "ClawBot 扫码登录(个人微信助手)",弹窗里明确"无法被拉进微信群,仅支持私聊")。
  • 不要部署 Task 20 的 netaclaw_agent_channel_group 表到生产环境。测试库里已建可保留供后续路径复用。

中长期方向:见下两节"方案 3WeChatFerry 路径"和"方案 4企业微信路径"的迁移成本评估。


方案 3 分析 · 改造为 WeChatFerry / wxhelper 逆向方案

协议特征

  • WeChatFerry 是 Windows PC Hook 协议DLL 注入到微信客户端进程),代理用户真实个人微信号wcferry 官网GitHub lich0821/WeChatFerry)。
  • 群消息识别:msg.roomid 包含 @chatroom 即为群消息。
  • bot 入群方式:用户手动在手机/PC 微信里把自己的微信号拉进群(=就是把自己加进去bot 通过 hook 自动收到群消息。
  • 提供完整 API发消息 / 收消息 / 群成员管理 / 数据库直读 / 文件解密。

风险

  • 违反《微信个人账号使用规范》,封号风险无法消除——只能通过控制使用方式延缓。
  • 作者本人 lich0821 已停止维护项目2024 年起),但社区仍在用。
  • 实测:约 2-3 天后可能掉线警告;高频消息 + @ 操作 + 大群行为容易触发风控。
  • 必须严格匹配微信客户端版本wcf 与微信版本绑定)。

改造工作量评估

子项 现有代码可复用度 工作量 说明
service/weixin.tsiLink HTTP/JSON 0% 完全重写:从 long-poll HTTP 改为 wcf 的 gRPC/Socket 客户端协议
getUpdates 长轮询 0% 改为 wcf 的事件订阅/回调
sendText(credential, chatId, ...) ⚠️ 30% API shape 类似to / text但传输层换成 wcf SDK
凭证模型 (WeixinCredential token / accountId / baseUrl) ⚠️ 50% 改为 wcf 的连接信息gRPC 端点、wxid
QR 登录流程 0% wcf 不需要 QR改为"启动 wcf 服务、用户在 PC 微信内手动登录" 的引导
routeInboundMessage 分流 100% 几乎为零 decideChatScope(message, accountId) 协议字段 room_id 与 wcf 的 roomid 形态一致,最多加个字段适配层
decideGroupAcceptance / detectAtMention / strippers 100% 0 纯函数完全无关
Group entity + service + controller 100% 0 DB 层完全复用
agent_executor.chatScope + Decline short-circuit 100% 0 agent 层无关
前端 UI群聊管理 / 徽标 / 跳转) 95% 仅"扫码登录"按钮替换为"启动 wcf 服务"引导
测试单测 100% 0 都是 mock 入站 message 的protocol-agnostic
部署 / 运维 N/A 必须有 Windows 节点 + PC 微信客户端长期登录Linux/Mac 服务器跑不了

整体难度评估:中等(如果只算代码改造)。实际难度:高(因为部署环境从 Linux 服务器变成 Windows + PC 微信客户端,运维模型完全变了)。

合规风险——任何用户主号绑定 wcf 都可能封号。建议只用小号专用号

落地步骤建议(粗略)

  1. 新增渠道类型 netaclaw_agent_channel.type = 'weixin-pchook'(与现有 weixin 并行)。
  2. 实现 service/weixin_pchook.ts:封装 wcf gRPC 客户端,提供与 weixin.ts 同 shape 的 getUpdates / sendText 接口。
  3. agent_channel.ts.routeInboundMessagechannel.type 分流到不同 weixin service下游处理完全不变。
  4. 前端频道编辑页加 type 选项:微信 ClawBot私聊 / 微信 PC Hook含群聊封号风险。后者明确警告标语。
  5. 部署文档新增 Windows 节点搭建指南。

方案 4 分析 · 改造为企业微信 (WeCom) 客户群方案

协议特征

  • 企业微信开放平台提供官方 APIqyapi.weixin.qq.com企微 API 文档
  • 群类型:
    • 内部群仅企业内成员bot 可在企业内主动建群、加入。
    • 客户群(外部群):企业建群后拉个人微信好友进群,bot 在企业侧、客户在个人微信侧
  • 消息接收:企业微信使用回调 webhook你提供 URL企微 push 消息),不是 long-poll。
  • bot 入群:企业建群时把"应用机器人"加为成员;客户群同理。
  • 不能加入"已有的个人微信群"——协议禁止。

改造工作量评估

子项 现有代码可复用度 工作量 说明
service/weixin.ts 整体 0% 完全重写HTTP+签名认证、回调 webhook 接收消息、access_token 续期、不同消息类型解码(企微的消息体 schema 与 iLink 完全不同)
QR 登录 0% 改为"安装企微应用 + OAuth 授权",部署一次后长期有效
凭证模型 0% token + accountId 改为 corp_id + corp_secret + agent_id
入站消息接入 30% 从主动 long-poll 改为被动 webhook需要在 Neta 后端开 HTTP endpoint做企微签名校验
decideChatScope ⚠️ 60% 企微消息里 chat_id / from_user_id 字段名不同,加适配层
三档触发策略 + at_mention 检测 90% 极小 企微消息也有 at_user_list,文本里 @xxx 格式与微信稍有不同
群发现机制 ⚠️ 50% 企微提供"获取群列表"主动 API与 iLink 的"被动发现"不同),需要重写 upsertOnInbound 时机——可改为"管理员触发刷新群列表"按钮
Group entity + service 100% 0 完全复用
agent_executor.chatScope + Decline 100% 0 完全复用
前端 UI 80% 频道管理改为"企业微信应用配置",群聊管理抽屉小改(移除"被动发现"提示,加"刷新群列表"按钮)
Risk Confirm / Clarify 100% 0 DM 路径完全可用
部署 90% 现有后端服务多开一个 webhook endpoint 即可,不需要新机器

整体难度评估:中等偏高(代码改造)。实际难度:中(部署模型友好,但要求企业资质)。

合规风险——这是腾讯官方推荐的合规路径。

业务可行性评估

  • 必须有企业微信注册资质(公司主体 + 营业执照),不能用个人身份。
  • 适合"客服群 / 销售对接群 / 售后群"等需要企业身份背书的场景。
  • 不适合"接入老板已有的家人群、技术研讨群"这种纯私域社群。
  • 客户群人数上限 500比个人微信群200更宽松。

落地步骤建议(粗略)

  1. 新增渠道类型 netaclaw_agent_channel.type = 'wecom'
  2. 实现 service/wecom.ts:封装企微 access_token 管理 + 主动 API消息发送、群列表获取 + 被动回调入口。
  3. 新增 controller controller/admin/wecom_callback.ts 处理企微 webhook 推送,做签名校验后转化为统一的 WeixinInboundMessageLike shape喂给 routeInboundMessage
  4. 频道编辑页加 type 选项 企业微信(客户群),配置项变为 corp_id / corp_secret / agent_id / 回调 URL。
  5. 群聊管理抽屉改为"主动拉取"模式,加"刷新群列表"按钮。

决策对比一句话总结

需求 推荐方案
只要私聊(一对一 AI 助手) 保持 iLink ClawBot(当前实现已可用)
加入用户已有的个人微信群 方案 5 · WCDB DB 读+SendInput(2026-05-12 新增,已验证可行)
客户运营 / 企业 SCRM 场景 企业微信 WeCom(合规、稳定、有企业资质前提)
多场景兼顾 加渠道类型字段,多种 type 并存(架构已为此预留)

方案 5 · WCDB DB 读 + Win32 SendInput(2026-05-12 追加)

状态: 技术可行性已验证(见 2026-05-11-weixin-4x-db-decrypt-progress.md)。 架构 C 修订(2026-05-12 架构师交叉评审):不再使用独立 .NET bridge 进程,改为 backend 内集成 + PowerShell 小脚本处理 Win32 调用。 前置: 用户本机装 Weixin 4.1.x 并已登录;backend 进程以同用户身份运行(不需要管理员)。 合规定位: 读用户本机 SQLite 文件,不触碰 Weixin 服务器,不做协议模拟、不做 DLL 注入。等同于用户自行备份/查看本机聊天记录。封号风险接近 0;唯一的合规议题是在产品侧给用户明示授权。

5.0 · 架构演进(决策记录)

版本 架构 状态 理由
v1 (2026-05-09) 独立 .NET UIA bridge Weixin 4.x Qt 自绘,UIA 树空
v2 (2026-05-12 早) 独立 .NET bridge + WCDB superseded 33 Task 大头是 Node PoC 翻 C#;没有进程隔离收益
v3 (2026-05-12 晚) backend 集成 + PS 脚本 选定 复用 better-sqlite3 已有依赖;PoC 直接搬;调试一体

决定性证据:

  • backend 已依赖 better-sqlite3@^12.8.0 并被 pkg 打包成 backend.exe 跑在 Windows 上
  • WCDB 解密 PoC poc-7f-reserve80.mjs 用纯 Node crypto 完成,0.2 秒解 41MB DB
  • Win32 调用(OpenProcess / SendInput)用 PowerShell 一次性脚本即可,实测无需管理员
  • Tray 是 .NET 是因为 WinForms NotifyIcon 必需;bridge 没有非 .NET 不可的理由

5.1 · 为什么选方案 5 而不是之前的四条路

路径 作废/保留 原因
方案 1 · iLink ClawBot 保留用于 DM ClawBot 是独立账号,永远收不到群消息
方案 2 · 企业微信 WeCom 保留作为 v2 备选 只能入"新建客户群",不能入用户已有个人群
方案 3 · WeChatFerry PC Hook 放弃 DLL 注入,封号风险高;且作者 2024 停更,对 Weixin 4.x 无官方支持
方案 4 · UIA(2026-05-09 spec) 作废 Weixin 4.x 用 Qt 5.15 自绘,UIA 树只有 3 个空壳节点
方案 5 · DB 读 + SendInput 选定 无注入无协议模拟;本地文件读;HMAC 验证能捕捉任何非正常页;已实测可解密并读到最新群消息

5.2 · 总体架构(架构 C · 2026-05-12 修订)

Windows 用户桌面
├─ Weixin.exe (4.1.x, 已登录用户真实个人微信号)
│    └─ 写 message_0.db / session.db / contact.db (WCDB 加密)
│    └─ 写 message_0.db-wal (WAL, 实时增量)
│    └─ 主窗口 (Qt 自绘, 仅 Ctrl+F 搜索 + 剪贴板 Ctrl+V 可控)
│
├─ backend.exe (Neta backend, Node 22)
│    └─ modules/netaclaw/runtime/weixin_db/
│        ├─ wcdb_codec.ts          WCDB 解密 (纯 crypto, 直接搬 PoC)
│        ├─ db_paths.ts            xwechat_files 目录结构
│        ├─ key_extractor.ts       spawn ps1 → JSON 输出 → 缓存
│        ├─ message_repo.ts        better-sqlite3 readonly 查询
│        ├─ wal_watcher.ts         setInterval 轮询 .db-wal mtime
│        ├─ incremental_reader.ts  解密 + 查 + 解 zstd
│        └─ message_sender.ts      spawn ps1 → 切群 + 发文本
│    └─ modules/netaclaw/service/weixin_db.ts   ← 主服务,装配上述子模块
│    └─ modules/netaclaw/service/agent_channel.ts
│         routeInboundMessage 按 channel.type 分流
│           ├─ type='weixin'    → iLink 路径 (DM)
│           └─ type='weixin-db' → ★直接调 weixinDbService.ingestRow(...)
│              (无 HTTP IPC, 内部函数调用)
│    └─ tools/win32/             PowerShell 脚本 (装包时拷到 installDir/tools/)
│        ├─ extract-weixin-key.ps1   一次性: dump key map JSON
│        └─ send-weixin-text.ps1     每次回复: Ctrl+F 切群 + Ctrl+V 发
│
└─ Neta.Tray.exe (.NET, 仅 NotifyIcon, 无变化)
       拉起 backend.exe;不再拉 bridge.exe (已删除)

关键差别 vs v2 .NET bridge:

  • 删除 Neta.WeChatBridge / Neta.WeChatBridge.Tests 整个 .NET 项目
  • 删除 /open/netaclaw/channel/weixin-db/handshake|inbound HTTP 入口(无 bridge 进程要推)
  • 删除 service/weixin_db.ts 中的 axios 客户端(无 HTTP 出站)
  • weixin_db.ts 改为完整服务: 启动时抽 key + 持有 wal watcher;入站直接调 agent_channel.routeInboundMessage
  • 安装时只多 2 个 .ps1 脚本(无编译产物);bridge 文件夹里没有 .exe 要打入

5.3 · 最关键的技术细节(不能再猜)

已通过 poc-7f-reserve80.mjs + poc-10-decrypt-and-read.mjs 完整验证:

WCDB cipher_compatibility = 4 (SQLCipher 4 标准)
  page_size     = 4096
  kdf_algorithm = PBKDF2-HMAC-SHA512
  hmac_algorithm = HMAC-SHA512
  iv_sz         = 16
  hmac_sz       = 64   ← 关键!HMAC-SHA512 是 64B(我之前按 SQLCipher 3 的 SHA1=20B 算成 48,全部失败)
  reserve_sz    = iv_sz + hmac_sz = 80

Raw key 模式(WCDB 内存中以 x'<64hex raw><32hex salt>' 形式,99 字符串):
  encKey  = 内存中提取的 32 字节 raw key (不派生)
  salt    = DB 文件 page 1 前 16 字节(每个 DB 不同,是随机的)
  hmacKey = PBKDF2-HMAC-SHA512(encKey, salt XOR 0x3a, 2 rounds, 32B)

每 page(4096B)解密:
  encStart   = 16 if pageNum == 1 else 0          # page 1 前 16B 是 salt, 不加密
  ciphertext = page[encStart : 4016]
  iv         = page[4016 : 4032]                   # 16B
  storedHmac = page[4032 : 4096]                   # 64B HMAC-SHA512

  # HMAC 验证(LE page number)
  computed = HMAC-SHA512(hmacKey, ciphertext || iv || pageNum_LE_u32)
  assert computed[0..64] == storedHmac

  # AES 解密
  plaintext = AES-256-CBC(encKey, iv).decrypt(ciphertext)

输出明文 SQLite:
  out[0..16] = "SQLite format 3\0"                  # 替换 salt 位
  out[16..encEnd] = plaintext
  out[encEnd..4096] = 原 reserve 区(含 IV + HMAC,不影响 SQLite 解析)

5.4 · 群消息数据模型(WCDB 实际结构)

message_0.db (主消息库)
├─ DeleteInfo / DeleteResInfo / HistoryAddMsgInfo / HistorySysMsgInfo
└─ Msg_<sha256_of_room_or_wxid> × N     ← 每个会话一张表(DM + 群都在这)
     ├─ local_id            INTEGER PK
     ├─ server_id           INTEGER     — 服务端消息 id
     ├─ local_type          INTEGER     — 1=文本, 3=图片, 其他高位值看 bit flag(81604378673 等)
     ├─ sort_seq            INTEGER
     ├─ real_sender_id      INTEGER     — 指向 contact.db 的 sender 映射
     ├─ create_time         INTEGER     — Unix 秒
     ├─ status              INTEGER
     ├─ upload_status / download_status INTEGER
     ├─ server_seq          INTEGER
     ├─ source              TEXT        — 来源元数据(可能含 wxid)
     ├─ message_content     TEXT/BLOB   — 明文或 zstd 压缩(前 4B=28 b5 2f fd)
     ├─ compress_content    TEXT
     ├─ packed_info_data    BLOB
     └─ WCDB_CT_message_content/source  — WCDB 自带的加密类型列,可忽略

contact.db + session.db  — 通过 real_sender_id 反查 wxid / 昵称 / 群名

room 判断:message_content 里若出现 "wxid_xxx:\n..." 形式的前缀 → 群消息(发送者 wxid 自带)
          反之是 DM(发送者就是这张表对应的联系人)

5.5 · Bridge 项目结构(替换老 Neta.WeChatBridge)

老 bridge 作废(UIA 实现),新 bridge 在同目录重建。目录名复用。

packages/windows-tray/Neta.WeChatBridge/
├── Neta.WeChatBridge.csproj      (net8.0-windows, AssemblyName=bridge)
├── Program.cs                    启动 + 版本白名单 + handshake + 启 watcher
├── Config/
│   ├── WeixinProfile.cs          Weixin 4.x 版本 profile
│   └── WeixinProfiles.yaml       版本白名单 (4.1.x, 4.2.x ...)
├── Crypto/
│   ├── WeixinProcessLocator.cs   定位 Weixin.exe(不是 WeChat.exe)
│   ├── MemoryKeyExtractor.cs     OpenProcess + VirtualQueryEx + 找 x'...' 96-char literal + 按 salt 反向匹配各 DB
│   ├── WcdbCodec.cs              解密一个 page(HMAC 验证 + AES 解密) - 见 5.3
│   ├── ZstdDecompressor.cs       解 message_content 的 zstd
│   └── DbDecryptor.cs            全量解密 DB 到临时文件
├── Db/
│   ├── DbPaths.cs                定位 ~/Documents/xwechat_files/<seed>/db_storage/
│   ├── MessageRepo.cs            node:sqlite 风格 API,解密后用 Microsoft.Data.Sqlite 读
│   ├── ContactRepo.cs            查 real_sender_id → wxid 映射
│   └── RoomRepo.cs               查群名/成员
├── Watcher/
│   ├── WalWatcher.cs             轮询 .db-wal mtime(500ms)
│   ├── IncrementalReader.cs      只查 create_time > lastKnownSeq 的行
│   └── MessageDispatcher.cs      去重 + 推 backend
├── Input/
│   ├── WeixinWindowLocator.cs    FindWindowEx 找指定群/联系人的聊天子窗口
│   └── SendInputSender.cs        Win32 SendInput UTF-16 字符模拟输入 + Enter
├── Http/
│   ├── BridgeHttpServer.cs       Kestrel 托管
│   ├── Endpoints/                /health /send /enable-room /disable-room /rooms /diag
│   └── TraySecretAuth.cs
├── Backend/
│   └── BackendClient.cs          POST /handshake /inbound
└── Runtime/
    ├── BridgeRuntimeInfo.cs
    └── GracefulShutdown.cs

作废的老 UIA 代码: Uia/WeChatWindow.csSessionListWatcher.csChatBoxReader.csUiaRoomMessageReader.csMessageSender.cs(UIA 版)、AttachmentExtractor.cs全部删除,不保留占位。

5.6 · 入站事件流(架构 C 修订版)

backend.exe 启动时 weixin-db channel 初始化:
 1. WeixinProcessLocator: 找 Weixin.exe 4.x 主进程 + 版本号
 2. 定位 seedDir = ~/Documents/xwechat_files/<wxid>_<suffix>/
 3. 抽 key: spawn `extract-weixin-key.ps1` (一次性, 输出 JSON):
      - PowerShell 用 OpenProcess(VM_READ | QUERY_INFO, pid)  ← 同用户, 无需管理员
      - 枚举 private RW 区域 + ReadProcessMemory
      - 正则找 x'<96hex>' literal
      - 根据各 DB 的前 16B salt 一一匹配 → 输出 {dbFile: rawKey32B} JSON
      - backend 读 stdout, 缓存到内存 (channel 单例 service)
 4. ★ 从 DB 读取 channel 的"用户已添加群名"白名单 (netaclaw_agent_channel_group WHERE channelId=X AND status=1)
 5. 初次全量解密 message_0.db → 临时文件 (dataDir/<cid>/decrypted/message_0.db)
 6. ★ 枚举 Msg_<sha> 表, 用 contact.db / session.db 反查每张表对应的群名
      → 构造 { tableName → roomName } 映射
      → ★ 只保留 roomName 在白名单内的表; 其他表整体跳过 (隐私 + 性能)
 7. 记录每张"白名单表"的 MAX(create_time) 为 lastKnownTs
 8. 启 WalWatcher (setInterval 500ms 防抖, 轮询 message_0.db-wal mtime):
     mtime 变 → 把 DB 主文件 + WAL + -shm 拷到 work 目录 → 全量解密
              → 对每个"白名单表" SELECT * WHERE create_time > lastKnownTs ORDER BY create_time ASC
              → 每条新行: 解 zstd → 构造 WeixinDbInboundPayload
              → ★ 直接调用 agentChannelService.routeInboundMessage(channel, state, pseudo)
                 (无 HTTP IPC, 同进程内函数调用)
              → 更新 lastKnownTs

白名单语义:

  • 用户在前端"群聊管理"里手动输入群名才会建条 netaclaw_agent_channel_group 记录,默认 status=1 启用
  • DB 解密阶段 ★ 第 6 步就过滤掉非白名单的表 → bot 永远不读未授权群的消息(无论加密 DB 里是什么)
  • 这是隐私保护 + 性能优化双重收益:用户家庭群、好友群不会被 agent "顺便看到"

运行时目录约定:

  • 解密临时文件路径: <pDataPath()>/weixin-db-work/cid-<channelId>/{src,decrypted}/<dbname>
  • backend 进程退出时不清理(下次启动覆盖即可,且方便排错)
  • DEV 时 pDataPath() 解析为 packages/backend/dist/,与现有 uploads/workspace 同一根

关键优化:

  • 全量解密 10644 pages 仅 0.2s (Node),性能不是问题
  • 解密后的临时文件只在 work 目录存在,backend 停止时清理
  • 白名单过滤在 SQL 层 (SELECT FROM "Msg_<sha>"),只读用户授权的表

5.7 · 回复路径(TODO · 占位)

状态: 占位,后续单独 spec 描述。

目标: 当 agent 决定回复某个白名单群时,把回复文本投递到 Weixin UI 让微信真实发出去。

已知约束(用于后续设计):

  • Weixin 4.x 主窗口是 Qt 自绘,EnumChildWindows 只能看到 2 个 top-level 子窗口(整个聊天 UI 在 MMUIRenderSubWindowHW 一块画布里,Qt widget 对 Win32 完全不可见)
  • UIA 树空,不能通过控件自动化切群/定位输入框
  • 候选路径(待评估,本 spec 不下定论):
    • 路径 A: 剪贴板 + Ctrl+V + Enter,假设用户当前已聚焦目标群
    • 路径 B: Ctrl+F 搜索群名 → Enter 切群 → Ctrl+V + Enter
    • 路径 C: WeChatFerry 4.x 注入 hook(灰色,封号风险)
  • 任一路径都要解决"用户在用微信时怎么避免打断"问题

接口设计(占位): weixin_db_service.ts 暴露:

/** 向白名单群发送一条文本回复。v1 占位,先抛 NotImplementedError。 */
async replyToGroup(channelId: number, roomName: string, text: string): Promise<void>;

routeInboundMessage 决策完 finalContent 后调,失败时 agent 视为发送失败(不重试)。

未决问题(下一次 spec 解决):

  1. 如何在用户操作微信时安全发送 / 何时拒发
  2. 切群后如何验证焦点在目标群的输入框(避免发错群)
  3. 同名群处理策略
  4. 长文本 / 多行 / emoji 的 SendInput 编码细节

5.8 · 数据模型变更(架构 C · 群白名单语义)

保留不变:

  • netaclaw_agent_channel_group 表(Phase 2 已上线,schema 主体不动)
  • agent_channel.config.group.botAlias
  • 所有 sessionId / dispatch key / clarify key 规则

语义变更(2026-05-12 关键):

字段 原 ClawBot/UIA 语义 weixin-db 新语义
roomId iLink 平台 ID 或 sha1(roomName) 就是用户输入的群名(string),作为白名单 key
roomName 显示名 同 roomId(冗余,前端可编辑)
status 0=disabled (待审批) / 1=enabled / -1=ignored 简化为 0=disabled / 1=enabled,默认 1(用户手动添加即启用)
firstSeenAt bridge 被动发现时间 用户添加时间
lastSeenAt bridge 看到该群消息时间 DB 解密时确认群存在的时间
创建路径 bridge 被动发现 → upsertOnInbound 前端用户主动 POST /list/add { channelId, roomName }

作废:

  • upsertOnInbound(被动发现写库)→ 删除
  • status=-1 (ignored) 状态 → 删除(没有"忽略"概念,用户没添加就不监管)
  • 前端"待审批横幅" + "新发现 N 个群" → 删除

新增:

ALTER TABLE netaclaw_agent_channel
  -- type 字段新增合法值 'weixin-db'(原 'weixin' 仍用于 iLink DM)
;

-- credential JSON 对 weixin-db 的含义:
-- {
--   "wxid":          "wxid_xxx",       -- 必填(用户从 PC 微信"设置→关于"抄;backend 启动时校验进程匹配)
--   "nickname":      "张三",            -- 启动后自动回填
--   "wechatVersion": "4.1.8.107",      -- 同上
-- }

作废:

  • 2026-05-09 spec 中的 netaclaw_wechat_archive SQLite 归档表 → 不再需要(用户白名单内的群消息直接落 session_entry,非白名单的根本不读)

5.9 · routeInboundMessage 的最终分流逻辑

async routeInboundMessage(channel, state, message) {
  // 按 channel.type 分流
  switch (channel.type) {
    case 'weixin':         // iLink ClawBot(DM only)
      if (message.room_id) return;     // 丢弃任何群消息(ClawBot 实际也收不到,双保险)
      return this.handleIlinkInbound(channel, state, message);

    case 'weixin-db':      // ★新增:本地 DB 读
      if (!message.room_id) {
        // DM 走 ClawBot,丢弃 DB 读出的 DM 避免双渠道重复响应(见 2026-05-09 spec 决策)
        this.logger.debug('[weixin-db] drop DM, use ClawBot for DM');
        return;
      }
      return this.handleDbInbound(channel, state, message);

    default:
      return;
  }
}

handleDbInbound 的代码 99% 复用 handleIlinkInbound:

  • pendingClarify 短路 → 同
  • decideChatScope → 同
  • decideGroupAcceptance → 同(trigger 策略 at_mention/all 仍生效;白名单已经过滤掉非授权群,本层只判 @ 命中等)
  • senderQueue 按 group dispatch key 串行 → 同
  • agentExecutor.execute({ chatScope: 'group', ... }) → 同
  • finalContent → 唯一差别在 sendText:调 weixinDbService.replyToGroup(channelId, roomName, text)(占位,见 5.7)

5.9.5 · 前端"用户主动添加群"UX(替换被动发现)

原 channel-group-panel(被动发现版)作废,改为简单的"白名单管理"形态:

┌─ 群聊白名单 · 老板微信 ──────────────────────[x]─┐
│                                                  │
│ 已添加监管 3 个群           [+ 添加群]             │
│                                                  │
│ ┌──────────────────────────────────────┐         │
│ │ 产品研发群           [启用 ●]  [删除]  │         │
│ │ 添加于 2026-05-12 · 最近消息 12 分钟前 │         │
│ │ 触发策略  (●) @机器人  ( ) 所有消息     │         │
│ │              [保存策略]                 │         │
│ └──────────────────────────────────────┘         │
│                                                  │
│ 💡 群名必须与 PC 微信里完全一致(包括空格、表情)│
│ 💡 未添加的群,bot 不会读取消息内容(隐私保护)   │
└──────────────────────────────────────────────────┘

[+ 添加群] 弹窗:
┌─ 添加群聊 ─┐
│ 群名:    [_________________________]  │
│ 触发策略: (●) @机器人  ( ) 所有消息     │
│              [取消]  [添加]              │
└─────────┘

前端约束:

  • 群名作为白名单 key,用户必须输入与 Weixin UI 完全一致的群名(后端校验 trim 但不做 fuzzy)
  • 同一 channel 下同名群只能加一次(UNIQUE(channelId, roomName) 校验)
  • 删除群 = 立即停止监管(不影响 Weixin 自身,只是 backend 不再读)
  • 不再有"待审批"状态;不再有"忽略"按钮

REST 接口:

POST /admin/netaclaw/channel/group/add        { channelId, roomName, triggerMode } → { id, status: 1 }
POST /admin/netaclaw/channel/group/list        { channelId } → list
POST /admin/netaclaw/channel/group/toggle      { id, status }
POST /admin/netaclaw/channel/group/updatePolicy { id, triggerMode }
POST /admin/netaclaw/channel/group/delete      { ids }

作废接口: 旧 /upsertOnInbound(已被 add 替代)

5.10 · 现有代码需要修改的地方(架构 C 修订)

后端 modifications

文件 改动
packages/backend/src/modules/netaclaw/entity/agent_channel.ts type 字段 enum 加 weixin-db
packages/backend/src/modules/netaclaw/service/agent_channel.ts routeInboundMessagecase 'weixin-db' 分支;handleDbInbound 新增(99% 复用 handleIlinkInbound,仅 sendText 改调 weixinDbService.replyToGroup)
packages/backend/src/modules/netaclaw/service/weixin_db.ts (新增) 完整服务(架构 C):启动时抽 key、维持 wal watcher、解密 + 增量读 + 投递 routeInboundMessage、replyToGroup 占位接口
packages/backend/src/modules/netaclaw/runtime/weixin_db/wcdb_codec.ts (新增) WCDB 解密(直接搬 PoC poc-7f-reserve80.mjs + poc-10)
packages/backend/src/modules/netaclaw/runtime/weixin_db/db_paths.ts (新增) xwechat_files 目录解析
packages/backend/src/modules/netaclaw/runtime/weixin_db/key_extractor.ts (新增) spawn ps1 抽 key + 缓存
packages/backend/src/modules/netaclaw/runtime/weixin_db/message_repo.ts (新增) better-sqlite3 readonly 查询 + zstd 解压
packages/backend/src/modules/netaclaw/runtime/weixin_db/wal_watcher.ts (新增) setInterval 500ms 轮询 wal mtime
packages/backend/src/modules/netaclaw/runtime/weixin_db/incremental_reader.ts (新增) 解密 → 白名单过滤 → 增量读 → 解 zstd
packages/backend/src/modules/netaclaw/runtime/weixin_db/room_resolver.ts (新增) 解析 Msg_ 表名 → 群名 → 白名单匹配
packages/backend/src/modules/netaclaw/service/agent_channel_group.ts 删除 upsertOnInbound(被动发现);新增 addByName(channelId, roomName, triggerMode) 显式添加
packages/backend/src/modules/netaclaw/controller/admin/agent_channel_group.ts endpoint /upsertOnInbound/add;入参变 { channelId, roomName, triggerMode }
packages/backend/tools/win32/extract-weixin-key.ps1 (新增) OpenProcess + 抽 key,JSON stdout 输出
packages/backend/tools/win32/send-weixin-text.ps1 (新增,占位) 5.7 实现时再写;v1 先写 stub 抛 NotImplemented

后端 删除(作废)

文件 理由
packages/backend/src/modules/netaclaw/service/weixin_uia.ts UIA 路径作废
packages/backend/src/modules/netaclaw/controller/open/weixin_uia.ts UIA bridge HTTP 入口作废(架构 C 不需要)
packages/backend/src/modules/netaclaw/runtime/wechat_uia_routing.ts 同上
packages/backend/src/modules/netaclaw/service/wechat_archive.ts 不再需要 SQLite 归档
packages/backend/src/modules/netaclaw/runtime/wechat_archive_schema.ts 同上
packages/backend/src/modules/netaclaw/controller/admin/wechat_archive.ts 同上
packages/backend/src/config/config.default.tsstaticFile.wechatUploads 不再需要图片挂载
packages/backend/src/comm/path.tspWechatUploadsPath 同上

前端 modifications

文件 改动
packages/frontend/src/modules/agent/types/index.d.ts AgentChannelInfo.type 改为 'weixin' | 'weixin-db';AgentGroupItem.status0/1,删 -1
packages/frontend/src/modules/agent/views/channel-management.vue drawer type 下拉:"微信本地代理(群聊 · 需 Windows + PC 微信)";所有 "UIA" 文案改 "本地代理"
packages/frontend/src/modules/agent/components/channel-group-panel.vue 重写 UX:删除"待审批横幅"、"忽略"按钮、被动发现提示;改为"+ 添加群"按钮 + 群卡片(启用 toggle / 触发策略 / 删除)
packages/frontend/src/modules/agent/composables/useUiaChannelValidation.tsuseDbChannelValidation.ts 改名;wxid 唯一性校验逻辑保留

前端 删除

文件 理由
packages/frontend/src/modules/agent/components/wechat-archive-panel.vue 归档面板作废

.NET Bridge — 整个项目删除

文件 理由
packages/windows-tray/Neta.WeChatBridge/ 整个项目 架构 C 不需要独立 bridge;backend 直接处理
packages/windows-tray/Neta.WeChatBridge.Tests/ 同上
packages/windows-tray/Neta.Tray/BridgeProcessManager.cs 不再拉 bridge.exe
packages/windows-tray/Neta.Tray/BridgeHealthPoller.cs 同上
packages/windows-tray/Neta.Tray/TrayApplicationContext.cs 中的 bridge 拉起逻辑 删除相关代码段;Tray 只负责 backend.exe

安装包脚本

文件 改动
packages/backend/scripts/build-windows-installer.js 删除 dotnet publish bridge 步骤;新增"拷贝 backend/tools/win32/*.ps1 到 installDir/tools/" 步骤
packages/backend/installer/setup.iss 删除 bridge.exe 文件项;新增 ps1 文件项

5.11 · 实施优先级(架构 C · 简化版)

Phase 内容 估时 前置
P1 清理 UIA + bridge .NET 项目 + archive 后端代码 0.5d 本 PR
P2 Backend runtime/weixin_db/ 各子模块(wcdb_codec / db_paths / message_repo / room_resolver) 2d P1
P3 Backend weixin_db.ts service 装配 + 启动钩子 + key extractor (含 ps1) 1.5d P2
P4 Backend agent_channel.ts 加 weixin-db 分支 + handleDbInbound 0.5d P3
P5 Backend group service addByName + controller /add 端点 0.5d P1
P6 前端 channel-management 改文案 + channel-group-panel 重写 UX(添加群表单) 1d P5
P7 安装包脚本去 .NET bridge + 加 ps1 拷贝 0.5d P3
P8 Tray 删除 bridge 拉起逻辑 0.25d P7
P9 端到端手工验证 0.5d 全部
P10(占位) 5.7 自动回复实现 TBD P9

合计 ~7 工作日(不含 P10)。对比架构 A 的 10 天 + .NET 维护负担,显著简化。

5.12 · 风险与兜底

风险 可能性 兜底
Weixin 升级后 raw key 在内存中的位置/格式变 KeyExtractor 在 ps1 里做版本兜底,失败提示用户重启 backend
Weixin 改 cipher_compatibility 或换 KDF 低(WCDB 标准) 真改了升级 wcdb_codec.ts 即可,无需重打包
DB 解密频率过高被 antivirus 检测 解密在内存做,只读 DB 文件不写;500ms-1s 防抖
PC 微信未登录 / 未启动 必然 weixinDbService 启动时探测,未就绪 channel.loginStatus='disconnected',前端卡片显示离线;60s 健康探针自动重连
Weixin 进程重启(PID 变) 健康探针检测到 PID 变化或解密失败 → unbindChannel + bindChannel 重跑抽 key 流程
同时登录多个微信号 不存在 PC 微信本身限制一机一号
用户输入群名与 Weixin 不一致 前端校验:群名长度 1-128;后端启动后 RoomResolver 扫描所有 session,若白名单某项匹配不上 → 前端卡片提示"未找到群: "
backend 在非 Windows 平台运行(Linux/Mac dev 机) 必然 weixinDbService.bindChannel 检测 process.platform !== 'win32' 直接 return + 标 loginStatus='unsupported_platform';不 spawn powershell,不让 backend crash
PowerShell 脚本路径解析错误 key_extractor.tsNETA_WEIXIN_KEY_SCRIPT env → <installDir>/tools/win32/*.ps1<cwd>/tools/win32/*.ps1<module>/../../tools/win32/*.ps1 顺序 fallback
5.7 回复部分(未实现) 必然 v1 暴露 replyToGroup 接口但抛 NotImplementedError;handleDbInbound 捕获后写入 sessionEntry "[占位:回复未实现]";不阻塞读链路

5.13 · 与 2026-05-08 原 Phase 成果的关系

原 spec 产出 方案 5 状态
Phase 1 · runtime/chat_scope.ts + runtime/errors.ts 完全保留
Phase 2 · entity/agent_channel_group.ts + service + controller 完全保留
Phase 3 · agent_executor.chatScope + GroupInteractionDeclineError short-circuit 完全保留
Phase 4 · routeInboundMessage 分流 + senderQueue 群级共享 + clarify 短路 保留,只是按 channel.type 加一个 case
Phase 5 · 前端 channel-management / channel-group-panel 保留,仅改文案 + group panel 重构为"用户主动添加"
Phase 6 · "UIA 采集" 相关代码(2026-05-09 的 A/B/C/D) 作废,全删
Phase 7 · iLink ClawBot DM 路径 保留(作为 DM 渠道)

结论:原 2026-05-08 的 20 个 task / 117 单测,95% 资产有效。只替换传输层。


5.14 · 开发与部署形态(架构 C 关键澄清)

DEV 模式工作流

backend 在 DEV 是普通 Node 进程,无需打包成 backend.exe 即可完整开发与调试 weixin-db 渠道:

开发者机器 (Windows + 已登录 Weixin 4.x):
  ├─ packages/backend/  → pnpm dev (Midway tsx HMR)
  │   └─ 改 wcdb_codec.ts / message_repo.ts / weixin_db.ts → 热更新, 无需重启
  ├─ packages/backend/tools/win32/extract-weixin-key.ps1
  │   └─ 改 ps1 → 下次 spawn 直接读最新版, 无需重启 backend
  ├─ packages/frontend/  → pnpm dev (Vite HMR)
  └─ Weixin.exe         → 用户保持登录

只在以下三个时机需要打包成 exe:

  1. 端到端验证 installer 把 ps1 拷到正确路径
  2. 给非开发者(测试用户)分发安装包
  3. CI 烟囱测试

绝大多数日常开发改动(代码、UI、PoC、测试)都用 pnpm dev 完成

PowerShell 脚本路径解析(robust 兜底)

PS 脚本是纯文本资源,需在 DEV / pkg 打包 / installer 安装三种环境定位到。key_extractor.ts 按以下顺序 fallback:

function resolveExtractorScript(): string {
  // 1. 环境变量(覆盖一切)
  if (process.env.NETA_WEIXIN_KEY_SCRIPT) return process.env.NETA_WEIXIN_KEY_SCRIPT;

  // 2. 模块自身相对路径(DEV / pkg 都行)
  //    runtime/weixin_db/key_extractor.ts → ../../../../tools/win32/extract-weixin-key.ps1
  const moduleRel = path.resolve(__dirname, '..', '..', '..', '..', 'tools', 'win32', 'extract-weixin-key.ps1');
  if (fs.existsSync(moduleRel)) return moduleRel;

  // 3. installer 部署路径: <installDir>/tools/win32/*.ps1
  const installDir = process.env.NETA_INSTALL_DIR || path.dirname(process.execPath);
  const installRel = path.join(installDir, 'tools', 'win32', 'extract-weixin-key.ps1');
  if (fs.existsSync(installRel)) return installRel;

  // 4. 当前工作目录 fallback
  const cwdRel = path.join(process.cwd(), 'tools', 'win32', 'extract-weixin-key.ps1');
  if (fs.existsSync(cwdRel)) return cwdRel;

  throw new Error('extract-weixin-key.ps1 not found in any candidate path');
}

跨平台兜底

backend 跨平台编译(Linux/Mac/Windows),但 weixin-db 只在 Windows 有意义:

// service/weixin_db.ts
async bindChannel(channel, onInbound) {
  if (process.platform !== 'win32') {
    this.logger.warn('[weixin-db] non-Windows platform (%s), channel %s skipped', process.platform, channel.id);
    // 标记 channel.loginStatus = 'unsupported_platform' (前端卡片显示)
    await this.channelRepo.update({ id: channel.id }, { loginStatus: 'unsupported_platform' });
    return;
  }
  // ...正常流程
}

效果:

  • Linux/Mac dev 机能完整跑 backend / 前端 / 单测(只是 weixin-db channel 不工作,其他功能正常)
  • 单测里所有 weixin_db service 测试用 jest mock,不依赖真实 powershell.exe → 跨平台跑通

健康探针 / 自动重连

// service/weixin_db.ts 新增
@Init()
async onInit() {
  setInterval(() => this.healthCheck().catch(() => void 0), 60_000);
}

private async healthCheck() {
  for (const [cid, runtime] of this.runtimes) {
    try {
      // 探测 Weixin 进程仍在 + DB 文件可读
      const ok = await this.probeAlive(runtime);
      if (!ok) {
        this.logger.warn('[weixin-db] cid=%s lost connection, rebinding', cid);
        this.unbindChannel(cid);
        const channel = await this.channelRepo.findOne({ where: { id: cid } });
        if (channel) await this.bindChannel(channel, this.cachedOnInbound);
      }
    } catch (err: any) {
      this.logger.error('[weixin-db] healthCheck cid=%s: %s', cid, err.message);
    }
  }
}

实施里程碑(明确优先级)

里程碑 范围 价值
M1 · 读 + 展现 Phase C-0 / C-1 / C-2 / C-3 / C-4 + Phase C-5 的 addByName 用户能添加群名,backend 实时读到群消息,落 sessionEntry,前端 chat 页能看到对话流
M2 · 完整 UX Phase C-6 / C-7 / C-8 前端"+ 添加群"完整体验,安装包能装,E2E 跑通
M3 · 自动回复(留 v2) Phase C-9(独立 spec) replyToGroup 真实实现

M1 是最小可演示版本:加群 → 看到群消息流到 chat 页面。回复占位即可。