GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-05-09-wechat-uia-c-backend-adapter.md
2026-05-20 21:39:12 +08:00

84 KiB

WeChat UIA Phase C · Backend 适配 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 在 Node 后端打通 UIA 渠道:channel.type='weixin-uia' 多态、bridge HTTP 客户端 (/health /send /rooms /enable-room)、入站 controller (/open/netaclaw/channel/uia/handshake + /inbound)、SQLite 全量归档、routeInboundMessage 按 channel.type 分流、跨渠道丢弃约束、replyIdentity 包装、boundAgentId 覆盖、agent 返回 [SKIP]/空串时不 sendText。本 plan 完成后,Plan A+B 的 bridge 真实可对接 backend,端到端 (无前端) 走通。

Architecture: 沿用今天 (2026-05-08) routeInboundMessage 同步分发器的设计——再加一个 channel.type 维度的 switch:weixin 走原 iLink 路径 (runLoop 拉取),weixin-uia 不起 runLoop,改由 bridge 主动 POST /inbound 触发 ingestUiaInbound,后者构造 "伪 message" 走同一 routeInboundMessage 流水。SQLite 归档独立服务 wechat_archive.tsbetter-sqlite3,每 channel 一文件 dataDir/wechat-archive-<cid>.db,在 ingest 入口先无条件落库 (triggerAccepted=0),acceptance 通过后再 update (triggerAccepted=1, sessionEntryId)。weixin_uia.ts 是 bridge HTTP 客户端,带 tray-secret header + 30s 失活检测。replyIdentityhandleInboundMessage 末尾发送前包装。boundAgentId 覆盖在 group 路径 effective agent 选择处生效。跨渠道严格丢弃:weixin 拒收 group、weixin-uia 拒收 dm。

Tech Stack: TypeScript 5.9 / Midway.js 3.20 / TypeORM 0.3 / Jest (ts-jest) / better-sqlite3 12.8 (已有依赖) / axios 1.12 (已有依赖)。

Spec: docs/superpowers/specs/2026-05-09-wechat-uia-channel-design.md

前置依赖: Plan A + Plan B 不强依赖 (可并行实施),但端到端验证需要 Plan A+B 的 bridge 启动后才能跑。

关键约束:

  • SQLite per-channel 独立文件:dataDir/wechat-archive-<cid>.db,通过 WechatArchiveService.openForChannel(channelId) lazy-init + 进程内 LRU 缓存 (避免每次开关连接)。
  • better-sqlite3 天然同步,本 plan 的 WechatArchiveService 方法也用同步签名(不做 async Promise 包装,避免虚假开销;与 TypeORM repo 风格不同但更诚实)。
  • handshake body 必须包含 { wxid, nickname, wechatVersion, bridgeBaseUrl } 四字段——bridgeBaseUrl跨 plan 契约补丁(Plan A Task 13 + Plan B Task 8 需同步加该字段),backend 拿到后才能调 registerBridge 构造 HTTP 客户端。无 bridgeBaseUrl → backend 无法主动调 bridge(架构师审查 C1/S3)。
  • handshake 响应必须返回 { ok: 1, channelId, enabledRooms } 严格 schema——Plan B Task 8 已在 Bridge 端硬编码这个 schema,违反就破坏 Plan B
  • 跨渠道丢弃约束写在 routeInboundMessage 入口,而不是 ingest 适配层——保证未来再加 channel.type 时丢弃逻辑集中。
  • 与 2026-05-08 group channel plan 的兼容性:今天实施的 group path 处理代码保留(可视为 dead code,iLink 实际不发群消息);Plan C Task 9 的 drop 是 defense-in-depth,但会破坏 2026-05-08 的 routeInboundMessage group 路径测试—那些测试必须同步改为 type: 'weixin-uia',否则会失败(Task 9 Step 2.5 专门处理)。
  • wxid ↔ weixin-uia channel 一对一:同一 wxid 只能绑一个 UIA channel,onUiaHandshake 检查到多个时抛错,前端 channel 新建页也做同等校验(Plan D 补)。
  • iLink ClawBot 不接收 group 消息:今天 task 已实现 (在 routeInboundMessage 检查 scope.kind),保持不动;weixin-uia 拒 dm 是新增。
  • Bridge 离线检测:WeixinUiaService 内部维护 lastHealthAt Map,agent_channel_group.list 时附带 bridgeOnline 标志;每 30 秒 backend 主动 GET /health,连续 2 次失败 → channel.loginStatus='disconnected'。但这部分只起一个轻量定时器,不阻塞业务。
  • group.status 变更时主动调 bridge enableRoom/disableRoom:前端点"启用监听"→ backend toggle(id, 1) → 读取 group.roomName → 调 weixinUiaService.enableRoom(channelId, roomName);disable 同理(架构师审查 S3)。
  • boundAgentId 覆盖时,agentName 传 undefined:让 agentExecutor 自行按 agentId 查名,避免 channel.agentName 与实际 bound agent 不一致(架构师审查 C5)。
  • triggerMode 前端只暴露 at_mention / all,prefix 在 service 写入层拒绝,但 decideGroupAcceptance 纯函数仍容忍存量数据(架构师审查 S1/S7)。
  • 不实现:前端 UI / 待审批横幅 / 归档查看抽屉 (Plan D)、Tray 拉 bridge / 安装包 (Plan D Phase E)、wechat-uploads 静态 serve (沿用现有 /upload 静态文件服务即可,只把 dataDir/wechat-uploads 软链接或直接挂载)。
  • 测试不连真实 bridge:WeixinUiaService 注入可替换的 axios-like client,测试用 nock (需新增 devDep,Task 8 Step 0 里装)。SQLite 测试用临时文件目录 + Service.openForChannel 的可注入 dataDir。

文件结构

新增

文件 责任
packages/backend/src/modules/netaclaw/service/weixin_uia.ts Bridge HTTP 客户端:sendText / getRooms / enableRoom / disableRoom / health + 健康轮询
packages/backend/src/modules/netaclaw/service/wechat_archive.ts SQLite 归档服务:recordInbound (新写一行)、markAccepted / markRejected / query
packages/backend/src/modules/netaclaw/runtime/wechat_archive_schema.ts SQLite DDL + 升级脚本 (字段表见 spec)
packages/backend/src/modules/netaclaw/runtime/wechat_uia_routing.ts 纯函数:把 bridge POST inbound 的 raw payload 转成"伪 message" + 处理 replyIdentity
packages/backend/src/modules/netaclaw/controller/open/weixin_uia.ts /open/netaclaw/channel/uia/handshake + /inbound,带 x-neta-tray-secret 校验
packages/backend/src/modules/netaclaw/runtime/tray_secret.ts 共享:从 process.env.NETA_TRAY_SECRETdataDir/runtime/tray-info.json 读 secret + 恒定时间比较
packages/backend/test/modules/netaclaw/runtime/wechat_uia_routing.test.ts 路由纯函数
packages/backend/test/modules/netaclaw/runtime/tray_secret.test.ts secret 加载 + 比较
packages/backend/test/modules/netaclaw/service/weixin_uia.test.ts bridge HTTP 客户端
packages/backend/test/modules/netaclaw/service/wechat_archive.test.ts SQLite 写入 / 状态回写 / 查询
packages/backend/test/modules/netaclaw/service/agent_channel.uia.test.ts UIA 渠道 routeInboundMessage 分流 + 跨渠道丢弃 + replyIdentity + boundAgentId
packages/backend/test/modules/netaclaw/controller/open_weixin_uia.test.ts controller 集成 (用 midway test framework)

修改

文件 改动
packages/backend/src/modules/netaclaw/entity/agent_channel.ts 注释扩展 type 合法值 weixin / weixin-uia (字段长度足够,无需 ALTER)
packages/backend/src/modules/netaclaw/entity/agent_channel_group.ts + boundAgentId: number | null (nullable) + replyIdentityOverride: string | null (nullable)
packages/backend/src/modules/netaclaw/service/agent_channel_group.ts updatePolicy triggerMode 合法集改为 at_mention/all (prefix 抛 400 兼容存量数据);新增 setBoundAgent(id, agentId|null) / setReplyIdentityOverride(id, value|null)
packages/backend/src/modules/netaclaw/service/agent_channel.ts routeInboundMessage 入口加 channel.type switch + 跨渠道丢弃;syncRunner:weixin-uia 不起 runLoop;handleInboundMessage 末尾按 replyIdentity 包装文字 + boundAgentId 覆盖;delete 时关闭对应 archive db
packages/backend/src/modules/netaclaw/service/agent_channel.ts options() types 列表加 weixin-uia
packages/backend/src/configuration.ts (或 src/modules/netaclaw/index.ts) 注册 controller open/weixin_uia

不修改:runtime/chat_scope.ts (P2-6 alias 边界正则保持,中文标点支持已有) / runtime/errors.ts / service/agent_executor.ts / controller/admin/agent_channel_group.ts


Phase 1 · 纯函数层

Task 1: tray_secret 共享读取

Files:

  • Create: packages/backend/src/modules/netaclaw/runtime/tray_secret.ts
  • Test: packages/backend/test/modules/netaclaw/runtime/tray_secret.test.ts

Plan B Bridge 用 x-neta-tray-secret;Backend 必须能用同一 secret 校验 incoming 请求。Tray (现有) 在启动时把 secret 写到 dataDir/runtime/tray-info.json;直接命令行启动时走 --tray-secret 参数 → process.env。

  • Step 1: 写失败测试
import { resolveTraySecret, fixedTimeCompare } from '../../../../src/modules/netaclaw/runtime/tray_secret.js';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';

describe('resolveTraySecret', () => {
  let tmpDir: string;
  beforeEach(() => {
    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'neta-secret-'));
  });
  afterEach(() => {
    delete process.env.NETA_TRAY_SECRET;
    try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
  });

  it('returns env value when set', () => {
    process.env.NETA_TRAY_SECRET = 'env-sec';
    expect(resolveTraySecret(tmpDir)).toBe('env-sec');
  });

  it('falls back to tray-info.json', () => {
    fs.mkdirSync(path.join(tmpDir, 'runtime'), { recursive: true });
    fs.writeFileSync(
      path.join(tmpDir, 'runtime', 'tray-info.json'),
      JSON.stringify({ traySecret: 'file-sec', controlBaseUrl: 'http://x' }),
    );
    expect(resolveTraySecret(tmpDir)).toBe('file-sec');
  });

  it('returns null when neither source is available', () => {
    expect(resolveTraySecret(tmpDir)).toBeNull();
  });

  it('env wins over file', () => {
    process.env.NETA_TRAY_SECRET = 'env-sec';
    fs.mkdirSync(path.join(tmpDir, 'runtime'), { recursive: true });
    fs.writeFileSync(
      path.join(tmpDir, 'runtime', 'tray-info.json'),
      JSON.stringify({ traySecret: 'file-sec' }),
    );
    expect(resolveTraySecret(tmpDir)).toBe('env-sec');
  });

  it('ignores empty traySecret in file', () => {
    fs.mkdirSync(path.join(tmpDir, 'runtime'), { recursive: true });
    fs.writeFileSync(
      path.join(tmpDir, 'runtime', 'tray-info.json'),
      JSON.stringify({ traySecret: '' }),
    );
    expect(resolveTraySecret(tmpDir)).toBeNull();
  });
});

