2162 lines
84 KiB
Markdown
2162 lines
84 KiB
Markdown
# 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.ts` 用 `better-sqlite3`,每 channel 一文件 `dataDir/wechat-archive-<cid>.db`,在 ingest 入口先无条件落库 (triggerAccepted=0),acceptance 通过后再 update (triggerAccepted=1, sessionEntryId)。`weixin_uia.ts` 是 bridge HTTP 客户端,带 `tray-secret` header + 30s 失活检测。`replyIdentity` 在 `handleInboundMessage` 末尾发送前包装。`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_SECRET` 或 `dataDir/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: 写失败测试**
|
|
|
|
```ts
|
|
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: 运行测试确认失败**
|
|
|
|
```bash
|
|
cd packages/backend && npx cross-env NODE_ENV=unittest jest test/modules/netaclaw/runtime/tray_secret.test.ts
|
|
```
|
|
|
|
- [ ] **Step 3: 实现**
|
|
|
|
```ts
|
|
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**
|
|
|
|
```bash
|
|
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: 写失败测试**
|
|
|
|
```ts
|
|
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: 实现**
|
|
|
|
```ts
|
|
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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```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**
|
|
|
|
```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)**
|
|
|
|
```bash
|
|
cd packages/backend && pnpm dev
|
|
# 启动后 Ctrl+C
|
|
```
|
|
|
|
Expected:DB 表新增 2 列,现有数据不动。若 production 需手动执行 migration SQL。
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
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` 末尾追加:
|
|
|
|
```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`:
|
|
|
|
```ts
|
|
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):
|
|
|
|
```ts
|
|
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 测试**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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` 尾部加:
|
|
|
|
```ts
|
|
@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 区分:
|
|
|
|
```ts
|
|
@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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```ts
|
|
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**
|
|
|
|
```ts
|
|
@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: 测试追加**
|
|
|
|
```ts
|
|
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**
|
|
|
|
```bash
|
|
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()**
|
|
|
|
```ts
|
|
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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```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`:
|
|
|
|
```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**
|
|
|
|
```ts
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
cd packages/backend && pnpm add -D nock@14.0.0
|
|
```
|
|
|
|
- [ ] **Step 1: 写失败测试**
|
|
|
|
```ts
|
|
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: 实现**
|
|
|
|
```ts
|
|
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**
|
|
|
|
```bash
|
|
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 风格):
|
|
|
|
```ts
|
|
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**
|
|
|
|
在 `syncRunner` 的 `shouldRun` 判断处把 `channel.type === 'weixin'` 条件**扩展为**:
|
|
|
|
```ts
|
|
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(...)` 之前插入:
|
|
|
|
```ts
|
|
// 跨渠道丢弃: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 中)
|
|
|
|
运行:
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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: 追加失败测试**
|
|
|
|
```ts
|
|
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(...)` 之前插入:
|
|
|
|
```ts
|
|
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` 之前插入:
|
|
|
|
```ts
|
|
// 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:
|
|
|
|
```ts
|
|
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**
|
|
|
|
```bash
|
|
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`)
|
|
|
|
```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:
|
|
|
|
```ts
|
|
import { buildPseudoMessageFromUia, type UiaInboundPayload } from '../runtime/wechat_uia_routing.js';
|
|
import { WechatArchiveService } from './wechat_archive.js';
|
|
```
|
|
|
|
类里加 `@Inject() wechatArchiveService: WechatArchiveService;`。追加方法:
|
|
|
|
```ts
|
|
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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```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**
|
|
|
|
```ts
|
|
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` 追加两个辅助方法**
|
|
|
|
```ts
|
|
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**
|
|
|
|
```bash
|
|
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 方法**
|
|
|
|
```ts
|
|
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: 测试追加**
|
|
|
|
```ts
|
|
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**
|
|
|
|
```bash
|
|
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 判断后:
|
|
|
|
```ts
|
|
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 前:
|
|
|
|
```ts
|
|
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: 测试追加**
|
|
|
|
```ts
|
|
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**
|
|
|
|
```bash
|
|
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 后决定执行顺序。
|