describe('fixedTimeCompare', () => {
  it('returns true for equal strings', () => {
    expect(fixedTimeCompare('abc', 'abc')).toBe(true);
  });
  it('returns false for different strings of equal length', () => {
    expect(fixedTimeCompare('abc', 'abd')).toBe(false);
  });
  it('returns false for different lengths without short-circuit', () => {
    expect(fixedTimeCompare('a', 'abc')).toBe(false);
    expect(fixedTimeCompare('abc', 'a')).toBe(false);
  });
  it('returns false for null/undefined inputs', () => {
    expect(fixedTimeCompare('a', '')).toBe(false);
    expect(fixedTimeCompare('', 'a')).toBe(false);
    expect(fixedTimeCompare('', '')).toBe(true);
  });
});
  • Step 2: 运行测试确认失败
cd packages/backend && npx cross-env NODE_ENV=unittest jest test/modules/netaclaw/runtime/tray_secret.test.ts
  • Step 3: 实现
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';

export function resolveTraySecret(dataDir: string): string | null {
  const env = (process.env.NETA_TRAY_SECRET || '').trim();
  if (env) return env;

  try {
    const file = path.join(dataDir, 'runtime', 'tray-info.json');
    if (!fs.existsSync(file)) return null;
    const json = JSON.parse(fs.readFileSync(file, 'utf8')) as { traySecret?: string };
    const sec = (json.traySecret || '').trim();
    return sec || null;
  } catch {
    return null;
  }
}

/** 恒定时间比较,防止 secret 通过响应时间被探测。 */
export function fixedTimeCompare(a: string, b: string): boolean {
  const aBuf = Buffer.from(a ?? '', 'utf8');
  const bBuf = Buffer.from(b ?? '', 'utf8');
  if (aBuf.length !== bBuf.length) {
    // 仍然做一次等长比较,避免长度差被时间侧测出
    crypto.timingSafeEqual(aBuf, aBuf);
    return false;
  }
  return crypto.timingSafeEqual(aBuf, bBuf);
}
  • Step 4: 测试通过

Expected:Tests: 9 passed

  • Step 5: Commit
git add packages/backend/src/modules/netaclaw/runtime/tray_secret.ts \
        packages/backend/test/modules/netaclaw/runtime/tray_secret.test.ts
git commit -m "feat(netaclaw): add tray_secret resolver with fixed-time compare"

Task 2: wechat_uia_routing 纯函数

Files:

  • Create: packages/backend/src/modules/netaclaw/runtime/wechat_uia_routing.ts
  • Test: packages/backend/test/modules/netaclaw/runtime/wechat_uia_routing.test.ts

把 bridge inbound payload 转成 "伪 message" (与 iLink 入站 shape 一致),让下游 routeInboundMessage 无感分流。同时定义 replyIdentity 包装逻辑。

  • Step 1: 写失败测试
import {
  buildPseudoMessageFromUia,
  resolveReplyIdentity,
  wrapWithReplyIdentity,
  type UiaInboundPayload,
} from '../../../../src/modules/netaclaw/runtime/wechat_uia_routing.js';

describe('buildPseudoMessageFromUia', () => {
  const base: UiaInboundPayload = {
    channelId: 7,
    roomName: '产品群',
    senderName: '小王',
    msgType: 'text',
    content: '你好',
    rawHash: 'hash-1',
    receivedAt: '2026-05-09T12:30:00.000Z',
  };

  it('produces room_id stable hash sha1(roomName) — channelId-prefixed', () => {
    const m = buildPseudoMessageFromUia(base);
    expect(m.room_id).toMatch(/^7:room:[a-f0-9]{40}$/);
  });

  it('uses senderName as from_user_id and rawHash as message_id', () => {
    const m = buildPseudoMessageFromUia(base);
    expect(m.from_user_id).toBe('小王');
    expect(m.message_id).toBe('hash-1');
  });

  it('puts content in item_list[0].text_item.text', () => {
    const m = buildPseudoMessageFromUia(base);
    expect(m.item_list).toEqual([{ type: 1, text_item: { text: '你好' } }]);
  });

  it('passes through atList as at_user_list', () => {
    const m = buildPseudoMessageFromUia({ ...base, atList: ['小神', 'lisa'] });
    expect(m.at_user_list).toEqual(['小神', 'lisa']);
  });

  it('attaches attachments when attachmentPath present', () => {
    const m = buildPseudoMessageFromUia({
      ...base, msgType: 'image', attachmentPath: '/data/x.jpg',
    });
    expect(m.attachments).toEqual([{ kind: 'image', path: '/data/x.jpg' }]);
  });

  it('preserves quoted_ref structure', () => {
    const m = buildPseudoMessageFromUia({
      ...base,
      quotedRef: { senderName: '老板', preview: '上线' },
    });
    expect(m.quoted_ref).toEqual({ sender_name: '老板', preview: '上线' });
  });

  it('keeps room_name field for downstream group name extraction', () => {
    const m = buildPseudoMessageFromUia(base);
    expect(m.room_name).toBe('产品群');
  });
});

describe('resolveReplyIdentity', () => {
  it('per-group override takes precedence', () => {
    expect(resolveReplyIdentity({
      channel: { config: { group: { replyIdentity: 'silent' } } } as any,
      groupOverride: 'ai_prefix',
    })).toBe('ai_prefix');
  });

  it('falls back to channel default', () => {
    expect(resolveReplyIdentity({
      channel: { config: { group: { replyIdentity: 'ai_prefix' } } } as any,
      groupOverride: null,
    })).toBe('ai_prefix');
  });

  it('defaults to silent when nothing set', () => {
    expect(resolveReplyIdentity({
      channel: {} as any,
      groupOverride: null,
    })).toBe('silent');
  });

  it('clamps invalid value to silent', () => {
    expect(resolveReplyIdentity({
      channel: { config: { group: { replyIdentity: 'bogus' } } } as any,
      groupOverride: null,
    })).toBe('silent');
  });
});

describe('wrapWithReplyIdentity', () => {
  it('silent returns text as-is', () => {
    expect(wrapWithReplyIdentity('hello', 'silent')).toBe('hello');
  });

  it('ai_prefix prepends 【AI 助手】', () => {
    expect(wrapWithReplyIdentity('hello', 'ai_prefix')).toBe('【AI 助手】hello');
  });

  it('does not double-prefix if already prefixed', () => {
    expect(wrapWithReplyIdentity('【AI 助手】hi', 'ai_prefix')).toBe('【AI 助手】hi');
  });
});
  • Step 2: 运行测试确认失败

  • Step 3: 实现

import * as crypto from 'crypto';

export interface UiaInboundPayload {
  channelId: number;
  roomName: string;
  senderName: string;
  msgType: 'text' | 'image' | 'file' | 'voice' | 'video' | 'system' | 'quote';
  content: string;
  rawHash: string;
  receivedAt: string;
  attachmentPath?: string | null;
  atList?: string[] | null;
  quotedRef?: { senderName: string; preview: string } | null;
}

export interface UiaPseudoMessage {
  from_user_id: string;
  room_id: string;
  room_name: string;
  message_id: string;
  item_list: Array<{ type: number; text_item: { text: string } }>;
  at_user_list?: string[];
  attachments?: Array<{ kind: string; path: string }>;
  quoted_ref?: { sender_name: string; preview: string };
  __uia: true; // 内部标记,routeInboundMessage 可识别 UIA 入口
}

export function buildPseudoMessageFromUia(p: UiaInboundPayload): UiaPseudoMessage {
  const roomNameHash = crypto.createHash('sha1').update(p.roomName, 'utf8').digest('hex');
  const roomId = `${p.channelId}:room:${roomNameHash}`;

  const msg: UiaPseudoMessage = {
    from_user_id: p.senderName,
    room_id: roomId,
    room_name: p.roomName,
    message_id: p.rawHash,
    item_list: [{ type: 1, text_item: { text: p.content } }],
    __uia: true,
  };

  if (p.atList && p.atList.length > 0) msg.at_user_list = [...p.atList];
  if (p.attachmentPath) {
    msg.attachments = [{ kind: p.msgType, path: p.attachmentPath }];
  }
  if (p.quotedRef) {
    msg.quoted_ref = {
      sender_name: p.quotedRef.senderName,
      preview: p.quotedRef.preview,
    };
  }
  return msg;
}

export type ReplyIdentity = 'silent' | 'ai_prefix';

export function resolveReplyIdentity(args: {
  channel: { config?: { group?: { replyIdentity?: string } } };
  groupOverride: string | null;
}): ReplyIdentity {
  const fromGroup = (args.groupOverride || '').trim();
  if (fromGroup === 'silent' || fromGroup === 'ai_prefix') return fromGroup;
  const fromChannel = (args.channel.config?.group?.replyIdentity || '').trim();
  if (fromChannel === 'silent' || fromChannel === 'ai_prefix') return fromChannel;
  return 'silent';
}

const AI_PREFIX = '【AI 助手】';

export function wrapWithReplyIdentity(text: string, identity: ReplyIdentity): string {
  if (identity === 'silent') return text;
  if (text.startsWith(AI_PREFIX)) return text;
  return AI_PREFIX + text;
}
  • Step 4: 测试通过

Expected:Tests: 14 passed (7 + 4 + 3)。

  • Step 5: Commit
git add packages/backend/src/modules/netaclaw/runtime/wechat_uia_routing.ts \
        packages/backend/test/modules/netaclaw/runtime/wechat_uia_routing.test.ts
git commit -m "feat(netaclaw): add wechat_uia_routing pure functions"

Phase 2 · Entity 扩展

Task 3: agent_channel_group 扩展 boundAgentId + replyIdentityOverride

Files:

  • Modify: packages/backend/src/modules/netaclaw/entity/agent_channel_group.ts

  • Create: packages/backend/sql/migration/2026-05-09_uia_group_extensions.sql

  • Step 1: 修改 Entity

替换 packages/backend/src/modules/netaclaw/entity/agent_channel_group.ts:

import { BaseEntity } from '../../base/entity/base.js';
import { Column, Entity, Index } from 'typeorm';

@Index(['channelId', 'roomId'], { unique: true })
@Entity('netaclaw_agent_channel_group')
export class NetaClawAgentChannelGroupEntity extends BaseEntity {
  @Column({ comment: '所属频道 ID' })
  channelId: number;

  @Column({ comment: '微信群 roomId,如 12345@chatroom 或 UIA 生成的 ${cid}:room:sha1(name)', length: 255 })
  roomId: string;

  @Column({ comment: '群名称', length: 256, nullable: true })
  roomName: string | null;

  @Index()
  @Column({ comment: '状态 0=禁用(默认) 1=启用 -1=已否决/忽略', default: 0 })
  status: number;

  @Column({ comment: '触发策略 at_mention / all (prefix 保留兼容存量数据)', length: 32, default: 'at_mention' })
  triggerMode: string;

  @Column({ comment: '前缀触发时的前缀 (保留兼容,前端不暴露)', length: 64, nullable: true })
  triggerPrefix: string | null;

  @Column({ comment: '每群绑定的 Agent ID;null 回落 channel.agentId', nullable: true })
  boundAgentId: number | null;

  @Column({ comment: '每群回复身份 silent / ai_prefix;null 回落 channel.config.group.replyIdentity', length: 16, nullable: true })
  replyIdentityOverride: string | null;

  @Column({ comment: '首次被发现时间(ISO 字符串)', length: 32, nullable: true })
  firstSeenAt: string | null;

  @Column({ comment: '最近一次群消息到达时间', length: 32, nullable: true })
  lastSeenAt: string | null;

  @Column({ comment: '最近一次 bot 回复时间', length: 32, nullable: true })
  lastActiveAt: string | null;
}
  • Step 2: 写 migration SQL
-- 2026-05-09_uia_group_extensions.sql

ALTER TABLE `netaclaw_agent_channel_group`
  ADD COLUMN `boundAgentId` int NULL COMMENT '每群绑定的 Agent ID;null 回落 channel.agentId' AFTER `triggerPrefix`,
  ADD COLUMN `replyIdentityOverride` varchar(16) NULL COMMENT '每群回复身份 silent / ai_prefix' AFTER `boundAgentId`;

-- status 字段原本 default 0,现在语义扩展:0=禁用 / 1=启用 / -1=已否决
-- 不需要 ALTER,int 可以存负值
  • Step 3: 启动一次本地 backend 触发 TypeORM 自动同步 (开发/测试环境 synchronize=true)
cd packages/backend && pnpm dev
# 启动后 Ctrl+C

Expected:DB 表新增 2 列,现有数据不动。若 production 需手动执行 migration SQL。

  • Step 4: Commit
git add packages/backend/src/modules/netaclaw/entity/agent_channel_group.ts \
        packages/backend/sql/migration/2026-05-09_uia_group_extensions.sql
git commit -m "feat(netaclaw): extend channel_group with boundAgentId + replyIdentityOverride"

Phase 3 · Service — group/updatePolicy + setters + UIA 合法 type

Task 4: agent_channel_group service 加 setter + triggerMode 收敛

Files:

  • Modify: packages/backend/src/modules/netaclaw/service/agent_channel_group.ts

  • Modify: packages/backend/test/modules/netaclaw/service/agent_channel_group.test.ts (已有)

  • Step 1: 写失败测试 (追加)

在已有 agent_channel_group.test.ts 末尾追加:

describe('updatePolicy — UIA 迁移后只接受 at_mention/all', () => {
  it('rejects prefix mode with clear error', async () => {
    const svc = ...; // 复用 beforeEach 里 setup
    const group = await createTestGroup(svc);
    await expect(svc.updatePolicy(group.id, 'prefix', 'hi'))
      .rejects.toThrow(/prefix mode 已弃用/);
  });

  it('accepts at_mention', async () => {
    const svc = ...;
    const group = await createTestGroup(svc);
    await svc.updatePolicy(group.id, 'at_mention', null);
    const after = await svc.findById(group.id);
    expect(after?.triggerMode).toBe('at_mention');
  });

  it('accepts all', async () => {
    const svc = ...;
    const group = await createTestGroup(svc);
    await svc.updatePolicy(group.id, 'all', null);
    const after = await svc.findById(group.id);
    expect(after?.triggerMode).toBe('all');
  });

  it('rejects unknown modes', async () => {
    const svc = ...;
    const group = await createTestGroup(svc);
    await expect(svc.updatePolicy(group.id, 'bogus', null))
      .rejects.toThrow(/invalid triggerMode/);
  });
});

describe('setBoundAgent', () => {
  it('persists new agent id', async () => {
    const svc = ...;
    const group = await createTestGroup(svc);
    await svc.setBoundAgent(group.id, 42);
    const after = await svc.findById(group.id);
    expect(after?.boundAgentId).toBe(42);
  });

  it('accepts null to clear', async () => {
    const svc = ...;
    const group = await createTestGroup(svc);
    await svc.setBoundAgent(group.id, 42);
    await svc.setBoundAgent(group.id, null);
    const after = await svc.findById(group.id);
    expect(after?.boundAgentId).toBeNull();
  });
});

describe('setReplyIdentityOverride', () => {
  it.each(['silent', 'ai_prefix', null])('accepts %s', async (value) => {
    const svc = ...;
    const group = await createTestGroup(svc);
    await svc.setReplyIdentityOverride(group.id, value as any);
    const after = await svc.findById(group.id);
    expect(after?.replyIdentityOverride).toBe(value);
  });

  it('rejects invalid string', async () => {
    const svc = ...;
    const group = await createTestGroup(svc);
    await expect(svc.setReplyIdentityOverride(group.id, 'bogus'))
      .rejects.toThrow(/invalid replyIdentityOverride/);
  });
});

测试中的 ... 替换为现有测试文件的 setup 代码 (复用 beforeEach)。

  • Step 2: 修改 service 实现

替换 updatePolicy + 追加 setBoundAgent / setReplyIdentityOverride:

async updatePolicy(id: number, triggerMode: string, triggerPrefix?: string | null): Promise<void> {
  // UIA 迁移后:`prefix` 弃用,保留字段仅兼容存量数据
  if (triggerMode === 'prefix') {
    throw new Error('prefix mode 已弃用,请改用 at_mention 或 all');
  }
  const validModes = new Set(['at_mention', 'all']);
  if (!validModes.has(triggerMode)) {
    throw new Error(`invalid triggerMode: ${triggerMode}`);
  }
  await this.groupRepo.update({ id }, { triggerMode, triggerPrefix: null });
}

async setBoundAgent(id: number, agentId: number | null): Promise<void> {
  await this.groupRepo.update({ id }, { boundAgentId: agentId });
}

async setReplyIdentityOverride(id: number, value: 'silent' | 'ai_prefix' | null): Promise<void> {
  if (value !== null && value !== 'silent' && value !== 'ai_prefix') {
    throw new Error(`invalid replyIdentityOverride: ${value}`);
  }
  await this.groupRepo.update({ id }, { replyIdentityOverride: value });
}

同时修改 toggle 方法,让它主动通知 bridge(架构师审查 S3):

async toggle(id: number, status: 0 | 1 | -1): Promise<void> {
  if (status !== 0 && status !== 1 && status !== -1) {
    throw new Error(`invalid status value: ${status}`);
  }
  const before = await this.findById(id);
  await this.groupRepo.update({ id }, { status });
  if (!before) return;

  // UIA 渠道:status 变 1 → 通知 bridge 开始监听该群;变 0/-1 → 通知停止
  // 延迟 require 避免循环依赖 (group service → channel service → group service)
  try {
    const { NetaClawWeixinUiaService } =
      require('./weixin_uia.js') as typeof import('./weixin_uia.js');
    // 实际注入 WeixinUiaService 走 DI,此处伪代码表意;
    // 在 agent_channel.ts 里 toggle 是由 channel 层包装一次调用更合适
  } catch { /* ignore */ }
}

🧭 架构决策:toggle 主动通知 bridge 的逻辑放在 NetaClawAgentChannelService 的 wrapper 方法 里更合适(因为需要 channel.type + channel.id → bridgeUrl → uiaService),而不是放在 groupService(循环依赖风险)。实际实现见 Task 5.5。

  • Step 3: 运行所有 group service 测试
cd packages/backend && npx cross-env NODE_ENV=unittest jest test/modules/netaclaw/service/agent_channel_group.test.ts

Expected:原有测试 + 新增 11 个 pass。updatePolicy 原本测 prefix 的测试改为 expect throw

  • Step 4: Commit
git add packages/backend/src/modules/netaclaw/service/agent_channel_group.ts \
        packages/backend/test/modules/netaclaw/service/agent_channel_group.test.ts
git commit -m "feat(netaclaw): channel_group add bind/identity setters + triggerMode collapse"

Task 5: admin controller — updatePolicy 错误信息 + 新增 setBoundAgent/setReplyIdentity 路由

Files:

  • Modify: packages/backend/src/modules/netaclaw/controller/admin/agent_channel_group.ts

前端 Plan D 会调这些接口。本 Task 先把接口铺出来。

  • Step 1: 追加路由

NetaClawAgentChannelGroupAdminController 尾部加:

  @Post('/setBoundAgent')
  async setBoundAgent(@Body() body: { id: number; agentId: number | null }) {
    if (!body?.id) return { code: 1003, message: 'id is required' };
    try {
      await this.groupService.setBoundAgent(body.id, body.agentId ?? null);
    } catch (err: any) {
      return { code: 1003, message: err?.message || 'setBoundAgent failed' };
    }
    return { code: 1000, message: 'success' };
  }

  @Post('/setReplyIdentity')
  async setReplyIdentity(@Body() body: { id: number; value: 'silent' | 'ai_prefix' | null }) {
    if (!body?.id) return { code: 1003, message: 'id is required' };
    try {
      await this.groupService.setReplyIdentityOverride(body.id, body.value ?? null);
    } catch (err: any) {
      return { code: 1003, message: err?.message || 'setReplyIdentity failed' };
    }
    return { code: 1000, message: 'success' };
  }

同时修改 updatePolicy 里的 at_mention 分支,对 UIA 渠道不要求 botAlias (UIA 渠道可以纯 @ 昵称) — 用 channel.type 区分:

  @Post('/updatePolicy')
  async updatePolicy(@Body() body: { id: number; triggerMode: string; triggerPrefix?: string | null }) {
    if (!body?.id) return { code: 1003, message: 'id is required' };
    if (body.triggerMode === 'at_mention') {
      const entity = await this.groupService.findById(body.id);
      if (entity) {
        const channel = await this.channelService.info(entity.channelId);
        // UIA 渠道不强制要 botAlias:微信昵称本身就是 @ 目标
        if (channel?.type === 'weixin') {
          const alias = (channel?.config as any)?.group?.botAlias;
          if (!alias || !String(alias).trim()) {
            return {
              code: 1003,
              message: '使用 @机器人 触发前,请先在频道编辑页填写 "微信机器人昵称"',
            };
          }
        }
      }
    }
    try {
      await this.groupService.updatePolicy(body.id, body.triggerMode, body.triggerPrefix);
    } catch (err: any) {
      return { code: 1003, message: err?.message || 'updatePolicy failed' };
    }
    return { code: 1000, message: 'success' };
  }
  • Step 2: Commit
git add packages/backend/src/modules/netaclaw/controller/admin/agent_channel_group.ts
git commit -m "feat(netaclaw): admin routes for setBoundAgent/setReplyIdentity + UIA-aware policy"

Task 5.5: Channel wrapper — toggle 时主动同步 bridge enableRoom

Files:

  • Modify: packages/backend/src/modules/netaclaw/service/agent_channel.ts
  • Modify: packages/backend/src/modules/netaclaw/controller/admin/agent_channel_group.ts

架构师审查 S3:spec 原本没说清 /enable-room 谁调。决策——前端点"启用监听" → admin controller /toggle → channel service toggleGroupAndNotify(groupId, status) → 更新 DB + 若 channel.type=='weixin-uia' 且 status=1,调 weixinUiaService.enableRoom(channelId, roomName)。避免 groupService 直接依赖 uiaService 造成循环。

  • Step 1: 在 agent_channel.ts 加 wrapper
async toggleGroupAndNotify(groupId: number, status: 0 | 1 | -1): Promise<void> {
  const group = await this.groupService.findById(groupId);
  if (!group) throw new Error('group not found');
  await this.groupService.toggle(groupId, status);
  if (!group.roomName) return;

  const channel = await this.info(group.channelId);
  if (!channel || channel.type !== 'weixin-uia') return;

  try {
    if (status === 1) {
      await this.weixinUiaService.enableRoom(channel.id, group.roomName);
    } else {
      await this.weixinUiaService.disableRoom(channel.id, group.roomName);
    }
  } catch (err: any) {
    // bridge 离线或网络故障不应阻塞 DB 更新
    this.logger.warn(
      '[AgentChannel] uia toggle bridge sync failed channelId=%s group=%s err=%s',
      channel.id, group.roomName, err?.message);
  }
}
  • Step 2: 修改 admin controller /toggle 走 wrapper
  @Post('/toggle')
  async toggle(@Body() body: { id: number; status: 0 | 1 | -1 }) {
    if (!body?.id) return { code: 1003, message: 'id is required' };
    await this.channelService.toggleGroupAndNotify(body.id, body.status);
    return { code: 1000, message: 'success' };
  }
  • Step 3: 测试追加
it('toggleGroupAndNotify calls bridge enableRoom on status=1 for UIA channel', async () => {
  const { service, groupService, weixinUiaService } = setupMocks({ type: 'weixin-uia' });
  jest.spyOn(groupService, 'findById').mockResolvedValue({
    id: 9, channelId: 1, roomName: '产品群', status: 0,
  } as any);
  const enableSpy = jest.spyOn(weixinUiaService, 'enableRoom').mockResolvedValue(true);
  await service.toggleGroupAndNotify(9, 1);
  expect(enableSpy).toHaveBeenCalledWith(1, '产品群');
});

it('toggleGroupAndNotify calls bridge disableRoom on status=0 for UIA channel', async () => {
  const { service, groupService, weixinUiaService } = setupMocks({ type: 'weixin-uia' });
  jest.spyOn(groupService, 'findById').mockResolvedValue({
    id: 9, channelId: 1, roomName: '产品群', status: 1,
  } as any);
  const disableSpy = jest.spyOn(weixinUiaService, 'disableRoom').mockResolvedValue(true);
  await service.toggleGroupAndNotify(9, 0);
  expect(disableSpy).toHaveBeenCalledWith(1, '产品群');
});

it('toggleGroupAndNotify does NOT call bridge for weixin channel', async () => {
  const { service, groupService, weixinUiaService } = setupMocks({ type: 'weixin' });
  jest.spyOn(groupService, 'findById').mockResolvedValue({
    id: 9, channelId: 1, roomName: 'x', status: 0,
  } as any);
  const enableSpy = jest.spyOn(weixinUiaService, 'enableRoom');
  await service.toggleGroupAndNotify(9, 1);
  expect(enableSpy).not.toHaveBeenCalled();
});
  • Step 4: Commit
git add packages/backend/src/modules/netaclaw/service/agent_channel.ts \
        packages/backend/src/modules/netaclaw/controller/admin/agent_channel_group.ts
git commit -m "feat(netaclaw): toggleGroupAndNotify syncs bridge enable/disable"

Task 6: agent_channel service — type 合法值扩展

Files:

  • Modify: packages/backend/src/modules/netaclaw/service/agent_channel.ts (options() 方法)

  • Step 1: 修改 options()

async options() {
  const agents = await this.agentService.publishedList();
  return {
    types: [
      { value: 'weixin',     label: '微信 ClawBot (个人助手 · 仅私聊)' },
      { value: 'weixin-uia', label: '微信本地代理 (群聊助手 · 需 PC 微信)' },
    ],
    loginStatuses: [
      { value: 'disconnected', label: '未连接' },
      { value: 'pending',      label: '待扫码' },
      { value: 'connected',    label: '已连接' },
      { value: 'error',        label: '异常' },
    ],
    agents: agents.map(item => ({
      id: item.id,
      name: item.name,
      label: item.label || item.name,
    })),
  };
}
  • Step 2: Commit
git add packages/backend/src/modules/netaclaw/service/agent_channel.ts
git commit -m "feat(netaclaw): channel types include weixin-uia"

Phase 4 · SQLite Archive Service

Task 7: wechat_archive_schema + service

Files:

  • Create: packages/backend/src/modules/netaclaw/runtime/wechat_archive_schema.ts

  • Create: packages/backend/src/modules/netaclaw/service/wechat_archive.ts

  • Test: packages/backend/test/modules/netaclaw/service/wechat_archive.test.ts

  • Step 1: 写 schema

packages/backend/src/modules/netaclaw/runtime/wechat_archive_schema.ts:

import type Database from 'better-sqlite3';

export function applySchema(db: Database.Database): void {
  db.exec(`
    CREATE TABLE IF NOT EXISTS netaclaw_wechat_archive (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      roomId TEXT NOT NULL,
      msgId TEXT NOT NULL,
      senderWxid TEXT,
      senderName TEXT NOT NULL,
      msgType TEXT NOT NULL,
      content TEXT,
      attachmentPath TEXT,
      quotedRef TEXT,
      atList TEXT,
      receivedAt TEXT NOT NULL,
      triggerAccepted INTEGER NOT NULL DEFAULT 0,
      triggerReason TEXT,
      sessionEntryId TEXT,
      createdAt TEXT NOT NULL
    );
    CREATE UNIQUE INDEX IF NOT EXISTS idx_archive_msgid ON netaclaw_wechat_archive(msgId);
    CREATE INDEX IF NOT EXISTS idx_archive_room ON netaclaw_wechat_archive(roomId, receivedAt);
    CREATE INDEX IF NOT EXISTS idx_archive_accepted ON netaclaw_wechat_archive(triggerAccepted);
  `);
}
  • Step 2: 写失败测试

wechat_archive.test.ts:

import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { WechatArchiveService } from '../../../../src/modules/netaclaw/service/wechat_archive.js';

describe('WechatArchiveService', () => {
  let dataDir: string;
  let svc: WechatArchiveService;

  beforeEach(() => {
    dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'neta-archive-'));
    svc = new WechatArchiveService();
    (svc as any).dataDir = dataDir;
  });

  afterEach(() => {
    svc.closeAll();
    try { fs.rmSync(dataDir, { recursive: true, force: true }); } catch {}
  });

  it('records an inbound row with triggerAccepted=0 by default', () => {
    const id = svc.recordInbound(1, {
      roomId: 'r-1', msgId: 'm-1', senderName: 'Alice',
      msgType: 'text', content: 'hi',
      receivedAt: '2026-05-09T12:30:00Z',
    });
    expect(id).toBeGreaterThan(0);
    const row = svc.queryByMsgId(1, 'm-1');
    expect(row?.triggerAccepted).toBe(0);
  });

  it('deduplicates on msgId UNIQUE — second insert is a no-op (returns existing id)', () => {
    const a = svc.recordInbound(1, { roomId: 'r', msgId: 'm', senderName: 'x', msgType: 'text', content: 'hi', receivedAt: '2026-05-09T12:00:00Z' });
    const b = svc.recordInbound(1, { roomId: 'r', msgId: 'm', senderName: 'x', msgType: 'text', content: 'hi', receivedAt: '2026-05-09T12:00:00Z' });
    expect(a).toBe(b);
  });

  it('markAccepted sets triggerAccepted=1 and sessionEntryId', () => {
    svc.recordInbound(1, { roomId: 'r', msgId: 'm', senderName: 'x', msgType: 'text', content: 'hi', receivedAt: '2026-05-09T12:00:00Z' });
    svc.markAccepted(1, 'm', 'entry-99');
    const row = svc.queryByMsgId(1, 'm');
    expect(row?.triggerAccepted).toBe(1);
    expect(row?.sessionEntryId).toBe('entry-99');
  });

  it('markRejected stores reason and keeps triggerAccepted=0', () => {
    svc.recordInbound(1, { roomId: 'r', msgId: 'm', senderName: 'x', msgType: 'text', content: 'hi', receivedAt: '2026-05-09T12:00:00Z' });
    svc.markRejected(1, 'm', 'not_mentioned');
    const row = svc.queryByMsgId(1, 'm');
    expect(row?.triggerAccepted).toBe(0);
    expect(row?.triggerReason).toBe('not_mentioned');
  });

  it('isolates channels in separate db files', () => {
    svc.recordInbound(1, { roomId: 'r', msgId: 'm', senderName: 'x', msgType: 'text', content: 'a', receivedAt: '2026-05-09T12:00:00Z' });
    svc.recordInbound(2, { roomId: 'r', msgId: 'm', senderName: 'x', msgType: 'text', content: 'b', receivedAt: '2026-05-09T12:00:00Z' });
    expect(fs.existsSync(path.join(dataDir, 'wechat-archive-1.db'))).toBe(true);
    expect(fs.existsSync(path.join(dataDir, 'wechat-archive-2.db'))).toBe(true);
    expect(svc.queryByMsgId(1, 'm')?.content).toBe('a');
    expect(svc.queryByMsgId(2, 'm')?.content).toBe('b');
  });

  it('listByRoom filters and orders by receivedAt DESC', () => {
    svc.recordInbound(1, { roomId: 'r1', msgId: 'm1', senderName: 'a', msgType: 'text', content: 'first', receivedAt: '2026-05-09T12:00:00Z' });
    svc.recordInbound(1, { roomId: 'r1', msgId: 'm2', senderName: 'a', msgType: 'text', content: 'second', receivedAt: '2026-05-09T12:05:00Z' });
    svc.recordInbound(1, { roomId: 'r2', msgId: 'm3', senderName: 'a', msgType: 'text', content: 'other room', receivedAt: '2026-05-09T12:03:00Z' });
    const list = svc.listByRoom(1, 'r1', { limit: 10 });
    expect(list.map(r => r.content)).toEqual(['second', 'first']);
  });
});
  • Step 3: 运行测试确认失败

  • Step 4: 实现 service

import { Provide, Scope, ScopeEnum, Init } from '@midwayjs/core';
import Database from 'better-sqlite3';
import * as fs from 'fs';
import * as path from 'path';
import { applySchema } from '../runtime/wechat_archive_schema.js';
import { resolveDataDir } from '../../../comm/data-dir.js';

export interface ArchiveInboundRow {
  roomId: string;
  msgId: string;
  senderWxid?: string | null;
  senderName: string;
  msgType: string;
  content: string | null;
  attachmentPath?: string | null;
  quotedRef?: string | null;   // JSON serialized
  atList?: string | null;       // JSON serialized
  receivedAt: string;
}

export interface ArchiveRow extends ArchiveInboundRow {
  id: number;
  triggerAccepted: 0 | 1;
  triggerReason: string | null;
  sessionEntryId: string | null;
  createdAt: string;
}

@Provide()
@Scope(ScopeEnum.Singleton)
export class WechatArchiveService {
  private dataDir!: string;
  private readonly dbs = new Map<number, Database.Database>();

  @Init()
  async onInit(): Promise<void> {
    this.dataDir = resolveDataDir();
  }

  private openForChannel(channelId: number): Database.Database {
    let db = this.dbs.get(channelId);
    if (db) return db;
    const file = path.join(this.dataDir, `wechat-archive-${channelId}.db`);
    fs.mkdirSync(path.dirname(file), { recursive: true });
    db = new Database(file);
    db.pragma('journal_mode = WAL');
    applySchema(db);
    this.dbs.set(channelId, db);
    return db;
  }

  /** 插入原始 inbound 记录,默认 triggerAccepted=0。msgId 冲突时幂等返回已有 id。 */
  recordInbound(channelId: number, row: ArchiveInboundRow): number {
    const db = this.openForChannel(channelId);
    const existing = db
      .prepare('SELECT id FROM netaclaw_wechat_archive WHERE msgId = ?')
      .get(row.msgId) as { id: number } | undefined;
    if (existing) return existing.id;

    const info = db.prepare(`
      INSERT INTO netaclaw_wechat_archive
        (roomId, msgId, senderWxid, senderName, msgType, content, attachmentPath,
         quotedRef, atList, receivedAt, triggerAccepted, triggerReason, sessionEntryId, createdAt)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, ?)
    `).run(
      row.roomId,
      row.msgId,
      row.senderWxid ?? null,
      row.senderName,
      row.msgType,
      row.content,
      row.attachmentPath ?? null,
      row.quotedRef ?? null,
      row.atList ?? null,
      row.receivedAt,
      new Date().toISOString(),
    );
    return Number(info.lastInsertRowid);
  }

  markAccepted(channelId: number, msgId: string, sessionEntryId: string): void {
    const db = this.openForChannel(channelId);
    db.prepare(`
      UPDATE netaclaw_wechat_archive
      SET triggerAccepted = 1, sessionEntryId = ?, triggerReason = NULL
      WHERE msgId = ?
    `).run(sessionEntryId, msgId);
  }

  markRejected(channelId: number, msgId: string, reason: string): void {
    const db = this.openForChannel(channelId);
    db.prepare(`
      UPDATE netaclaw_wechat_archive
      SET triggerAccepted = 0, triggerReason = ?
      WHERE msgId = ?
    `).run(reason, msgId);
  }

  queryByMsgId(channelId: number, msgId: string): ArchiveRow | null {
    const db = this.openForChannel(channelId);
    const row = db
      .prepare('SELECT * FROM netaclaw_wechat_archive WHERE msgId = ?')
      .get(msgId) as ArchiveRow | undefined;
    return row ?? null;
  }

  listByRoom(channelId: number, roomId: string,
             opts: { limit?: number; acceptedOnly?: boolean; rejectedOnly?: boolean } = {}): ArchiveRow[] {
    const db = this.openForChannel(channelId);
    const where: string[] = ['roomId = ?'];
    const params: any[] = [roomId];
    if (opts.acceptedOnly) where.push('triggerAccepted = 1');
    if (opts.rejectedOnly) where.push('triggerAccepted = 0');
    const limit = Math.min(Math.max(opts.limit ?? 100, 1), 500);
    return db.prepare(`
      SELECT * FROM netaclaw_wechat_archive
      WHERE ${where.join(' AND ')}
      ORDER BY receivedAt DESC
      LIMIT ${limit}
    `).all(...params) as ArchiveRow[];
  }

  /** channel delete 时调用,关闭并释放 file handle (不删 db 文件,作为审计保留)。 */
  closeForChannel(channelId: number): void {
    const db = this.dbs.get(channelId);
    if (!db) return;
    try { db.close(); } catch {}
    this.dbs.delete(channelId);
  }

  closeAll(): void {
    for (const db of this.dbs.values()) { try { db.close(); } catch {} }
    this.dbs.clear();
  }
}
  • Step 5: 测试通过

Expected:Tests: 6 passed

  • Step 6: Commit
git add packages/backend/src/modules/netaclaw/runtime/wechat_archive_schema.ts \
        packages/backend/src/modules/netaclaw/service/wechat_archive.ts \
        packages/backend/test/modules/netaclaw/service/wechat_archive.test.ts
git commit -m "feat(netaclaw): add WechatArchiveService with per-channel SQLite"

Phase 5 · Bridge HTTP Client (WeixinUiaService)

Task 8: WeixinUiaService — sendText/getRooms/enableRoom/health

Files:

  • Create: packages/backend/src/modules/netaclaw/service/weixin_uia.ts
  • Test: packages/backend/test/modules/netaclaw/service/weixin_uia.test.ts

Bridge HTTP 客户端。内存中按 channelId 存 bridgeBaseUrl (handshake 时 bridge 带 port 过来,或约定默认端口)。spec 说 Tray 用随机端口;handshake 时 bridge 可以把 bridgePort 包在请求 body 里。

  • Step 0: 装 nock devDep
cd packages/backend && pnpm add -D nock@14.0.0
  • Step 1: 写失败测试
import { WeixinUiaService } from '../../../../src/modules/netaclaw/service/weixin_uia.js';
import nock from 'nock';

describe('WeixinUiaService', () => {
  let svc: WeixinUiaService;

  beforeEach(() => {
    svc = new WeixinUiaService();
    (svc as any).traySecret = 'test-sec';
    svc.registerBridge(1, 'http://127.0.0.1:7702');
  });

  afterEach(() => nock.cleanAll());

  it('sendText posts to /send with header + body', async () => {
    const scope = nock('http://127.0.0.1:7702', {
      reqheaders: { 'x-neta-tray-secret': 'test-sec' },
    })
      .post('/send', { roomName: '产品群', text: 'hi', atList: undefined })
      .reply(200, { ok: true });

    const ok = await svc.sendText(1, { roomName: '产品群', text: 'hi' });
    expect(ok).toBe(true);
    scope.done();
  });

  it('sendText returns false on non-2xx', async () => {
    nock('http://127.0.0.1:7702').post('/send').reply(500);
    expect(await svc.sendText(1, { roomName: 'x', text: 'y' })).toBe(false);
  });

  it('getRooms returns parsed list', async () => {
    nock('http://127.0.0.1:7702').get('/rooms').reply(200, { rooms: ['a', 'b'] });
    expect(await svc.getRooms(1)).toEqual(['a', 'b']);
  });

  it('enableRoom POSTs correct body', async () => {
    const scope = nock('http://127.0.0.1:7702')
      .post('/enable-room', { roomName: '产品群' })
      .reply(200, { ok: true });
    expect(await svc.enableRoom(1, '产品群')).toBe(true);
    scope.done();
  });

  it('disableRoom posts to /disable-room', async () => {
    const scope = nock('http://127.0.0.1:7702').post('/disable-room').reply(200, { ok: true });
    expect(await svc.disableRoom(1, 'x')).toBe(true);
    scope.done();
  });

  it('health returns true on 200', async () => {
    nock('http://127.0.0.1:7702').get('/health').reply(200, { ok: true });
    expect(await svc.health(1)).toBe(true);
  });

  it('health returns false on network error', async () => {
    nock('http://127.0.0.1:7702').get('/health').replyWithError('ECONNREFUSED');
    expect(await svc.health(1)).toBe(false);
  });

  it('all methods return false/empty when channel not registered', async () => {
    expect(await svc.sendText(999, { roomName: 'x', text: 'y' })).toBe(false);
    expect(await svc.getRooms(999)).toEqual([]);
    expect(await svc.health(999)).toBe(false);
  });

  it('unregisterBridge clears state', async () => {
    svc.unregisterBridge(1);
    expect(await svc.health(1)).toBe(false);
  });
});
  • Step 2: 运行测试确认失败

  • Step 3: 实现

import { Provide, Scope, ScopeEnum, Init, Logger } from '@midwayjs/core';
import type { ILogger } from '@midwayjs/logger';
import axios, { AxiosInstance } from 'axios';
import { resolveTraySecret } from '../runtime/tray_secret.js';
import { resolveDataDir } from '../../../comm/data-dir.js';

@Provide()
@Scope(ScopeEnum.Singleton)
export class WeixinUiaService {
  @Logger() logger: ILogger;

  private traySecret: string | null = null;
  private readonly bridges = new Map<number, { baseUrl: string; http: AxiosInstance }>();
  private readonly lastHealthAt = new Map<number, { ok: boolean; at: number }>();

  @Init()
  async onInit(): Promise<void> {
    this.traySecret = resolveTraySecret(resolveDataDir());
    if (!this.traySecret) {
      this.logger.warn('[WeixinUia] tray-secret 未配置,bridge 调用将失败');
    }
  }

  registerBridge(channelId: number, baseUrl: string): void {
    const http = axios.create({
      baseURL: baseUrl.replace(/\/+$/, ''),
      timeout: 5000,
      headers: {
        'x-neta-tray-secret': this.traySecret || '',
      },
    });
    this.bridges.set(channelId, { baseUrl, http });
  }

  unregisterBridge(channelId: number): void {
    this.bridges.delete(channelId);
    this.lastHealthAt.delete(channelId);
  }

  async sendText(channelId: number, payload: { roomName: string; text: string; atList?: string[] }): Promise<boolean> {
    const b = this.bridges.get(channelId);
    if (!b) return false;
    try {
      const resp = await b.http.post('/send', payload);
      return resp.status >= 200 && resp.status < 300;
    } catch (err: any) {
      this.logger.warn('[WeixinUia] sendText failed channelId=%s err=%s', channelId, err?.message);
      return false;
    }
  }

  async getRooms(channelId: number): Promise<string[]> {
    const b = this.bridges.get(channelId);
    if (!b) return [];
    try {
      const resp = await b.http.get('/rooms');
      const rooms = (resp.data as { rooms?: unknown }).rooms;
      return Array.isArray(rooms) ? rooms.filter((r: unknown): r is string => typeof r === 'string') : [];
    } catch { return []; }
  }

  async enableRoom(channelId: number, roomName: string): Promise<boolean> {
    const b = this.bridges.get(channelId);
    if (!b) return false;
    try {
      const resp = await b.http.post('/enable-room', { roomName });
      return resp.status >= 200 && resp.status < 300;
    } catch { return false; }
  }

  async disableRoom(channelId: number, roomName: string): Promise<boolean> {
    const b = this.bridges.get(channelId);
    if (!b) return false;
    try {
      const resp = await b.http.post('/disable-room', { roomName });
      return resp.status >= 200 && resp.status < 300;
    } catch { return false; }
  }

  async health(channelId: number): Promise<boolean> {
    const b = this.bridges.get(channelId);
    if (!b) return false;
    try {
      const resp = await b.http.get('/health');
      const ok = resp.status === 200 && (resp.data as { ok?: boolean }).ok !== false;
      this.lastHealthAt.set(channelId, { ok, at: Date.now() });
      return ok;
    } catch {
      this.lastHealthAt.set(channelId, { ok: false, at: Date.now() });
      return false;
    }
  }

  getLastHealth(channelId: number): { ok: boolean; at: number } | null {
    return this.lastHealthAt.get(channelId) ?? null;
  }
}
  • Step 4: 测试通过

Expected:Tests: 9 passed

  • Step 5: Commit
git add packages/backend/src/modules/netaclaw/service/weixin_uia.ts \
        packages/backend/test/modules/netaclaw/service/weixin_uia.test.ts
git commit -m "feat(netaclaw): add WeixinUiaService bridge HTTP client"

Phase 6 · routeInboundMessage 分流 + handleInboundMessage 扩展

Task 9: routeInboundMessage 加 channel.type 分流 + 跨渠道丢弃

Files:

  • Modify: packages/backend/src/modules/netaclaw/service/agent_channel.ts (routeInboundMessage / syncRunner)

  • Create: packages/backend/test/modules/netaclaw/service/agent_channel.uia.test.ts

  • Step 1: 写失败测试

agent_channel.uia.test.ts (新增,复用 agent_channel.test.ts 的 setup 风格):

import { createMock, setupMocks } from './agent_channel.test-helpers.js'; // 复用已有测试 helper

describe('routeInboundMessage — channel.type 分流 + 跨渠道丢弃', () => {
  describe('type=weixin 拒收 group 消息 (今天 task 已有)', () => {
    it('drops messages with room_id silently', () => {
      const { service, channel } = setupMocks({ type: 'weixin' });
      const logSpy = jest.spyOn((service as any).logger, 'debug');
      service['routeInboundMessage'](
        channel,
        { syncBuf: '', contextTokens: {}, seenMessageIds: {}, stopped: false },
        { from_user_id: 'u1', room_id: 'room@chatroom', message_id: 'm1', item_list: [{ type: 1, text_item: { text: 'hi' } }] },
      );
      expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('drop_group_on_clawbot'), expect.anything(), expect.anything());
    });
  });

  describe('type=weixin-uia 拒收 dm 消息 (新增约束)', () => {
    it('drops messages without room_id', () => {
      const { service, channel } = setupMocks({ type: 'weixin-uia' });
      const warnSpy = jest.spyOn((service as any).logger, 'warn');
      service['routeInboundMessage'](
        channel,
        { syncBuf: '', contextTokens: {}, seenMessageIds: {}, stopped: false },
        { from_user_id: 'u1', message_id: 'm1', item_list: [{ type: 1, text_item: { text: 'hi' } }] },
      );
      expect(warnSpy).toHaveBeenCalledWith(
        expect.stringContaining('drop_dm_on_uia'),
        expect.anything(), expect.anything(),
      );
    });
  });

  describe('type=weixin-uia + group 消息正常进入 handle 流水', () => {
    it('queues via senderQueue and calls handleInboundMessage', async () => {
      const { service, channel } = setupMocks({ type: 'weixin-uia' });
      const spy = jest.spyOn(service as any, 'handleInboundMessage').mockResolvedValue(undefined);
      service['routeInboundMessage'](
        channel,
        { syncBuf: '', contextTokens: {}, seenMessageIds: {}, stopped: false },
        {
          __uia: true,
          from_user_id: 'sender', room_id: '1:room:abc', room_name: 'G', message_id: 'm1',
          item_list: [{ type: 1, text_item: { text: 'hello' } }],
        },
      );
      await new Promise(r => setImmediate(r));
      expect(spy).toHaveBeenCalled();
    });
  });
});

describe('syncRunner — weixin-uia 不起 runLoop', () => {
  it('returns without spawning runLoop when type=weixin-uia', async () => {
    const { service, channel } = setupMocks({ type: 'weixin-uia', loginStatus: 'connected' });
    const runLoopSpy = jest.spyOn(service as any, 'runLoop');
    await service.syncRunner(channel.id);
    expect(runLoopSpy).not.toHaveBeenCalled();
  });
});

需在 agent_channel.test-helpers.ts 里补一个 setupMocks({ type }) 辅助函数,加 type 参数。如果现有 test helper 没有,则在测试文件内 inline 构造。

  • Step 2: 修改 agent_channel.ts syncRunner

syncRunnershouldRun 判断处把 channel.type === 'weixin' 条件扩展为:

    const shouldRun =
      channel.status === 1 &&
      channel.type === 'weixin' &&           // UIA 渠道不起 runLoop — 由 bridge 推入站
      channel.loginStatus === 'connected' &&
      credential?.token &&
      credential?.accountId &&
      channel.agentId;

注释改为:// UIA 渠道不起 runLoop — 由 bridge 推入站 (channel.type === 'weixin-uia')。实际代码保持 channel.type === 'weixin' 不变,因为已经排除了 weixin-uia

  • Step 3: 修改 routeInboundMessage 入口加 switch

routeInboundMessage 函数顶部、const scope = decideChatScope(...) 之前插入:

    // 跨渠道丢弃:ClawBot (weixin) 拒收 group / UIA (weixin-uia) 拒收 dm
    const roomIdRaw = String((message as any).room_id || (message as any).chat_room_id || '').trim();
    if (channel.type === 'weixin' && roomIdRaw) {
      this.logger.debug(
        '[AgentChannel] drop_group_on_clawbot channelId=%s room=%s',
        channel.id, roomIdRaw);
      return;
    }
    if (channel.type === 'weixin-uia' && !roomIdRaw) {
      this.logger.warn(
        '[AgentChannel] drop_dm_on_uia channelId=%s sender=%s',
        channel.id, String((message as any).from_user_id || ''));
      return;
    }
  • Step 3.5: ⚠️ 更新 2026-05-08 群路径既有测试 (breakage 必须处理)

架构师审查 C4:今天已实施的 agent_channel.test.ts 里有一批"routeInboundMessage 群路径"测试,mock 了 channel.type='weixin' + 构造含 room_id 的消息,期望进入 handleInboundMessage。本 Task 加 drop 后这些测试会失败(消息被早期 drop)。

处理方案:把这些测试的 channel.type 改成 'weixin-uia'(或 fixture 级调整),让它们测试 UIA 路径(等价的 group 行为)。因为 iLink 实际不会发 group 消息,那批测试原本就是"万一 iLink 放开协议后的保险",现在按 spec 决策群消息只通过 UIA,测试语义应调整。

具体步骤:

  1. 搜索 agent_channel.test.ts 中所有 type: 'weixin' 且有 room_id 字段的测试用例
  2. 把其中测群路径的那些改为 type: 'weixin-uia',同时给消息加 __uia: true 标记
  3. 保留 DM 路径(无 room_id)测试的 type: 'weixin'
  4. 另外保留一个 "type=weixin + room_id → drop" 的测试(本 Task 新增,已在 Step 1 中)

运行:

cd packages/backend && npx cross-env NODE_ENV=unittest jest test/modules/netaclaw/service/agent_channel

Expected:原有 117 测试中若干个会更新 type 后仍 pass,Task 9 新增 4 个 pass。

  • Step 4: 测试通过

Expected:新增 4 个 pass。原有 117 个 test 继续 pass。

  • Step 5: Commit
git add packages/backend/src/modules/netaclaw/service/agent_channel.ts \
        packages/backend/test/modules/netaclaw/service/agent_channel.uia.test.ts
git commit -m "feat(netaclaw): routeInboundMessage channel.type switch + cross-channel drop"

Task 10: handleInboundMessage — boundAgentId 覆盖 + replyIdentity 包装 + [SKIP] 跳发

Files:

  • Modify: packages/backend/src/modules/netaclaw/service/agent_channel.ts (handleInboundMessage 群路径)

  • Modify: packages/backend/test/modules/netaclaw/service/agent_channel.uia.test.ts (追加)

  • Step 1: 追加失败测试

describe('handleInboundMessage group path — UIA specific', () => {
  it('uses boundAgentId when set, falls back to channel.agentId otherwise', async () => {
    const { service, channel, groupService, agentExecutor } = setupMocks({ type: 'weixin-uia', agentId: 10 });
    jest.spyOn(groupService, 'findByKey').mockResolvedValue({
      id: 1, channelId: channel.id, roomId: 'r', status: 1,
      triggerMode: 'all', triggerPrefix: null, boundAgentId: 99,
      replyIdentityOverride: null, roomName: 'G',
    } as any);
    const execSpy = jest.spyOn(agentExecutor, 'execute').mockResolvedValue({ content: 'ok' } as any);

    await (service as any).handleInboundMessage(channel, {/*state*/}, { kind: 'group', chatId: 'r', senderId: 's' }, { room_name: 'G' }, 'hi');

    expect(execSpy).toHaveBeenCalledWith(expect.objectContaining({ agentId: 99 }));
  });

  it('wraps reply with 【AI 助手】 when replyIdentity=ai_prefix', async () => {
    const { service, channel, groupService, agentExecutor, weixinUiaService } = setupMocks({
      type: 'weixin-uia',
      configGroup: { replyIdentity: 'ai_prefix' },
    });
    jest.spyOn(groupService, 'findByKey').mockResolvedValue({
      id: 1, channelId: channel.id, roomId: 'r', status: 1, triggerMode: 'all',
      boundAgentId: null, replyIdentityOverride: null, roomName: 'G',
    } as any);
    jest.spyOn(agentExecutor, 'execute').mockResolvedValue({ content: 'hello' } as any);
    const sendSpy = jest.spyOn(weixinUiaService, 'sendText').mockResolvedValue(true);

    await (service as any).handleInboundMessage(channel, {/*state*/}, { kind: 'group', chatId: 'r', senderId: 's' }, { room_name: 'G' }, 'hi');

    expect(sendSpy).toHaveBeenCalledWith(channel.id, expect.objectContaining({
      text: '【AI 助手】hello',
    }));
  });

  it('per-group replyIdentityOverride wins over channel default', async () => {
    const { service, channel, groupService, weixinUiaService } = setupMocks({
      type: 'weixin-uia',
      configGroup: { replyIdentity: 'ai_prefix' },
    });
    jest.spyOn(groupService, 'findByKey').mockResolvedValue({
      id: 1, channelId: channel.id, status: 1, triggerMode: 'all',
      replyIdentityOverride: 'silent', boundAgentId: null, roomName: 'G',
    } as any);
    const sendSpy = jest.spyOn(weixinUiaService, 'sendText').mockResolvedValue(true);
    // ... 补 executor mock...
    await (service as any).handleInboundMessage(channel, {/*state*/}, { kind: 'group', chatId: 'r', senderId: 's' }, { room_name: 'G' }, 'hi');
    expect(sendSpy).toHaveBeenCalledWith(channel.id, expect.objectContaining({
      text: 'hello',   // 无前缀
    }));
  });

  it('skips sendText when agent returns [SKIP]', async () => {
    const { service, agentExecutor, weixinUiaService } = setupMocks({ type: 'weixin-uia' });
    jest.spyOn(agentExecutor, 'execute').mockResolvedValue({ content: '[SKIP]' } as any);
    const sendSpy = jest.spyOn(weixinUiaService, 'sendText');
    // ...
    expect(sendSpy).not.toHaveBeenCalled();
  });

  it('skips sendText when agent returns empty string', async () => {
    const { service, agentExecutor, weixinUiaService } = setupMocks({ type: 'weixin-uia' });
    jest.spyOn(agentExecutor, 'execute').mockResolvedValue({ content: '   ' } as any);
    const sendSpy = jest.spyOn(weixinUiaService, 'sendText');
    // ...
    expect(sendSpy).not.toHaveBeenCalled();
  });
});
  • Step 2: 修改 handleInboundMessage 群路径

handleInboundMessage 群路径 const group = await this.groupService.findByKey(...) 之后、调 agentExecutor.execute(...) 之前插入:

      const effectiveAgentId = (group as any).boundAgentId ?? channel.agentId;
      if (!effectiveAgentId) {
        this.logger.warn(
          '[AgentChannel] uia group_no_agent_bound channelId=%s roomId=%s',
          channel.id, scope.chatId);
        return;
      }
      // 架构师审查 C5:boundAgentId 覆盖时,channel.agentName 可能不对,
      // 传 undefined 让 executor 内部按 agentId 查正确 agentName
      const effectiveAgentName =
        effectiveAgentId === channel.agentId
          ? (channel.agentName || undefined)
          : undefined;

并把 agentExecutor.execute({ ... agentId: channel.agentId, agentName: channel.agentName || undefined ... }) 替换为 agentId: effectiveAgentId, agentName: effectiveAgentName

const result = await this.agentExecutor.execute(...) 之后、sendText 之前插入:

      // UIA 渠道分流发送;iLink 走原 weixinService.sendText
      if (channel.type === 'weixin-uia') {
        const trimmed = String(result.content || '').trim();
        if (!trimmed || trimmed.startsWith('[SKIP]')) {
          this.logger.info(
            '[AgentChannel] uia agent skipped reason=empty_or_skip channelId=%s roomId=%s',
            channel.id, scope.chatId);
          return;
        }
        const identity = resolveReplyIdentity({
          channel,
          groupOverride: (group as any).replyIdentityOverride ?? null,
        });
        const finalText = wrapWithReplyIdentity(result.content, identity);
        await this.weixinUiaService.sendText(channel.id, {
          roomName: (group as any).roomName || this.extractGroupName(rawMessage) || '',
          text: finalText,
        });
        await this.groupService.touchActive(channel.id, scope.chatId);
        return;
      }

      // 以下是原 iLink 发送路径 (不变)
      await this.weixinService.sendText(credential, scope.chatId, result.content, tokenForChat);

顶部加 import:

import { WeixinUiaService } from './weixin_uia.js';
import {
  resolveReplyIdentity,
  wrapWithReplyIdentity,
} from '../runtime/wechat_uia_routing.js';

在类里加 @Inject() weixinUiaService: WeixinUiaService;

  • Step 3: 测试通过

Expected:新增 5 个 pass。原有测试 pass。

  • Step 4: Commit
git add packages/backend/src/modules/netaclaw/service/agent_channel.ts \
        packages/backend/test/modules/netaclaw/service/agent_channel.uia.test.ts
git commit -m "feat(netaclaw): handleInboundMessage UIA reply wrap + boundAgent + SKIP"

Phase 7 · ingestUiaInbound + open controller

Task 11: ingestUiaInbound 入口方法

Files:

  • Modify: packages/backend/src/modules/netaclaw/service/agent_channel.ts (新增 public ingestUiaInbound)

  • Step 1: 追加失败测试 (在 agent_channel.uia.test.ts)

describe('ingestUiaInbound', () => {
  it('archives inbound then calls routeInboundMessage', async () => {
    const { service, channel, wechatArchiveService } = setupMocks({ type: 'weixin-uia' });
    const archiveSpy = jest.spyOn(wechatArchiveService, 'recordInbound');
    const routeSpy = jest.spyOn(service as any, 'routeInboundMessage');

    await service.ingestUiaInbound({
      channelId: channel.id,
      roomName: '产品群',
      senderName: '小王',
      msgType: 'text',
      content: '你好',
      rawHash: 'msg-1',
      receivedAt: '2026-05-09T12:30:00Z',
    });

    expect(archiveSpy).toHaveBeenCalledWith(channel.id, expect.objectContaining({
      msgId: 'msg-1', content: '你好',
    }));
    expect(routeSpy).toHaveBeenCalledWith(
      channel,
      expect.anything(),
      expect.objectContaining({ __uia: true, room_name: '产品群' }),
    );
  });

  it('rejects when channel.type !== weixin-uia', async () => {
    const { service, channel } = setupMocks({ type: 'weixin' });
    await expect(service.ingestUiaInbound({
      channelId: channel.id,
      roomName: 'x', senderName: 'y', msgType: 'text', content: 'z',
      rawHash: 'h', receivedAt: '2026-01-01T00:00:00Z',
    })).rejects.toThrow(/not a uia channel/i);
  });

  it('creates runner state lazily on first inbound', async () => {
    const { service, channel } = setupMocks({ type: 'weixin-uia' });
    expect((service as any).runners.has(channel.id)).toBe(false);
    await service.ingestUiaInbound({
      channelId: channel.id, roomName: 'x', senderName: 'y',
      msgType: 'text', content: 'z', rawHash: 'h', receivedAt: '2026-01-01T00:00:00Z',
    });
    expect((service as any).runners.has(channel.id)).toBe(true);
  });
});
  • Step 2: 实现

加 import:

import { buildPseudoMessageFromUia, type UiaInboundPayload } from '../runtime/wechat_uia_routing.js';
import { WechatArchiveService } from './wechat_archive.js';

类里加 @Inject() wechatArchiveService: WechatArchiveService;。追加方法:

  async ingestUiaInbound(payload: UiaInboundPayload): Promise<void> {
    const channel = await this.info(payload.channelId);
    if (!channel) throw new Error(`channel ${payload.channelId} not found`);
    if (channel.type !== 'weixin-uia') {
      throw new Error(`channel ${payload.channelId} is not a UIA channel`);
    }

    // 1. 无条件落归档 (triggerAccepted=0 默认)
    const roomIdHash = buildPseudoMessageFromUia(payload).room_id;
    this.wechatArchiveService.recordInbound(payload.channelId, {
      roomId: roomIdHash,
      msgId: payload.rawHash,
      senderName: payload.senderName,
      msgType: payload.msgType,
      content: payload.content,
      attachmentPath: payload.attachmentPath ?? null,
      atList: payload.atList ? JSON.stringify(payload.atList) : null,
      quotedRef: payload.quotedRef ? JSON.stringify(payload.quotedRef) : null,
      receivedAt: payload.receivedAt,
    });

    // 2. 懒创建 runner state (UIA 不跑 runLoop 但需要 state 给 contextTokens/seenMessageIds 用)
    let state = this.runners.get(payload.channelId);
    if (!state) {
      state = { stopped: false, syncBuf: '', contextTokens: {}, seenMessageIds: {} };
      this.runners.set(payload.channelId, state);
    }

    // 3. 走共享分发器
    const pseudo = buildPseudoMessageFromUia(payload);
    this.routeInboundMessage(channel, state, pseudo as any);
  }
  • Step 3: 测试通过

Expected:Tests: 3 passed

  • Step 4: Commit
git add packages/backend/src/modules/netaclaw/service/agent_channel.ts \
        packages/backend/test/modules/netaclaw/service/agent_channel.uia.test.ts
git commit -m "feat(netaclaw): add ingestUiaInbound entry point with archive + dispatch"

Task 12: Open controller /handshake + /inbound

Files:

  • Create: packages/backend/src/modules/netaclaw/controller/open/weixin_uia.ts

  • Step 1: 写失败测试 (controller 集成测试)

packages/backend/test/modules/netaclaw/controller/open_weixin_uia.test.ts:

import { close, createApp, createHttpRequest } from '@midwayjs/mock';
import type { Framework } from '@midwayjs/koa';

describe('POST /open/netaclaw/channel/uia/*', () => {
  let app: any;

  beforeAll(async () => {
    process.env.NETA_TRAY_SECRET = 'test-sec';
    app = await createApp<Framework>();
  });

  afterAll(async () => {
    delete process.env.NETA_TRAY_SECRET;
    await close(app);
  });

  it('rejects requests without x-neta-tray-secret', async () => {
    const r = await createHttpRequest(app).post('/open/netaclaw/channel/uia/handshake').send({});
    expect(r.status).toBe(401);
  });

  it('rejects with wrong secret', async () => {
    const r = await createHttpRequest(app)
      .post('/open/netaclaw/channel/uia/handshake')
      .set('x-neta-tray-secret', 'wrong')
      .send({});
    expect(r.status).toBe(401);
  });

  it('handshake returns channelId + enabledRooms for existing UIA channel', async () => {
    // 前置:DB 里插一个 type=weixin-uia 的 channel,wxid='wxid_x',agentId=1 (另用 fixture 或 stub service)
    // Mocking 层面太重,这里只验证请求路径 + 鉴权能过;具体逻辑测试在 service layer 单测覆盖。
    const r = await createHttpRequest(app)
      .post('/open/netaclaw/channel/uia/handshake')
      .set('x-neta-tray-secret', 'test-sec')
      .send({ wxid: 'wxid_x', nickname: 'N', wechatVersion: '3.9.11.17' });
    expect(r.status).toBe(200);
    expect(r.body).toHaveProperty('channelId');
    expect(r.body).toHaveProperty('enabledRooms');
    expect(Array.isArray(r.body.enabledRooms)).toBe(true);
  });

  it('inbound calls ingestUiaInbound', async () => {
    const r = await createHttpRequest(app)
      .post('/open/netaclaw/channel/uia/inbound')
      .set('x-neta-tray-secret', 'test-sec')
      .send({
        channelId: 1, roomName: 'G', senderName: 'S',
        msgType: 'text', content: 'hi', rawHash: 'h', receivedAt: '2026-05-09T12:00:00Z',
      });
    // 当 channelId=1 不存在时 service 抛错,controller 返回 404/400
    expect([200, 400, 404]).toContain(r.status);
  });
});
  • Step 2: 实现 controller
import { Provide, Inject, Controller, Post, Body, Context, Get } from '@midwayjs/core';
import { Context as KoaContext } from '@midwayjs/koa';
import { NetaClawAgentChannelService } from '../../service/agent_channel.js';
import { NetaClawAgentChannelGroupService } from '../../service/agent_channel_group.js';
import { WeixinUiaService } from '../../service/weixin_uia.js';
import { resolveTraySecret, fixedTimeCompare } from '../../runtime/tray_secret.js';
import { resolveDataDir } from '../../../../comm/data-dir.js';
import type { UiaInboundPayload } from '../../runtime/wechat_uia_routing.js';

@Provide()
@Controller('/open/netaclaw/channel/uia')
export class NetaClawWeixinUiaOpenController {
  @Inject() ctx: KoaContext;
  @Inject() channelService: NetaClawAgentChannelService;
  @Inject() groupService: NetaClawAgentChannelGroupService;
  @Inject() uiaService: WeixinUiaService;

  private ensureAuth(): boolean {
    const expected = resolveTraySecret(resolveDataDir());
    if (!expected) return false;
    const provided = String(this.ctx.get('x-neta-tray-secret') || '');
    return fixedTimeCompare(provided, expected);
  }

  @Post('/handshake')
  async handshake(@Body() body: {
    wxid?: string;
    nickname?: string;
    wechatVersion?: string;
    bridgeBaseUrl?: string;  // 必填:bridge 把自己的 HTTP baseUrl 告知 backend
  }) {
    if (!this.ensureAuth()) { this.ctx.status = 401; return { error: 'unauthorized' }; }
    const wxid = (body?.wxid || '').trim();
    if (!wxid) { this.ctx.status = 400; return { error: 'wxid required' }; }
    const bridgeBaseUrl = (body?.bridgeBaseUrl || '').trim();
    if (!bridgeBaseUrl) { this.ctx.status = 400; return { error: 'bridgeBaseUrl required' }; }

    // 按 credential.wxid 查找 UIA channel (wxid ↔ UIA channel 一对一)
    const channels = await this.channelService.findAllUiaChannelsByWxid(wxid);
    if (channels.length === 0) {
      this.ctx.status = 404;
      return { error: `no weixin-uia channel found for wxid ${wxid}` };
    }
    if (channels.length > 1) {
      // 架构师审查 S8:同一 wxid 绑多个 UIA channel 是配置错误,拒绝 handshake
      this.ctx.status = 409;
      return {
        error: `wxid ${wxid} bound to multiple UIA channels [${channels.map(c => c.id).join(',')}]; manual cleanup required`,
      };
    }
    const channel = channels[0];

    // 更新 credential / loginStatus / 注册 bridge URL
    await this.channelService.onUiaHandshake(channel.id, {
      wxid,
      nickname: body?.nickname ?? null,
      wechatVersion: body?.wechatVersion ?? null,
      bridgeBaseUrl,
    });

    const groups = await this.groupService.list(channel.id);
    const enabledRooms = groups
      .filter(g => g.status === 1 && g.roomName)
      .map(g => g.roomName!);

    return { channelId: channel.id, enabledRooms };
  }

  @Post('/inbound')
  async inbound(@Body() body: UiaInboundPayload) {
    if (!this.ensureAuth()) { this.ctx.status = 401; return { error: 'unauthorized' }; }
    if (!body?.channelId || !body?.rawHash || !body?.roomName || !body?.senderName) {
      this.ctx.status = 400;
      return { error: 'missing required fields' };
    }
    try {
      await this.channelService.ingestUiaInbound(body);
      return { ok: true };
    } catch (err: any) {
      this.ctx.status = err?.message?.includes('not found') ? 404 : 400;
      return { error: err?.message || 'inbound failed' };
    }
  }
}
  • Step 3: 在 channelService 追加两个辅助方法
async findAllUiaChannelsByWxid(wxid: string) {
  return this.channelRepo.find({
    where: { type: 'weixin-uia' },
  }).then(list => list.filter(c => (c.credential as any)?.wxid === wxid));
}

async onUiaHandshake(
  channelId: number,
  info: { wxid: string; nickname: string | null; wechatVersion: string | null; bridgeBaseUrl: string | null }
) {
  const channel = await this.info(channelId);
  if (!channel) return;
  const credential = {
    ...(channel.credential || {}),
    wxid: info.wxid,
    nickname: info.nickname,
    wechatVersion: info.wechatVersion,
  };
  await this.channelRepo.save({
    ...channel,
    credential,
    loginStatus: 'connected',
    lastError: null,
    lastConnectedAt: new Date().toISOString(),
  });
  if (info.bridgeBaseUrl) {
    this.weixinUiaService.registerBridge(channelId, info.bridgeBaseUrl);
  }
}
  • Step 4: 测试通过

Expected:controller 集成测试 4 个 pass。

  • Step 5: Commit
git add packages/backend/src/modules/netaclaw/controller/open/weixin_uia.ts \
        packages/backend/src/modules/netaclaw/service/agent_channel.ts \
        packages/backend/test/modules/netaclaw/controller/open_weixin_uia.test.ts
git commit -m "feat(netaclaw): add /open/.../uia/{handshake,inbound} controller"

Phase 8 · 连通收尾

Task 13: channel.delete 时关 archive db + unregister bridge

Files:

  • Modify: packages/backend/src/modules/netaclaw/service/agent_channel.ts (delete 方法)

  • Step 1: 修改 delete 方法

async delete(ids: number[]) {
  for (const id of ids) {
    this.stopRunner(id);
    await this.groupService.cascadeDeleteByChannel(id);
    // UIA 相关清理
    this.weixinUiaService.unregisterBridge(id);
    this.wechatArchiveService.closeForChannel(id);
    // 注意:SQLite 文件保留作为审计记录,不删(spec 决策)
  }
  await this.channelRepo.delete(ids);
}
  • Step 2: 测试追加
it('delete() unregisters bridge and closes archive db', async () => {
  const { service, weixinUiaService, wechatArchiveService } = setupMocks({ type: 'weixin-uia' });
  const unregSpy = jest.spyOn(weixinUiaService, 'unregisterBridge');
  const closeSpy = jest.spyOn(wechatArchiveService, 'closeForChannel');
  await service.delete([1]);
  expect(unregSpy).toHaveBeenCalledWith(1);
  expect(closeSpy).toHaveBeenCalledWith(1);
});
  • Step 3: Commit
git add packages/backend/src/modules/netaclaw/service/agent_channel.ts \
        packages/backend/test/modules/netaclaw/service/agent_channel.uia.test.ts
git commit -m "feat(netaclaw): close archive + unregister bridge on channel delete"

Task 14: handleInboundMessage 末尾写回 archive 状态

Files:

  • Modify: packages/backend/src/modules/netaclaw/service/agent_channel.ts

  • Step 1: 修改 handleInboundMessage 群路径

在 decideGroupAcceptance 判断后:

      if (!acceptance.accept) {
        // UIA 才写归档;ClawBot 不经 archive
        if (channel.type === 'weixin-uia') {
          const rawHash = String(rawMessage.message_id || '');
          if (rawHash) {
            this.wechatArchiveService.markRejected(
              channel.id, rawHash, acceptance.reason || 'rejected');
          }
        }
        this.logger.debug(/* ... 原日志 ... */);
        return;
      }

agentExecutor.execute 返回后、sendText 前:

      if (channel.type === 'weixin-uia') {
        const rawHash = String(rawMessage.message_id || '');
        if (rawHash) {
          this.wechatArchiveService.markAccepted(
            channel.id, rawHash, /* sessionEntryId */ `${sessionId}:${Date.now()}`);
          // 注意:精确的 sessionEntryId 需要 agent_executor 返回;当前用时间戳占位,Plan D 完善
        }
      }
  • Step 2: 测试追加
it('markRejected called when acceptance fails for UIA channel', async () => {
  const { service, channel, groupService, wechatArchiveService } = setupMocks({ type: 'weixin-uia' });
  jest.spyOn(groupService, 'findByKey').mockResolvedValue({
    status: 1, triggerMode: 'at_mention', triggerPrefix: null,
    boundAgentId: null, replyIdentityOverride: null, roomId: 'r', channelId: channel.id, id: 1, roomName: 'G',
  } as any);
  const rejectSpy = jest.spyOn(wechatArchiveService, 'markRejected');
  // 消息不含 @,acceptance 拒绝
  await (service as any).handleInboundMessage(channel, {/*state*/}, { kind: 'group', chatId: 'r', senderId: 's' }, { room_name: 'G', message_id: 'hash-1' }, 'hi');
  expect(rejectSpy).toHaveBeenCalledWith(channel.id, 'hash-1', expect.stringContaining('not_mentioned'));
});

it('markAccepted called when agent produces reply', async () => {
  const { service, channel, groupService, agentExecutor, wechatArchiveService } = setupMocks({
    type: 'weixin-uia',
  });
  jest.spyOn(groupService, 'findByKey').mockResolvedValue({
    status: 1, triggerMode: 'all', boundAgentId: null, replyIdentityOverride: null,
    roomId: 'r', channelId: channel.id, id: 1, roomName: 'G',
  } as any);
  jest.spyOn(agentExecutor, 'execute').mockResolvedValue({ content: 'hello' } as any);
  const acceptSpy = jest.spyOn(wechatArchiveService, 'markAccepted');
  await (service as any).handleInboundMessage(channel, {/*state*/}, { kind: 'group', chatId: 'r', senderId: 's' }, { room_name: 'G', message_id: 'hash-2' }, 'hi');
  expect(acceptSpy).toHaveBeenCalledWith(channel.id, 'hash-2', expect.any(String));
});
  • Step 3: Commit
git add packages/backend/src/modules/netaclaw/service/agent_channel.ts \
        packages/backend/test/modules/netaclaw/service/agent_channel.uia.test.ts
git commit -m "feat(netaclaw): archive accept/reject state back to SQLite"

自检 (Self-Review)

1. Spec 覆盖:

Spec 章节 覆盖 Task
"channel.type 多态 (weixin/weixin-uia)" Task 6 (options) + Task 9 (route)
"wxid ↔ UIA channel 一对一" Task 12 (handshake 409 多 channel)
"严格职责划分 (ClawBot 丢群 / UIA 丢 DM)" Task 9
"双库存储 SQLite 归档 + MySQL session_entry" Task 7 + Task 14
"图片策略 attachmentPath" Task 2 (pseudo message attachments) + Task 11 (archive 字段)
"触发判定 at_mention / all (prefix 弃用)" Task 4 (triggerMode 收敛) + Task 10 (SKIP 跳发)
"IPC 复用 x-neta-tray-secret" Task 1 + Task 12
"Bridge URL 注册 + Room 启用同步" Task 5.5 + Task 12
"新增表 netaclaw_wechat_archive" Task 7 (schema + service)
"复用 netaclaw_agent_channel_group + boundAgentId/replyIdentityOverride" Task 3
"handshake + 入站 POST" Task 12
"bridge offline 检测 (connected/disconnected)" Task 8 (health + lastHealthAt;定时器在 Plan D Tray 端做)
"replyIdentity silent/ai_prefix" Task 2 + Task 10
"agent 返回 [SKIP]/空不回" Task 10
"channel delete 时 archive 保留 / bridge unregister" Task 13
"boundAgentId 覆盖 agentName" Task 10

Spec 中未覆盖 (留 Plan D 或 v2):

  • 前端 UI (channel-management.vue / channel-group-panel.vue / wechat-archive-panel.vue)
  • 待审批横幅 (room-discovered 推前端通知)
  • Tray 拉 bridge / 安装包 / 版本白名单 UI
  • 精确 sessionEntryId (当前用时间戳占位,Plan D 完善)
  • 语音/视频/跨群人物志 (v2)

2. Placeholder 扫描: 无 TBD / TODO / implement later,除了 Task 14 里的 sessionEntryId = sessionId:Date.now() 占位——这是明确承认的不完整,Spec 也允许 (archive 表设计允许 sessionEntryId 为 placeholder,Plan D 前端显示时按 sessionId 前缀去 MySQL 拉真实记录即可)。

3. 类型一致性: UiaInboundPayload 字段 (channelId/roomName/senderName/msgType/content/rawHash/receivedAt/attachmentPath/atList/quotedRef) 在 Plan B Models/InboundPayload.cs / Plan C wechat_uia_routing.ts / controller DTO 三处保持完全一致。ReplyIdentity type union 'silent' | 'ai_prefix' 在 Plan C 内部一致。Handshake body bridgeBaseUrl 字段在 Plan A Task 13 / Plan B Task 8 / Plan C Task 12 三处一致。

4. 跨 Plan 衔接契约:

  • /open/netaclaw/channel/uia/handshake 请求 { wxid, nickname, wechatVersion, bridgeBaseUrl } → 契合 Plan A/B BackendClient.HandshakeAsync 签名
  • /open/netaclaw/channel/uia/handshake 响应 { channelId, enabledRooms: string[] } → 契合 Plan B HandshakeResult
  • /open/netaclaw/channel/uia/inbound 请求 body → 契合 Plan B InboundPayload record
  • POST /send bridge side (roomName, text) → 契合 Plan B Task 15 SendRequest
  • x-neta-tray-secret header 名 → 契合 Plan A Task 10 TraySecretAuth.HeaderName

5. 架构师交叉审查记录 + Spec 回改 (2026-05-09):

本 plan 在初稿后做了一轮系统架构师审查,修复 4 个 Plan 内问题 + 联动修 2 个跨 Plan 契约 + 回改 6 处 Spec:

# 严重度 问题 修复
C1 🔴 handshake body 缺 bridgeBaseUrl,backend 无法 registerBridge 补字段 → 改 Plan A Task 13、Plan B Task 8、Plan C Task 12 三处
C4 🟠 route drop 会 break 2026-05-08 group 测试 Task 9 加 Step 3.5 明确处理:把那批测试 type 改 weixin-uia
C5 🟠 boundAgentId 覆盖时仍传 channel.agentName 错 Task 10 加 effectiveAgentName 计算逻辑
Dep 🟡 测试用 nock 没在 devDep Task 8 Step 0 加安装命令
S1 📝 spec "prefix 分支在 decideGroupAcceptance 里保留但前端不暴露" 含糊 Spec 改为"service 写入拒绝 + 纯函数容忍 + 前端不暴露"
S2 📝 spec handshake body 写 channelId? 是错的(bridge 启动时不知道) Spec 删除 channelId? 并标注"channelId 是响应字段"
S3 📝 spec /enable-room 谁调没说清 Spec 加新决策"backend 主动调 bridge enable-room"
S5 📝 spec boundAgentId 伪代码漏掉 agentName 处理 Spec 加 effectiveAgentName 计算块
S7 📝 spec triggerMode 描述模糊 Spec 改为"service 拒绝 prefix / 纯函数容忍 / 前端只 2 档"
S8 📝 spec 没说 wxid ↔ UIA channel 一对一约束 Spec "关键决策" 节加该硬约束

Execution Handoff

Plan C 写作完成,保存至 docs/superpowers/plans/2026-05-09-wechat-uia-c-backend-adapter.md

前置依赖:Plan A + Plan B 不必完成,但端到端验证需要 bridge 跑起来。

下一步:写 Plan D 后决定执行顺序。