@mrrisega/dsh-remote 0.7.10-beta.3 → 0.7.11-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/clients/dsh-remote/agent/README.md +412 -0
- package/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
- package/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
- package/clients/dsh-remote/agent/codex.mjs +736 -0
- package/clients/dsh-remote/agent/detect.mjs +399 -0
- package/clients/dsh-remote/agent/events.mjs +511 -0
- package/clients/dsh-remote/agent/gemini.mjs +503 -0
- package/clients/dsh-remote/agent/index.mjs +339 -0
- package/clients/dsh-remote/agent/opencode.mjs +628 -0
- package/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
- package/clients/dsh-remote/agent/proc.mjs +179 -0
- package/clients/dsh-remote/agent/version.mjs +148 -0
- package/clients/dsh-remote/approval-coordinator.mjs +150 -0
- package/clients/dsh-remote/decision-poller.mjs +235 -0
- package/clients/dsh-remote/dsh-bridge.mjs +214 -1
- package/clients/dsh-remote/push-reporter.mjs +54 -0
- package/clients/dsh-remote/test/agent-codex.test.mjs +535 -0
- package/clients/dsh-remote/test/agent-detect.test.mjs +390 -0
- package/clients/dsh-remote/test/agent-events.test.mjs +340 -0
- package/clients/dsh-remote/test/agent-gemini.test.mjs +355 -0
- package/clients/dsh-remote/test/agent-jsonl-driver.test.mjs +165 -0
- package/clients/dsh-remote/test/agent-opencode.test.mjs +369 -0
- package/clients/dsh-remote/test/agent-permission-bridge.test.mjs +340 -0
- package/clients/dsh-remote/test/agent-proc.test.mjs +175 -0
- package/clients/dsh-remote/test/agent-runtime.test.mjs +282 -0
- package/clients/dsh-remote/test/agent-version.test.mjs +122 -0
- package/clients/dsh-remote/test/approval-coordinator.test.mjs +192 -0
- package/clients/dsh-remote/test/decision-poller.test.mjs +328 -0
- package/clients/dsh-remote/test/wecom-bot.test.mjs +1438 -0
- package/clients/dsh-remote/wecom-bot.mjs +2444 -0
- package/dsh-setup.mjs +18 -3
- package/package.json +2 -2
- package/packages/dsh-remote-web/lib/client.js +1048 -223
- package/packages/dsh-remote-web/lib/index.js +299 -2
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/README.md +412 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/codex.mjs +736 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/detect.mjs +399 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/events.mjs +511 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/gemini.mjs +503 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/index.mjs +339 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/opencode.mjs +628 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/proc.mjs +179 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/version.mjs +148 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/approval-coordinator.mjs +150 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/decision-poller.mjs +235 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +258 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/push-reporter.mjs +533 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +2444 -0
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +1 -1
- package/packages/dsh-remote-web/test/activation-entry.test.mjs +11 -1
- package/packages/dsh-remote-web/test/announcements-ui.test.mjs +8 -3
- package/packages/dsh-remote-web/test/connect-ui.test.mjs +16 -4
- package/packages/dsh-remote-web/test/im-bots.test.mjs +359 -0
- package/packages/dsh-remote-web/test/lib-panel-ia.mjs +98 -0
- package/packages/dsh-remote-web/test/login-selfheal-ui.test.mjs +13 -5
- package/packages/dsh-remote-web/test/panel-ia.test.mjs +798 -0
- package/packages/dsh-remote-web/test/password-reset.test.mjs +11 -1
- package/packages/dsh-remote-web/test/plugin-link.test.mjs +78 -0
- package/packages/dsh-remote-web/test/purchase-agreement-ui.test.mjs +10 -1
- package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +20 -8
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +30 -21
- package/packages/dsh-remote-web/test/sms-captcha-ui.test.mjs +15 -2
- package/packages/dsh-remote-web/test/telemetry.test.mjs +5 -3
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +57 -130
|
@@ -0,0 +1,1438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 企业微信「智能机器人」长连接通道 —— bridge 侧单测(task-21)。
|
|
3
|
+
*
|
|
4
|
+
* 全部用**本地 ws WebSocketServer** 模拟官方长连接,不连任何真实网络/不使用业主凭据。
|
|
5
|
+
* 覆盖规格里的每一条硬要求:
|
|
6
|
+
* ① 认证帧格式(cmd/headers.req_id/body{secret,bot_id})
|
|
7
|
+
* ② 认证失败(errcode≠0)**不重连**,且错误可读、带 errcode
|
|
8
|
+
* ③ 心跳 30s 语义(连续未 ack 判死重连)+ pong 正常时保持连接
|
|
9
|
+
* ④ 指数退避重连 1s→2s→…→上限
|
|
10
|
+
* ⑤ 入站文本解析(text/mixed)+ 绑定 chatid + 被动回复形状
|
|
11
|
+
* ⑥ 主动推送报文(aibot_send_msg + chatid/markdown 形状)
|
|
12
|
+
* ⑦ 状态文件 im-bot-status.json:字段契约 + 0600 + 原子替换(无残留 .tmp)+ 合并其它段
|
|
13
|
+
* ⑧ secret 绝不进日志(含"服务端把 secret 回显进 errmsg"的最坏情况)
|
|
14
|
+
* ⑨ 出站通知映射(turn_done / turn_error / approval_request)+ 绑定会话优先 / targetChatId 兜底 / 都没有时跳过
|
|
15
|
+
* ⑩ 入站转发(promptSession)与失败降级(无会话/无订阅器)
|
|
16
|
+
* ⑪ 配置契约 im-bots.json(默认关闭 / 缺凭据 / allowFrom 白名单 / targetChatId)
|
|
17
|
+
* ⑫ 控制器 fs.watchFile 热更新(禁用即停、改凭据重连)
|
|
18
|
+
*/
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { EventEmitter, once } from "node:events";
|
|
24
|
+
import test from "node:test";
|
|
25
|
+
|
|
26
|
+
import { WebSocketServer } from "ws";
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
ANSWER_MAX_CHARS,
|
|
30
|
+
ANSWER_TRUNCATED_NOTE,
|
|
31
|
+
APPROVAL_CARD_SUMMARY_MAX,
|
|
32
|
+
IM_BOTS_FILE,
|
|
33
|
+
IM_BOT_STATUS_FILE,
|
|
34
|
+
approvalTaskId,
|
|
35
|
+
atomicWriteJson,
|
|
36
|
+
buildApprovalCard,
|
|
37
|
+
buildDecidedCard,
|
|
38
|
+
buildPreviewCard,
|
|
39
|
+
buildPreviewDecidedCard,
|
|
40
|
+
createScopedLogger,
|
|
41
|
+
isPreviewCardEvent,
|
|
42
|
+
createWecomBot,
|
|
43
|
+
createWecomBotController,
|
|
44
|
+
extractInboundText,
|
|
45
|
+
fileMode,
|
|
46
|
+
maskSecrets,
|
|
47
|
+
normalizeImBotsConfig,
|
|
48
|
+
normalizeSessionStatusList,
|
|
49
|
+
parseApprovalEventKey,
|
|
50
|
+
pickInboundIdentity,
|
|
51
|
+
readImBotsConfig,
|
|
52
|
+
toMarkdownNotification
|
|
53
|
+
} from "../wecom-bot.mjs";
|
|
54
|
+
import { NODE_KINDS } from "../dsh-events.mjs";
|
|
55
|
+
|
|
56
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
57
|
+
|
|
58
|
+
/** 轮询等待(避免固定 sleep 造成 flake)。 */
|
|
59
|
+
async function waitFor(fn, { timeoutMs = 4000, stepMs = 15, label = "条件" } = {}) {
|
|
60
|
+
const deadline = Date.now() + timeoutMs;
|
|
61
|
+
for (;;) {
|
|
62
|
+
const v = await fn();
|
|
63
|
+
if (v) return v;
|
|
64
|
+
if (Date.now() > deadline) throw new Error(`waitFor 超时:${label}`);
|
|
65
|
+
await sleep(stepMs);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const SECRET = "test-secret-abc123";
|
|
70
|
+
const BOT_ID = "bot-test-0001";
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 本地假长连接服务端。
|
|
74
|
+
* @param {{onFrame?:Function, onConnect?:Function}} opts
|
|
75
|
+
*/
|
|
76
|
+
async function startFakeServer(opts = {}) {
|
|
77
|
+
const state = { conns: [], frames: [], closed: 0 };
|
|
78
|
+
const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
|
79
|
+
await once(wss, "listening");
|
|
80
|
+
const port = wss.address().port;
|
|
81
|
+
wss.on("connection", (ws) => {
|
|
82
|
+
const conn = { ws, at: Date.now(), frames: [] };
|
|
83
|
+
state.conns.push(conn);
|
|
84
|
+
ws.on("message", (raw) => {
|
|
85
|
+
let frame = null;
|
|
86
|
+
try { frame = JSON.parse(String(raw)); } catch { return; }
|
|
87
|
+
conn.frames.push(frame);
|
|
88
|
+
state.frames.push(frame);
|
|
89
|
+
if (typeof opts.onFrame === "function") {
|
|
90
|
+
try { opts.onFrame(frame, ws, state); } catch { /* 断言由用例负责 */ }
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
ws.on("close", () => { state.closed += 1; });
|
|
94
|
+
if (typeof opts.onConnect === "function") {
|
|
95
|
+
try { opts.onConnect(ws, conn, state); } catch { /* 忽略 */ }
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
return {
|
|
99
|
+
port,
|
|
100
|
+
url: `ws://127.0.0.1:${port}`,
|
|
101
|
+
state,
|
|
102
|
+
ack(reqId, extra = {}) {
|
|
103
|
+
const frame = { headers: reqId ? { req_id: reqId } : {}, errcode: 0, errmsg: "ok", ...extra };
|
|
104
|
+
for (const c of state.conns) {
|
|
105
|
+
try { c.ws.send(JSON.stringify(frame)); } catch { /* 忽略 */ }
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
/** 广播一帧给当前所有连接(模拟官方下发)。 */
|
|
109
|
+
push(frame) {
|
|
110
|
+
for (const c of state.conns) {
|
|
111
|
+
try { c.ws.send(JSON.stringify(frame)); } catch { /* 忽略 */ }
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
close() {
|
|
115
|
+
for (const c of state.conns) { try { c.ws.terminate(); } catch { /* 忽略 */ } }
|
|
116
|
+
return new Promise((resolve) => wss.close(() => resolve()));
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 认证成功的服务端行为:收到 aibot_subscribe 就回 ok。 */
|
|
122
|
+
const autoAckAuth = (frame, ws) => {
|
|
123
|
+
if (frame.cmd === "aibot_subscribe") {
|
|
124
|
+
ws.send(JSON.stringify({ headers: { req_id: frame.headers.req_id }, errcode: 0, errmsg: "ok" }));
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
/** 心跳回 ack(让连接保持)。 */
|
|
128
|
+
const autoAckPing = (frame, ws) => {
|
|
129
|
+
if (frame.cmd === "ping") {
|
|
130
|
+
ws.send(JSON.stringify({ headers: { req_id: frame.headers.req_id }, errcode: 0, errmsg: "ok" }));
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
/** 主动推送回 ack(用于断言 sent 计数)。 */
|
|
134
|
+
const autoAckSend = (frame, ws) => {
|
|
135
|
+
if (frame.cmd === "aibot_send_msg" || frame.cmd === "aibot_respond_msg") {
|
|
136
|
+
ws.send(JSON.stringify({ headers: { req_id: frame.headers.req_id }, errcode: 0, errmsg: "ok" }));
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
function tmpRelayDir() {
|
|
141
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "dsh-wecom-"));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function readStatus(relayDir) {
|
|
145
|
+
try { return JSON.parse(fs.readFileSync(path.join(relayDir, IM_BOT_STATUS_FILE), "utf8")); } catch { return null; }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 极简假订阅器(EventEmitter + dsh-events 的最小可用面)。 */
|
|
149
|
+
function fakeSubscriber(over = {}) {
|
|
150
|
+
const sub = new EventEmitter();
|
|
151
|
+
sub.state = "ready";
|
|
152
|
+
sub.started = false;
|
|
153
|
+
sub.closed = false;
|
|
154
|
+
sub.promptCalls = [];
|
|
155
|
+
sub.sessionDetails = Array.isArray(over.sessions) ? over.sessions.slice() : [];
|
|
156
|
+
sub.start = () => { sub.started = true; };
|
|
157
|
+
sub.close = () => { sub.closed = true; };
|
|
158
|
+
sub.listSessions = async () => ({ ok: true, sessions: sub.sessionDetails });
|
|
159
|
+
// task-24:follow 中的会话 id(`subscriber.sessions()` 的真实形状是 string[])。
|
|
160
|
+
// 默认从 sessions 详情里取 id(更贴近真实:细节列表里有的会话通常也在 follow 中)。
|
|
161
|
+
sub.sessionIds = Array.isArray(over.sessionIds)
|
|
162
|
+
? over.sessionIds.slice()
|
|
163
|
+
: sub.sessionDetails.map((x) => x && x.sessionId).filter(Boolean);
|
|
164
|
+
sub.sessions = () => sub.sessionIds.slice();
|
|
165
|
+
/** 回答回传用(dsh-events 的真实签名:{ok:true,text} / {ok:false,code,text:""};可动态替换)。 */
|
|
166
|
+
sub.lastAssistantCalls = [];
|
|
167
|
+
sub.assistantReply = over.assistantText !== undefined ? over.assistantText : { ok: true, text: "" };
|
|
168
|
+
sub.lastAssistantText = async ({ sessionId } = {}) => {
|
|
169
|
+
sub.lastAssistantCalls.push({ sessionId });
|
|
170
|
+
return typeof sub.assistantReply === "function" ? sub.assistantReply(sessionId) : sub.assistantReply;
|
|
171
|
+
};
|
|
172
|
+
sub.promptSession = async (arg) => {
|
|
173
|
+
sub.promptCalls.push(arg);
|
|
174
|
+
if (over.promptImpl) return over.promptImpl(arg);
|
|
175
|
+
return { ok: true, accepted: true };
|
|
176
|
+
};
|
|
177
|
+
return sub;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─────────────────────────── ① 认证 + 状态文件契约 ───────────────────────────
|
|
181
|
+
|
|
182
|
+
test("★ 认证帧格式(aibot_subscribe + secret/bot_id)与成功后状态文件契约(0600/字段/原子写/合并不覆盖其它段)", async () => {
|
|
183
|
+
const relayDir = tmpRelayDir();
|
|
184
|
+
fs.writeFileSync(path.join(relayDir, IM_BOT_STATUS_FILE), JSON.stringify({
|
|
185
|
+
version: 1, updatedAt: 1, feishu: { connected: true } // 别的模块写的段,必须原样保留
|
|
186
|
+
}), { mode: 0o600 });
|
|
187
|
+
|
|
188
|
+
const server = await startFakeServer({ onFrame: autoAckAuth });
|
|
189
|
+
const bot = createWecomBot({
|
|
190
|
+
botId: BOT_ID, secret: SECRET, url: server.url, relayDir, logger: () => {}
|
|
191
|
+
});
|
|
192
|
+
try {
|
|
193
|
+
bot.start();
|
|
194
|
+
await waitFor(() => bot.status().authenticated, { label: "认证完成" });
|
|
195
|
+
const auth = server.state.frames.find((f) => f.cmd === "aibot_subscribe");
|
|
196
|
+
assert.ok(auth, "必须发出 aibot_subscribe 认证帧");
|
|
197
|
+
assert.deepEqual(Object.keys(auth).sort(), ["body", "cmd", "headers"], "认证帧只允许 cmd/headers/body 三个顶层字段");
|
|
198
|
+
assert.deepEqual(Object.keys(auth.headers), ["req_id"], "headers 里只有 req_id");
|
|
199
|
+
assert.equal(typeof auth.headers.req_id, "string");
|
|
200
|
+
assert.ok(auth.headers.req_id.length > 0);
|
|
201
|
+
assert.deepEqual(auth.body, { secret: SECRET, bot_id: BOT_ID }, "body 形状必须是 {secret, bot_id}");
|
|
202
|
+
|
|
203
|
+
const st = bot.status();
|
|
204
|
+
assert.equal(st.connected, true);
|
|
205
|
+
assert.equal(st.authenticated, true);
|
|
206
|
+
assert.equal(st.enabled, true);
|
|
207
|
+
|
|
208
|
+
const file = path.join(relayDir, IM_BOT_STATUS_FILE);
|
|
209
|
+
const status = readStatus(relayDir);
|
|
210
|
+
assert.ok(status, "必须写 im-bot-status.json");
|
|
211
|
+
assert.equal(status.version, 1);
|
|
212
|
+
assert.ok(status.updatedAt > 0);
|
|
213
|
+
assert.deepEqual(status.feishu, { connected: true }, "其它 IM 的段必须原样保留(绝不整份覆盖)");
|
|
214
|
+
const w = status.wecom;
|
|
215
|
+
for (const k of ["enabled", "connected", "authenticated", "lastError", "lastConnectedAt", "lastMessageAt", "boundChatId", "targetChatId"]) {
|
|
216
|
+
assert.ok(Object.prototype.hasOwnProperty.call(w, k), `状态文件 wecom 段必须含字段 ${k}`);
|
|
217
|
+
}
|
|
218
|
+
assert.equal(w.enabled, true);
|
|
219
|
+
assert.equal(w.connected, true);
|
|
220
|
+
assert.equal(w.authenticated, true);
|
|
221
|
+
assert.equal(w.lastError, "");
|
|
222
|
+
assert.ok(w.lastConnectedAt > 0);
|
|
223
|
+
assert.equal(w.boundChatId, "");
|
|
224
|
+
assert.ok(!JSON.stringify(status).includes(SECRET), "状态文件绝不能含 secret");
|
|
225
|
+
if (process.platform !== "win32") assert.equal(fileMode(file), 0o600, "状态文件必须 0600");
|
|
226
|
+
const leftovers = fs.readdirSync(relayDir).filter((n) => n.includes(".tmp"));
|
|
227
|
+
assert.deepEqual(leftovers, [], "原子写下不得残留 .tmp 文件");
|
|
228
|
+
} finally {
|
|
229
|
+
await bot.stop();
|
|
230
|
+
await server.close();
|
|
231
|
+
fs.rmSync(relayDir, { recursive: true, force: true });
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ─────────────────────────── ② 认证失败不重连 ───────────────────────────
|
|
236
|
+
|
|
237
|
+
test("★ 认证失败(errcode≠0):立即停止、不无限重连,错误可读且带 errcode", async () => {
|
|
238
|
+
const relayDir = tmpRelayDir();
|
|
239
|
+
const server = await startFakeServer({
|
|
240
|
+
onFrame: (frame, ws) => {
|
|
241
|
+
if (frame.cmd === "aibot_subscribe") {
|
|
242
|
+
ws.send(JSON.stringify({ headers: { req_id: frame.headers.req_id }, errcode: 40101, errmsg: "invalid secret" }));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
const bot = createWecomBot({
|
|
247
|
+
botId: BOT_ID, secret: SECRET, url: server.url, relayDir, logger: () => {},
|
|
248
|
+
reconnectBaseMs: 20, reconnectMaxMs: 40 // 就算要重连也会很快暴露
|
|
249
|
+
});
|
|
250
|
+
try {
|
|
251
|
+
const authFailed = new Promise((resolve) => bot.once("auth-failed", resolve));
|
|
252
|
+
bot.start();
|
|
253
|
+
const ev = await authFailed;
|
|
254
|
+
assert.equal(ev.errcode, 40101);
|
|
255
|
+
const st = bot.status();
|
|
256
|
+
assert.equal(st.authFailed, true);
|
|
257
|
+
assert.equal(st.authenticated, false);
|
|
258
|
+
assert.match(st.lastError, /40101/);
|
|
259
|
+
assert.match(st.lastError, /认证被拒绝/);
|
|
260
|
+
await sleep(300); // 远超退避时间
|
|
261
|
+
assert.equal(server.state.conns.length, 1, "凭据错必须**不重连**(否则会被官方限流/封禁)");
|
|
262
|
+
assert.equal(readStatus(relayDir).wecom.authenticated, false);
|
|
263
|
+
assert.ok(!JSON.stringify(readStatus(relayDir)).includes(SECRET), "状态文件不得含 secret");
|
|
264
|
+
} finally {
|
|
265
|
+
await bot.stop();
|
|
266
|
+
await server.close();
|
|
267
|
+
fs.rmSync(relayDir, { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ─────────────────────────── ③ 心跳 ───────────────────────────
|
|
272
|
+
|
|
273
|
+
test("★ 心跳:ping 帧形状正确、pong 正常时保持连接;连续未 ack 判死并重连", async () => {
|
|
274
|
+
// 场景 A:正常回 pong → 连接不掉
|
|
275
|
+
{
|
|
276
|
+
const server = await startFakeServer({ onFrame: (f, ws) => { autoAckAuth(f, ws); autoAckPing(f, ws); } });
|
|
277
|
+
const bot = createWecomBot({
|
|
278
|
+
botId: BOT_ID, secret: SECRET, url: server.url, logger: () => {},
|
|
279
|
+
heartbeatMs: 20, heartbeatMaxMiss: 2, reconnectBaseMs: 20, reconnectMaxMs: 40
|
|
280
|
+
});
|
|
281
|
+
try {
|
|
282
|
+
bot.start();
|
|
283
|
+
await waitFor(() => bot.status().authenticated, { label: "认证完成" });
|
|
284
|
+
await sleep(150);
|
|
285
|
+
const pings = server.state.frames.filter((f) => f.cmd === "ping");
|
|
286
|
+
assert.ok(pings.length >= 2, `应按间隔发心跳(实际 ${pings.length} 次)`);
|
|
287
|
+
assert.deepEqual(Object.keys(pings[0]).sort(), ["cmd", "headers"], "ping 帧只允许 cmd/headers");
|
|
288
|
+
assert.equal(typeof pings[0].headers.req_id, "string");
|
|
289
|
+
assert.equal(server.state.conns.length, 1, "pong 正常时不得重连");
|
|
290
|
+
assert.ok(bot.status().lastPongAt > 0);
|
|
291
|
+
} finally {
|
|
292
|
+
await bot.stop();
|
|
293
|
+
await server.close();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// 场景 B:服务端从不回 pong → 连续未 ack → 判死重连
|
|
297
|
+
{
|
|
298
|
+
const server = await startFakeServer({ onFrame: autoAckAuth });
|
|
299
|
+
const bot = createWecomBot({
|
|
300
|
+
botId: BOT_ID, secret: SECRET, url: server.url, logger: () => {},
|
|
301
|
+
heartbeatMs: 20, heartbeatMaxMiss: 2, reconnectBaseMs: 20, reconnectMaxMs: 40
|
|
302
|
+
});
|
|
303
|
+
try {
|
|
304
|
+
bot.start();
|
|
305
|
+
await waitFor(() => bot.status().authenticated, { label: "认证完成" });
|
|
306
|
+
await waitFor(() => server.state.conns.length >= 2, { timeoutMs: 3000, label: "心跳超时后重连" });
|
|
307
|
+
assert.ok(server.state.frames.filter((f) => f.cmd === "ping").length >= 2);
|
|
308
|
+
} finally {
|
|
309
|
+
await bot.stop();
|
|
310
|
+
await server.close();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// ─────────────────────────── ④ 指数退避 ───────────────────────────
|
|
316
|
+
|
|
317
|
+
test("★ 断网重连:指数退避(1s→2s→…)且不超过 30s 上限(这里用 20/40ms 缩放验证形状)", async () => {
|
|
318
|
+
// 服务端一接上就断开(网络断的典型形态)→ 客户端应退避重连,且间隔递增到上限
|
|
319
|
+
const server = await startFakeServer({
|
|
320
|
+
onConnect: (ws) => { try { ws.close(); } catch { /* 忽略 */ } }
|
|
321
|
+
});
|
|
322
|
+
const bot = createWecomBot({
|
|
323
|
+
botId: BOT_ID, secret: SECRET, url: server.url, logger: () => {},
|
|
324
|
+
reconnectBaseMs: 40, reconnectMaxMs: 80, authTimeoutMs: 500
|
|
325
|
+
});
|
|
326
|
+
try {
|
|
327
|
+
bot.start();
|
|
328
|
+
await waitFor(() => server.state.conns.length >= 4, { timeoutMs: 5000, label: "至少 4 次连接" });
|
|
329
|
+
const times = server.state.conns.map((c) => c.at);
|
|
330
|
+
const gaps = times.slice(1).map((t, i) => t - times[i]);
|
|
331
|
+
// 间距应递增(40 → 80 → 80),且都不超过上限 + 合理调度余量
|
|
332
|
+
assert.ok(gaps[0] >= 20, `第一次重连间隔应 ≥ 基础退避(实际 ${gaps[0]}ms)`);
|
|
333
|
+
assert.ok(gaps[gaps.length - 1] <= 80 + 120, `重连间隔不得超过上限太多(实际 ${gaps[gaps.length - 1]}ms)`);
|
|
334
|
+
const st = bot.status();
|
|
335
|
+
assert.ok(st.reconnectAttempts >= 3);
|
|
336
|
+
assert.ok(st.nextRetryInMs <= 80, "nextRetryInMs 必须被上限钳住");
|
|
337
|
+
} finally {
|
|
338
|
+
await bot.stop();
|
|
339
|
+
await server.close();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// ─────────────────────────── ⑤ 入站解析 + 被动回复 ───────────────────────────
|
|
344
|
+
|
|
345
|
+
test("★ 入站文本:解析 text/mixed、绑定 chatid、被动回复 aibot_respond_msg 形状", async () => {
|
|
346
|
+
const relayDir = tmpRelayDir();
|
|
347
|
+
const server = await startFakeServer({ onFrame: (f, ws) => { autoAckAuth(f, ws); autoAckPing(f, ws); } });
|
|
348
|
+
// 无订阅器(不传 upstream)→ 入站转发降级,但绑定/回复仍要工作
|
|
349
|
+
const callbacks = [];
|
|
350
|
+
const bot = createWecomBot({
|
|
351
|
+
botId: BOT_ID, secret: SECRET, url: server.url, relayDir, logger: () => {},
|
|
352
|
+
onMessage: (m) => callbacks.push(m) // 规格里的便捷回调形态
|
|
353
|
+
});
|
|
354
|
+
try {
|
|
355
|
+
const messages = [];
|
|
356
|
+
bot.on("message", (m) => messages.push(m));
|
|
357
|
+
bot.start();
|
|
358
|
+
await waitFor(() => bot.status().authenticated, { label: "认证完成" });
|
|
359
|
+
|
|
360
|
+
server.push({
|
|
361
|
+
cmd: "aibot_msg_callback",
|
|
362
|
+
headers: { req_id: "inbound-req-1" },
|
|
363
|
+
body: { msgid: "msg-1", msgtype: "text", text: { content: "帮我看下构建为什么失败" }, from: { userid: "user-a" }, chattype: "single" }
|
|
364
|
+
});
|
|
365
|
+
await waitFor(() => messages.length === 1, { label: "入站事件" });
|
|
366
|
+
assert.equal(messages[0].text, "帮我看下构建为什么失败");
|
|
367
|
+
assert.equal(messages[0].msgid, "msg-1");
|
|
368
|
+
assert.equal(messages[0].chatid, "user-a", "单聊 chatid = 对方 userid");
|
|
369
|
+
assert.equal(messages[0].fromUser, "user-a");
|
|
370
|
+
assert.equal(messages[0].isGroup, false);
|
|
371
|
+
assert.equal(callbacks.length, 1, "onMessage 回调也要被调用(与事件并行)");
|
|
372
|
+
assert.equal(callbacks[0].text, "帮我看下构建为什么失败");
|
|
373
|
+
|
|
374
|
+
// 绑定 + 状态文件
|
|
375
|
+
assert.equal(bot.status().boundChatId, "user-a", "首次入站即绑定推送接收方");
|
|
376
|
+
const st = readStatus(relayDir);
|
|
377
|
+
assert.equal(st.wecom.boundChatId, "user-a");
|
|
378
|
+
assert.ok(st.wecom.lastMessageAt > 0);
|
|
379
|
+
|
|
380
|
+
// 无订阅器 → 被动回复一句「转不过去」
|
|
381
|
+
const reply = await waitFor(() => server.state.frames.find((f) => f.cmd === "aibot_respond_msg"), { label: "被动回复" });
|
|
382
|
+
assert.deepEqual(Object.keys(reply.headers), ["req_id"]);
|
|
383
|
+
assert.equal(reply.headers.req_id, "inbound-req-1", "被动回复必须回带入站帧的 req_id");
|
|
384
|
+
assert.equal(reply.body.msgtype, "stream");
|
|
385
|
+
assert.equal(typeof reply.body.stream.id, "string");
|
|
386
|
+
assert.equal(reply.body.stream.finish, true);
|
|
387
|
+
assert.match(reply.body.stream.content, /还没连上|转不过去/);
|
|
388
|
+
|
|
389
|
+
// 同 msgid 重发 → 去重(不再 emit)
|
|
390
|
+
server.push({
|
|
391
|
+
cmd: "aibot_msg_callback",
|
|
392
|
+
headers: { req_id: "inbound-req-1b" },
|
|
393
|
+
body: { msgid: "msg-1", msgtype: "text", text: { content: "重复" }, from: { userid: "user-a" } }
|
|
394
|
+
});
|
|
395
|
+
await sleep(100);
|
|
396
|
+
assert.equal(messages.length, 1, "同一 msgid 必须去重");
|
|
397
|
+
} finally {
|
|
398
|
+
await bot.stop();
|
|
399
|
+
await server.close();
|
|
400
|
+
fs.rmSync(relayDir, { recursive: true, force: true });
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("纯函数:extractInboundText 覆盖 text/mixed/voice/非文本;pickInboundIdentity 区分单聊群聊", () => {
|
|
405
|
+
assert.equal(extractInboundText({ msgtype: "text", text: { content: " hi " } }), "hi");
|
|
406
|
+
assert.equal(extractInboundText({
|
|
407
|
+
msgtype: "mixed",
|
|
408
|
+
mixed: { msg_item: [{ type: "text", text: { content: "第一句" } }, { type: "image", image: {} }, { type: "text", text: { content: "第二句" } }] }
|
|
409
|
+
}), "第一句\n第二句");
|
|
410
|
+
assert.equal(extractInboundText({ msgtype: "mixed", mixed: [{ type: "text", text: { content: "a" } }] }), "a");
|
|
411
|
+
assert.equal(extractInboundText({ msgtype: "voice", voice: { content: "语音转写" } }), "语音转写");
|
|
412
|
+
assert.equal(extractInboundText({ msgtype: "image" }), "", "图片没有文本 → 空串(由上层提示)");
|
|
413
|
+
|
|
414
|
+
assert.deepEqual(pickInboundIdentity({ from: { userid: "u1" } }), { fromUser: "u1", chatid: "u1", isGroup: false });
|
|
415
|
+
assert.deepEqual(pickInboundIdentity({ chatid: "g1", chattype: "group", from: { userid: "u1" } }), { fromUser: "u1", chatid: "g1", isGroup: true });
|
|
416
|
+
assert.deepEqual(pickInboundIdentity({}), { fromUser: "", chatid: "", isGroup: false });
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// ─────────────────────────── ⑥ 主动推送 ───────────────────────────
|
|
420
|
+
|
|
421
|
+
test("★ 主动推送:aibot_send_msg 报文形状(chatid + markdown);未认证时如实失败", async () => {
|
|
422
|
+
const server = await startFakeServer({ onFrame: (f, ws) => { autoAckAuth(f, ws); autoAckSend(f, ws); } });
|
|
423
|
+
const bot = createWecomBot({ botId: BOT_ID, secret: SECRET, url: server.url, logger: () => {} });
|
|
424
|
+
try {
|
|
425
|
+
const early = bot.sendMarkdown("user-a", "还没认证");
|
|
426
|
+
assert.deepEqual(early, { ok: false, reason: "not_authenticated" }, "未认证不得发");
|
|
427
|
+
assert.equal(bot.sendMarkdown("", "x").reason, "no_chatid");
|
|
428
|
+
|
|
429
|
+
bot.start();
|
|
430
|
+
await waitFor(() => bot.status().authenticated, { label: "认证完成" });
|
|
431
|
+
const r = bot.sendMarkdown("user-a", "✅ 任务完成|构建修复");
|
|
432
|
+
assert.equal(r.ok, true);
|
|
433
|
+
const frame = await waitFor(() => server.state.frames.find((f) => f.cmd === "aibot_send_msg"), { label: "主动推送帧" });
|
|
434
|
+
assert.deepEqual(Object.keys(frame).sort(), ["body", "cmd", "headers"]);
|
|
435
|
+
assert.equal(typeof frame.headers.req_id, "string");
|
|
436
|
+
assert.equal(frame.body.chatid, "user-a");
|
|
437
|
+
assert.equal(frame.body.msgtype, "markdown");
|
|
438
|
+
assert.equal(frame.body.markdown.content, "✅ 任务完成|构建修复");
|
|
439
|
+
await waitFor(() => bot.status().sent === 1, { label: "推送被 ack 后计数" });
|
|
440
|
+
} finally {
|
|
441
|
+
await bot.stop();
|
|
442
|
+
await server.close();
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// ─────────────────────────── ⑦ secret 不进日志 ───────────────────────────
|
|
447
|
+
|
|
448
|
+
test("★ secret 绝不进日志:连服务端把 secret 回显进 errmsg 的最坏情况也不能漏", async () => {
|
|
449
|
+
const lines = [];
|
|
450
|
+
const server = await startFakeServer({
|
|
451
|
+
onFrame: (frame, ws) => {
|
|
452
|
+
if (frame.cmd === "aibot_subscribe") {
|
|
453
|
+
// 最坏情况:服务端把收到的 secret 原样写回 errmsg(真实服务端不会,但我们要兜住)
|
|
454
|
+
ws.send(JSON.stringify({
|
|
455
|
+
headers: { req_id: frame.headers.req_id },
|
|
456
|
+
errcode: 40001,
|
|
457
|
+
errmsg: `invalid credential secret=${frame.body.secret} bot_id=${frame.body.bot_id}`
|
|
458
|
+
}));
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
const bot = createWecomBot({
|
|
463
|
+
botId: BOT_ID, secret: SECRET, url: server.url, logger: (level, line) => lines.push(line),
|
|
464
|
+
authTimeoutMs: 500
|
|
465
|
+
});
|
|
466
|
+
try {
|
|
467
|
+
bot.start();
|
|
468
|
+
await waitFor(() => bot.status().authFailed, { label: "认证失败" });
|
|
469
|
+
await sleep(80);
|
|
470
|
+
const loggerLines = bot._logger.lines.join("\n");
|
|
471
|
+
assert.ok(loggerLines.length > 0, "应至少有一行日志(便于排查)");
|
|
472
|
+
assert.ok(!loggerLines.includes(SECRET), "日志绝不能出现 secret 字面量");
|
|
473
|
+
assert.ok(!lines.join("\n").includes(SECRET), "外部 sink 同样不能收到 secret");
|
|
474
|
+
assert.ok(!bot.status().lastError.includes(SECRET), "状态里的错误文案也不能带 secret");
|
|
475
|
+
} finally {
|
|
476
|
+
await bot.stop();
|
|
477
|
+
await server.close();
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// ─────────────────────────── ⑧ allowFrom 白名单 ───────────────────────────
|
|
482
|
+
|
|
483
|
+
test("白名单 allowFrom:陌生发送者被拒绝且不绑定;白名单内正常绑定", async () => {
|
|
484
|
+
const server = await startFakeServer({ onFrame: (f, ws) => { autoAckAuth(f, ws); autoAckPing(f, ws); } });
|
|
485
|
+
const bot = createWecomBot({
|
|
486
|
+
botId: BOT_ID, secret: SECRET, url: server.url, logger: () => {},
|
|
487
|
+
allowFrom: ["user-ok"]
|
|
488
|
+
});
|
|
489
|
+
try {
|
|
490
|
+
const messages = [];
|
|
491
|
+
const rejected = [];
|
|
492
|
+
bot.on("message", (m) => messages.push(m));
|
|
493
|
+
bot.on("inbound-rejected", (m) => rejected.push(m));
|
|
494
|
+
bot.start();
|
|
495
|
+
await waitFor(() => bot.status().authenticated, { label: "认证完成" });
|
|
496
|
+
|
|
497
|
+
server.push({
|
|
498
|
+
cmd: "aibot_msg_callback", headers: { req_id: "evil-1" },
|
|
499
|
+
body: { msgid: "e1", msgtype: "text", text: { content: "删库跑路" }, from: { userid: "user-evil" } }
|
|
500
|
+
});
|
|
501
|
+
await waitFor(() => rejected.length === 1, { label: "拒绝事件" });
|
|
502
|
+
assert.equal(messages.length, 0, "白名单外不得分发");
|
|
503
|
+
assert.equal(bot.status().boundChatId, "", "白名单外不得绑定");
|
|
504
|
+
const reply = await waitFor(() => server.state.frames.find((f) => f.cmd === "aibot_respond_msg"), { label: "拒绝回复" });
|
|
505
|
+
assert.match(reply.body.stream.content, /权限/);
|
|
506
|
+
|
|
507
|
+
server.push({
|
|
508
|
+
cmd: "aibot_msg_callback", headers: { req_id: "ok-1" },
|
|
509
|
+
body: { msgid: "e2", msgtype: "text", text: { content: "你好" }, from: { userid: "user-ok" } }
|
|
510
|
+
});
|
|
511
|
+
await waitFor(() => messages.length === 1, { label: "白名单内分发" });
|
|
512
|
+
assert.equal(bot.status().boundChatId, "user-ok");
|
|
513
|
+
} finally {
|
|
514
|
+
await bot.stop();
|
|
515
|
+
await server.close();
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
// ─────────────────────────── ⑨ 入站转发(fake subscriber)───────────────────────────
|
|
520
|
+
|
|
521
|
+
test("★ 入站转发:有会话 → promptSession({sessionId,text});无会话 → 回复「还没选择会话」且不调用", async () => {
|
|
522
|
+
const server = await startFakeServer({ onFrame: (f, ws) => { autoAckAuth(f, ws); autoAckPing(f, ws); } });
|
|
523
|
+
// 场景 A:订阅器已发现运行中的会话
|
|
524
|
+
{
|
|
525
|
+
const sub = fakeSubscriber();
|
|
526
|
+
const bot = createWecomBot({ botId: BOT_ID, secret: SECRET, url: server.url, logger: () => {}, subscriber: sub });
|
|
527
|
+
try {
|
|
528
|
+
bot.start();
|
|
529
|
+
await waitFor(() => bot.status().authenticated, { label: "认证完成" });
|
|
530
|
+
sub.emit("session-state", { sessionId: "session-abc", state: "running" });
|
|
531
|
+
await sleep(30);
|
|
532
|
+
server.push({
|
|
533
|
+
cmd: "aibot_msg_callback", headers: { req_id: "fwd-1" },
|
|
534
|
+
body: { msgid: "m-f1", msgtype: "text", text: { content: "跑一下测试" }, from: { userid: "user-a" } }
|
|
535
|
+
});
|
|
536
|
+
await waitFor(() => sub.promptCalls.length === 1, { label: "promptSession 调用" });
|
|
537
|
+
assert.deepEqual(sub.promptCalls[0], { sessionId: "session-abc", text: "跑一下测试" });
|
|
538
|
+
const reply = await waitFor(() => server.state.frames.find((f) => f.cmd === "aibot_respond_msg"), { label: "成功回复" });
|
|
539
|
+
assert.match(reply.body.stream.content, /已转给桌面端会话/);
|
|
540
|
+
assert.equal(bot.status().inbound, 1);
|
|
541
|
+
} finally {
|
|
542
|
+
await bot.stop();
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
// 场景 B:没有任何会话 → 提示用户先选择,且不得调用 promptSession
|
|
546
|
+
{
|
|
547
|
+
const sub = fakeSubscriber({ sessions: [] });
|
|
548
|
+
const bot = createWecomBot({ botId: BOT_ID, secret: SECRET, url: server.url, logger: () => {}, subscriber: sub });
|
|
549
|
+
try {
|
|
550
|
+
const failed = [];
|
|
551
|
+
bot.on("inbound-failed", (e) => failed.push(e));
|
|
552
|
+
bot.start();
|
|
553
|
+
await waitFor(() => bot.status().authenticated, { label: "认证完成" });
|
|
554
|
+
server.push({
|
|
555
|
+
cmd: "aibot_msg_callback", headers: { req_id: "fwd-2" },
|
|
556
|
+
body: { msgid: "m-f2", msgtype: "text", text: { content: "hello" }, from: { userid: "user-a" } }
|
|
557
|
+
});
|
|
558
|
+
await waitFor(() => failed.length === 1, { label: "转发失败事件" });
|
|
559
|
+
assert.equal(failed[0].reason, "no_session");
|
|
560
|
+
assert.equal(sub.promptCalls.length, 0, "没有会话时绝不乱发 promptSession");
|
|
561
|
+
const reply = await waitFor(() => server.state.frames.filter((f) => f.cmd === "aibot_respond_msg").pop(), { label: "提示回复" });
|
|
562
|
+
assert.match(reply.body.stream.content, /还没在桌面端选择要接管的会话/);
|
|
563
|
+
} finally {
|
|
564
|
+
await bot.stop();
|
|
565
|
+
await server.close();
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// ─────────────────────────── ⑩ 出站通知(fake subscriber)───────────────────────────
|
|
571
|
+
|
|
572
|
+
test("★ 出站通知:turn_done/turn_error → Markdown、approval_request → 审批卡片,都发到绑定的 chatid;无接收方时跳过", async () => {
|
|
573
|
+
const server = await startFakeServer({ onFrame: (f, ws) => { autoAckAuth(f, ws); autoAckPing(f, ws); } });
|
|
574
|
+
const sub = fakeSubscriber();
|
|
575
|
+
|
|
576
|
+
// 纯函数映射(文案钉死)
|
|
577
|
+
assert.match(toMarkdownNotification({ kind: NODE_KINDS.TURN_END, sessionId: "s1", reason: "completed" }), /^✅ 任务完成/);
|
|
578
|
+
assert.match(toMarkdownNotification({ kind: NODE_KINDS.TURN_END, sessionId: "s1", reason: "error" }), /^❌ 出错了/);
|
|
579
|
+
assert.match(toMarkdownNotification({ kind: NODE_KINDS.TURN_END, sessionId: "s1", reason: "max-tokens" }), /^❌ 出错了/);
|
|
580
|
+
assert.match(toMarkdownNotification({ kind: NODE_KINDS.SESSION_ERROR, sessionId: "s1", message: "boom" }), /^❌ 出错了.*boom/s);
|
|
581
|
+
assert.match(toMarkdownNotification({ kind: NODE_KINDS.APPROVAL_REQUEST, sessionId: "s1", toolName: "Bash" }), /^🛂 需要你在电脑上拍板|Bash/);
|
|
582
|
+
assert.equal(toMarkdownNotification({ kind: NODE_KINDS.GAP }, {}), null, "其它节点不在这里推");
|
|
583
|
+
assert.equal(toMarkdownNotification({ kind: NODE_KINDS.TURN_END, reason: "completed" }, { sessionTitle: () => "重构登录" }), "✅ 任务完成|重构登录");
|
|
584
|
+
|
|
585
|
+
// 场景 A:先入站绑定 chatid,再出站推送
|
|
586
|
+
const bot = createWecomBot({ botId: BOT_ID, secret: SECRET, url: server.url, logger: () => {}, subscriber: sub });
|
|
587
|
+
try {
|
|
588
|
+
bot.start();
|
|
589
|
+
await waitFor(() => bot.status().authenticated, { label: "认证完成" });
|
|
590
|
+
server.push({
|
|
591
|
+
cmd: "aibot_msg_callback", headers: { req_id: "bind-1" },
|
|
592
|
+
body: { msgid: "m-b1", msgtype: "text", text: { content: "hi" }, from: { userid: "user-a" } }
|
|
593
|
+
});
|
|
594
|
+
await waitFor(() => bot.status().boundChatId === "user-a", { label: "绑定" });
|
|
595
|
+
|
|
596
|
+
sub.emit("event", { kind: NODE_KINDS.TURN_END, sessionId: "session-x", reason: "completed", turn: 1, seq: 9, at: Date.now() });
|
|
597
|
+
const send = await waitFor(() => server.state.frames.find((f) => f.cmd === "aibot_send_msg"), { label: "完成通知" });
|
|
598
|
+
assert.equal(send.body.chatid, "user-a", "优先用最近一次入站 chatid");
|
|
599
|
+
assert.match(send.body.markdown.content, /^✅ 任务完成/);
|
|
600
|
+
|
|
601
|
+
// 同一事件重复(重连快照补报)→ 去重
|
|
602
|
+
const before = server.state.frames.filter((f) => f.cmd === "aibot_send_msg").length;
|
|
603
|
+
sub.emit("event", { kind: NODE_KINDS.TURN_END, sessionId: "session-x", reason: "completed", turn: 1, seq: 9, at: Date.now() });
|
|
604
|
+
await sleep(120);
|
|
605
|
+
assert.equal(server.state.frames.filter((f) => f.cmd === "aibot_send_msg").length, before, "同一 turn/end 不得重复推送");
|
|
606
|
+
|
|
607
|
+
// 出错 → 仍是 markdown
|
|
608
|
+
sub.emit("event", { kind: NODE_KINDS.SESSION_ERROR, sessionId: "session-x", message: "build failed" });
|
|
609
|
+
const errSend = await waitFor(() => server.state.frames.filter((f) => f.cmd === "aibot_send_msg" && f.body.markdown && /出错了/.test(f.body.markdown.content)).pop(), { label: "出错通知" });
|
|
610
|
+
assert.equal(errSend.body.chatid, "user-a");
|
|
611
|
+
// 审批类 → **卡片**(task-24),不再是 markdown
|
|
612
|
+
sub.emit("event", { kind: NODE_KINDS.APPROVAL_REQUEST, sessionId: "session-x", eventId: "ev-1", toolName: "Bash", reason: "要执行一条命令" });
|
|
613
|
+
const cardSend = await waitFor(() => server.state.frames.filter((f) => f.cmd === "aibot_send_msg" && f.body.msgtype === "template_card").pop(), { label: "审批卡片" });
|
|
614
|
+
assert.equal(cardSend.body.chatid, "user-a");
|
|
615
|
+
assert.equal(cardSend.body.template_card.card_type, "button_interaction");
|
|
616
|
+
assert.match(cardSend.body.template_card.main_title.desc, /Bash/);
|
|
617
|
+
} finally {
|
|
618
|
+
await bot.stop();
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// 场景 B:没有入站绑定,也没有 targetChatId → 跳过并记日志(不发任何推送)
|
|
622
|
+
{
|
|
623
|
+
const sub2 = fakeSubscriber();
|
|
624
|
+
const bot2 = createWecomBot({ botId: BOT_ID, secret: SECRET, url: server.url, logger: () => {}, subscriber: sub2 });
|
|
625
|
+
try {
|
|
626
|
+
const sentBefore = server.state.frames.filter((f) => f.cmd === "aibot_send_msg").length;
|
|
627
|
+
bot2.start();
|
|
628
|
+
await waitFor(() => bot2.status().authenticated, { label: "认证完成2" });
|
|
629
|
+
sub2.emit("event", { kind: NODE_KINDS.TURN_END, sessionId: "s2", reason: "completed", turn: 1, seq: 1 });
|
|
630
|
+
await sleep(150);
|
|
631
|
+
assert.equal(server.state.frames.filter((f) => f.cmd === "aibot_send_msg").length, sentBefore, "没有接收方时跳过");
|
|
632
|
+
assert.ok(bot2._logger.lines.some((l) => /还没绑定接收方/.test(l)), "要记一条可排查的日志");
|
|
633
|
+
} finally {
|
|
634
|
+
await bot2.stop();
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// 场景 C:targetChatId 兜底
|
|
639
|
+
{
|
|
640
|
+
const sub3 = fakeSubscriber();
|
|
641
|
+
const bot3 = createWecomBot({
|
|
642
|
+
botId: BOT_ID, secret: SECRET, url: server.url, logger: () => {}, subscriber: sub3,
|
|
643
|
+
targetChatId: "user-target"
|
|
644
|
+
});
|
|
645
|
+
try {
|
|
646
|
+
bot3.start();
|
|
647
|
+
await waitFor(() => bot3.status().authenticated, { label: "认证完成3" });
|
|
648
|
+
sub3.emit("event", { kind: NODE_KINDS.TURN_END, sessionId: "s3", reason: "completed", turn: 2, seq: 2 });
|
|
649
|
+
const send = await waitFor(() => server.state.frames.filter((f) => f.cmd === "aibot_send_msg" && f.body.chatid === "user-target").pop(), { label: "targetChatId 兜底" });
|
|
650
|
+
assert.ok(send.body.markdown.content.startsWith("✅ 任务完成"));
|
|
651
|
+
} finally {
|
|
652
|
+
await bot3.stop();
|
|
653
|
+
await server.close();
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
// ─────────────────────────── ⑩b 回答回传(入站 → agent 回答 → 原路发回)───────────────────────────
|
|
659
|
+
|
|
660
|
+
/** 建一个「已认证 + 入站一次 + 可注入回答」的 bot,返回便捷断言面。 */
|
|
661
|
+
async function setupRelayBot(over = {}) {
|
|
662
|
+
const server = await startFakeServer({ onFrame: (f, ws) => { autoAckAuth(f, ws); autoAckPing(f, ws); autoAckSend(f, ws); } });
|
|
663
|
+
const lines = [];
|
|
664
|
+
const sub = fakeSubscriber({
|
|
665
|
+
sessions: [{ sessionId: "session-abc", running: true }],
|
|
666
|
+
...(over.sub || {})
|
|
667
|
+
});
|
|
668
|
+
if (over.assistantText !== undefined) sub.assistantReply = over.assistantText;
|
|
669
|
+
const bot = createWecomBot({
|
|
670
|
+
botId: BOT_ID, secret: SECRET, url: server.url,
|
|
671
|
+
logger: (level, line) => lines.push(line),
|
|
672
|
+
subscriber: sub,
|
|
673
|
+
...(over.bot || {})
|
|
674
|
+
});
|
|
675
|
+
const sends = () => server.state.frames.filter((f) => f.cmd === "aibot_send_msg");
|
|
676
|
+
const inbound = async (msgid, content, userid = "user-a") => {
|
|
677
|
+
const before = sub.promptCalls.length;
|
|
678
|
+
server.push({
|
|
679
|
+
cmd: "aibot_msg_callback", headers: { req_id: `in-${msgid}` },
|
|
680
|
+
body: { msgid, msgtype: "text", text: { content }, from: { userid } }
|
|
681
|
+
});
|
|
682
|
+
await waitFor(() => sub.promptCalls.length > before, { label: `入站 ${msgid} 被转发` });
|
|
683
|
+
};
|
|
684
|
+
const turnEnd = (extra = {}) => sub.emit("event", {
|
|
685
|
+
kind: NODE_KINDS.TURN_END, sessionId: "session-abc", reason: "completed",
|
|
686
|
+
turn: 1, seq: 1, at: Date.now(), ...extra
|
|
687
|
+
});
|
|
688
|
+
return { server, sub, bot, lines, sends, inbound, turnEnd, close: async () => { await bot.stop(); await server.close(); } };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
test("★ 回答回传:入站后同会话 turn_done 发**回答正文**(不再发 ✅标题);其它会话/已消费保持标题通知;取不到正文降级标题", async () => {
|
|
692
|
+
const rt = await setupRelayBot({ assistantText: { ok: true, text: "这是 agent 的最终回答" } });
|
|
693
|
+
try {
|
|
694
|
+
rt.bot.start();
|
|
695
|
+
await waitFor(() => rt.bot.status().authenticated, { label: "认证完成" });
|
|
696
|
+
await rt.inbound("relay-1", "你好");
|
|
697
|
+
|
|
698
|
+
// ① 入站后同会话 turn/end → 只发正文,不发标题
|
|
699
|
+
rt.turnEnd({ turn: 1, seq: 11 });
|
|
700
|
+
const first = await waitFor(() => rt.sends()[0], { label: "回答回传" });
|
|
701
|
+
assert.equal(first.body.chatid, "user-a", "回答必须发回最近入站的会话(boundChatId)");
|
|
702
|
+
assert.equal(first.body.msgtype, "markdown");
|
|
703
|
+
assert.equal(first.body.markdown.content, "这是 agent 的最终回答", "回答回传 = 正文本身(不带 ✅ 标题)");
|
|
704
|
+
assert.equal(rt.bot.status().answers, 1);
|
|
705
|
+
assert.deepEqual(rt.sub.lastAssistantCalls, [{ sessionId: "session-abc" }], "应通过 lastAssistantText 取正文");
|
|
706
|
+
await sleep(120);
|
|
707
|
+
assert.equal(rt.sends().length, 1, "同一次对话不得再补一条「✅ 任务完成」");
|
|
708
|
+
|
|
709
|
+
// ② 其它会话的 turn/end → 仍是标题通知
|
|
710
|
+
rt.sub.emit("event", { kind: NODE_KINDS.TURN_END, sessionId: "session-other", reason: "completed", turn: 1, seq: 12, at: Date.now() });
|
|
711
|
+
const other = await waitFor(() => rt.sends().find((f) => /任务完成/.test(f.body.markdown.content)), { label: "其它会话标题通知" });
|
|
712
|
+
assert.match(other.body.markdown.content, /^✅ 任务完成/);
|
|
713
|
+
|
|
714
|
+
// ③ 同会话但已消费过一次 → 保持标题通知(不会重复回传正文)
|
|
715
|
+
rt.turnEnd({ turn: 2, seq: 13 });
|
|
716
|
+
const again = await waitFor(() => rt.sends().filter((f) => /任务完成/.test(f.body.markdown.content)).length >= 2, { label: "消费一次后的标题通知" });
|
|
717
|
+
assert.ok(again, "同一次入站只回传一次正文,之后回落到标题通知");
|
|
718
|
+
|
|
719
|
+
// ④ 取不到正文 → 降级标题通知(不丢消息、也不假装有正文)
|
|
720
|
+
await rt.inbound("relay-2", "再问一句");
|
|
721
|
+
rt.sub.assistantReply = { ok: false, code: "page-failed", text: "" };
|
|
722
|
+
rt.turnEnd({ turn: 3, seq: 14 });
|
|
723
|
+
await waitFor(() => rt.lines.some((l) => /降级为标题通知/.test(l)), { label: "降级日志" });
|
|
724
|
+
const fallback = rt.sends().filter((f) => /任务完成/.test(f.body.markdown.content)).pop();
|
|
725
|
+
assert.match(fallback.body.markdown.content, /^✅ 任务完成/, "取不到正文时退回标题通知");
|
|
726
|
+
|
|
727
|
+
// ⑤ 重连快照补报的旧 turn(fromSnapshot)不得回传正文
|
|
728
|
+
await rt.inbound("relay-3", "第三句");
|
|
729
|
+
rt.sub.assistantReply = { ok: true, text: "来自快照的旧回答" };
|
|
730
|
+
const beforeSnapshot = rt.sends().length;
|
|
731
|
+
rt.turnEnd({ turn: 4, seq: 15, fromSnapshot: true });
|
|
732
|
+
await sleep(150);
|
|
733
|
+
assert.equal(rt.sends().length, beforeSnapshot + 1, "fromSnapshot 只走标题通知(一条),不触发正文回传");
|
|
734
|
+
assert.ok(!rt.sends().some((f) => f.body.markdown.content === "来自快照的旧回答"));
|
|
735
|
+
} finally {
|
|
736
|
+
await rt.close();
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
test("★ 回答回传:turn/end 瞬间正文还没落库 → 短重试后仍能回传(不是直接降级)", async () => {
|
|
741
|
+
let call = 0;
|
|
742
|
+
const rt = await setupRelayBot({
|
|
743
|
+
assistantText: () => {
|
|
744
|
+
call += 1;
|
|
745
|
+
// 第一次「ok 但空」(真实竞态),第二次拿到正文
|
|
746
|
+
return call === 1 ? { ok: true, text: "" } : { ok: true, text: "重试后拿到的回答" };
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
try {
|
|
750
|
+
rt.bot.start();
|
|
751
|
+
await waitFor(() => rt.bot.status().authenticated, { label: "认证完成" });
|
|
752
|
+
await rt.inbound("retry-1", "你好");
|
|
753
|
+
rt.turnEnd({ turn: 1, seq: 31 });
|
|
754
|
+
const send = await waitFor(() => rt.sends()[0], { label: "重试后的回答回传" });
|
|
755
|
+
assert.equal(send.body.markdown.content, "重试后拿到的回答", "ok 但空 → 补一次再发,不应直接降级");
|
|
756
|
+
assert.equal(rt.sub.lastAssistantCalls.length, 2, "应恰好重试一次");
|
|
757
|
+
assert.equal(rt.bot.status().answers, 1);
|
|
758
|
+
} finally {
|
|
759
|
+
await rt.close();
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
test("★ 回答回传边界:超过 TTL 不回传、超长截断+注明、正文绝不进日志、非 completed 带状态行", async () => {
|
|
764
|
+
// ① TTL:注入 40ms,入站后等过期再 turn/end → 只发标题通知
|
|
765
|
+
{
|
|
766
|
+
const rt = await setupRelayBot({ assistantText: { ok: true, text: "过期不该发的回答" }, bot: { answerRelayTtlMs: 40 } });
|
|
767
|
+
try {
|
|
768
|
+
rt.bot.start();
|
|
769
|
+
await waitFor(() => rt.bot.status().authenticated, { label: "认证完成" });
|
|
770
|
+
await rt.inbound("ttl-1", "你好");
|
|
771
|
+
await sleep(120); // 超过 40ms TTL
|
|
772
|
+
rt.turnEnd({ turn: 1, seq: 21, at: Date.now() });
|
|
773
|
+
const send = await waitFor(() => rt.sends()[0], { label: "超时后的标题通知" });
|
|
774
|
+
assert.match(send.body.markdown.content, /^✅ 任务完成/, "超过 10 分钟语义(测试缩放为 40ms)应回到标题通知");
|
|
775
|
+
assert.ok(!send.body.markdown.content.includes("过期不该发的回答"));
|
|
776
|
+
} finally {
|
|
777
|
+
await rt.close();
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// ② 截断 + 正文不进日志 + 非 completed 状态行
|
|
782
|
+
{
|
|
783
|
+
const longAnswer = "开头" + "长".repeat(300) + "SECRET-ANSWER-TAIL";
|
|
784
|
+
const rt = await setupRelayBot({
|
|
785
|
+
assistantText: { ok: true, text: longAnswer },
|
|
786
|
+
bot: { answerMaxChars: 50 }
|
|
787
|
+
});
|
|
788
|
+
try {
|
|
789
|
+
rt.bot.start();
|
|
790
|
+
await waitFor(() => rt.bot.status().authenticated, { label: "认证完成" });
|
|
791
|
+
await rt.inbound("trunc-1", "来点长的");
|
|
792
|
+
rt.turnEnd({ turn: 1, seq: 22 });
|
|
793
|
+
const send = await waitFor(() => rt.sends()[0], { label: "截断后的回答" });
|
|
794
|
+
const content = send.body.markdown.content;
|
|
795
|
+
assert.ok(content.endsWith(ANSWER_TRUNCATED_NOTE), `超长回答要以「${ANSWER_TRUNCATED_NOTE}」结尾`);
|
|
796
|
+
assert.equal(content.length, 50 + ANSWER_TRUNCATED_NOTE.length, "正文部分必须恰好截到 answerMaxChars");
|
|
797
|
+
assert.ok(!content.includes("SECRET-ANSWER-TAIL"));
|
|
798
|
+
assert.ok(content.startsWith("开头"));
|
|
799
|
+
// 日志只打长度,不打正文
|
|
800
|
+
const allLogs = rt.lines.join("\n");
|
|
801
|
+
assert.ok(!allLogs.includes("SECRET-ANSWER-TAIL"), "★ 回答正文绝不能进日志");
|
|
802
|
+
assert.ok(!allLogs.includes("长".repeat(40)), "★ 回答正文片段也不能进日志");
|
|
803
|
+
assert.ok(allLogs.split("\n").some((l) => /回答回传 .* len=\d+/.test(l)), "日志只记长度");
|
|
804
|
+
assert.ok(allLogs.split("\n").some((l) => /已截断/.test(l)), "日志要标出截断");
|
|
805
|
+
|
|
806
|
+
// ③ 非 completed:带一句状态行 + 正文(仍是一条消息)
|
|
807
|
+
rt.sub.assistantReply = { ok: true, text: "只跑了一半的结果" };
|
|
808
|
+
await rt.inbound("err-1", "再试");
|
|
809
|
+
rt.turnEnd({ turn: 2, seq: 23, reason: "error" });
|
|
810
|
+
const errSend = await waitFor(() => rt.sends().find((f) => f.body.markdown.content.includes("只跑了一半的结果")), { label: "出错时回传" });
|
|
811
|
+
assert.match(errSend.body.markdown.content, /^❌ 出错了/);
|
|
812
|
+
assert.ok(errSend.body.markdown.content.includes("只跑了一半的结果"), "同一条消息里既给状态也给正文");
|
|
813
|
+
} finally {
|
|
814
|
+
await rt.close();
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
// ─────────────────────────── ⑩c 审批卡片(task-24)───────────────────────────
|
|
820
|
+
|
|
821
|
+
/** 模拟「用户点了卡片按钮」:官方入站事件帧(body.event.eventtype=template_card_event)。 */
|
|
822
|
+
function pushCardClick(server, { reqId, taskId, eventKey, chatid = "user-a", userid = "user-a" }) {
|
|
823
|
+
server.push({
|
|
824
|
+
cmd: "aibot_event_callback",
|
|
825
|
+
headers: { req_id: reqId },
|
|
826
|
+
body: {
|
|
827
|
+
msgid: `evt-${reqId}`,
|
|
828
|
+
create_time: 1700000000,
|
|
829
|
+
msgtype: "event",
|
|
830
|
+
chatid,
|
|
831
|
+
chattype: "single",
|
|
832
|
+
from: { userid },
|
|
833
|
+
event: { eventtype: "template_card_event", event_key: eventKey, task_id: taskId }
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** 建一个「已认证、可收卡片点击」的 bot。 */
|
|
839
|
+
async function setupCardBot(over = {}) {
|
|
840
|
+
const server = await startFakeServer({ onFrame: (f, ws) => { autoAckAuth(f, ws); autoAckPing(f, ws); autoAckSend(f, ws); } });
|
|
841
|
+
const lines = [];
|
|
842
|
+
const decisions = [];
|
|
843
|
+
const bot = createWecomBot({
|
|
844
|
+
botId: BOT_ID, secret: SECRET, url: server.url,
|
|
845
|
+
logger: (level, line) => lines.push(line),
|
|
846
|
+
onApprovalDecision: async (requestId, decision, meta) => {
|
|
847
|
+
decisions.push({ requestId, decision, meta });
|
|
848
|
+
if (typeof over.decide === "function") return over.decide(requestId, decision, meta);
|
|
849
|
+
return { ok: true, via: "dsh" };
|
|
850
|
+
},
|
|
851
|
+
...(over.bot || {})
|
|
852
|
+
});
|
|
853
|
+
const sends = () => server.state.frames.filter((f) => f.cmd === "aibot_send_msg");
|
|
854
|
+
const updates = () => server.state.frames.filter((f) => f.cmd === "aibot_respond_update_msg");
|
|
855
|
+
const cards = () => sends().filter((f) => f.body && f.body.msgtype === "template_card");
|
|
856
|
+
const texts = () => sends().filter((f) => f.body && f.body.markdown);
|
|
857
|
+
return {
|
|
858
|
+
server, bot, lines, decisions, sends, updates, cards, texts,
|
|
859
|
+
async start() { bot.start(); await waitFor(() => bot.status().authenticated, { label: "认证完成(card)" }); },
|
|
860
|
+
async close() { await bot.stop(); await server.close(); }
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
test("★ 审批卡片报文:button_interaction 结构 / task_id 安全哈希 / 按钮 key / 摘要 ≤112 / 未认证拒绝", async () => {
|
|
865
|
+
// 纯函数先钉死(不依赖网络)
|
|
866
|
+
assert.match(approvalTaskId("ev-abc"), /^apr_[A-Za-z0-9_-]+$/);
|
|
867
|
+
assert.ok(approvalTaskId("x".repeat(500)).length <= 128, "task_id 必须 ≤128");
|
|
868
|
+
assert.notEqual(approvalTaskId("a"), approvalTaskId("b"), "不同 requestId → 不同 task_id");
|
|
869
|
+
assert.deepEqual(parseApprovalEventKey("allow:abc:def"), { decision: "allow", requestId: "abc:def" }, "只按第一个冒号切");
|
|
870
|
+
assert.deepEqual(parseApprovalEventKey("deny:xyz"), { decision: "deny", requestId: "xyz" });
|
|
871
|
+
assert.equal(parseApprovalEventKey("bogus:x"), null);
|
|
872
|
+
assert.equal(parseApprovalEventKey("allow:"), null, "空 requestId 不算合法按钮");
|
|
873
|
+
const pureCard = buildApprovalCard({ requestId: "ev-1", summary: "长".repeat(300), toolName: "Bash" });
|
|
874
|
+
assert.equal(pureCard.card_type, "button_interaction");
|
|
875
|
+
assert.equal(pureCard.sub_title_text.length, APPROVAL_CARD_SUMMARY_MAX, "摘要必须截到 112 字");
|
|
876
|
+
assert.equal(pureCard.button_list[0].style, 1);
|
|
877
|
+
assert.equal(pureCard.button_list[1].style, 2);
|
|
878
|
+
assert.match(buildDecidedCard({ requestId: "ev-1", state: "allowed", who: "张三", at: Date.now() }).main_title.title, /已允许/);
|
|
879
|
+
assert.ok(normalizeSessionStatusList([{ id: "a" }, { id: "a" }, { id: "b", title: " t ", updatedAt: 5 }]).length === 2);
|
|
880
|
+
|
|
881
|
+
const rt = await setupCardBot();
|
|
882
|
+
try {
|
|
883
|
+
await rt.start();
|
|
884
|
+
const longSummary = "命令详情 ".repeat(60);
|
|
885
|
+
const r = rt.bot.sendApprovalCard("user-a", { requestId: "ev-abc:123", title: "需要你在电脑上拍板", summary: longSummary, toolName: "Bash" });
|
|
886
|
+
assert.equal(r.ok, true);
|
|
887
|
+
const frame = await waitFor(() => rt.cards()[0], { label: "审批卡片帧" });
|
|
888
|
+
assert.equal(frame.body.chatid, "user-a");
|
|
889
|
+
assert.equal(frame.body.msgtype, "template_card");
|
|
890
|
+
const card = frame.body.template_card;
|
|
891
|
+
assert.equal(card.card_type, "button_interaction");
|
|
892
|
+
assert.match(card.task_id, /^apr_[A-Za-z0-9_-]+$/);
|
|
893
|
+
assert.ok(card.task_id.length <= 128);
|
|
894
|
+
assert.equal(card.task_id, r.taskId);
|
|
895
|
+
assert.equal(card.main_title.title, "需要你在电脑上拍板");
|
|
896
|
+
assert.match(card.main_title.desc, /Bash/);
|
|
897
|
+
assert.ok(card.sub_title_text.length <= APPROVAL_CARD_SUMMARY_MAX);
|
|
898
|
+
assert.equal(card.button_list.length, 2);
|
|
899
|
+
assert.deepEqual(card.button_list.map((b) => ({ text: b.text, style: b.style })), [
|
|
900
|
+
{ text: "允许一次", style: 1 }, { text: "拒绝", style: 2 }
|
|
901
|
+
]);
|
|
902
|
+
assert.equal(card.button_list[0].key, "allow:ev-abc:123", "requestId 原样进 key(含冒号)");
|
|
903
|
+
assert.equal(card.button_list[1].key, "deny:ev-abc:123");
|
|
904
|
+
// 未认证时如实失败(不静默)
|
|
905
|
+
assert.equal(rt.bot.sendApprovalCard("user-a", { requestId: "" }).reason, "no_request_id");
|
|
906
|
+
assert.equal(rt.bot.sendApprovalCard("", { requestId: "x" }).reason, "no_chatid");
|
|
907
|
+
} finally {
|
|
908
|
+
await rt.close();
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
test("★ 审批卡片点击:allow/deny → onApprovalDecision → aibot_respond_update_msg(req_id=事件帧)→ 短文本确认", async () => {
|
|
913
|
+
const rt = await setupCardBot();
|
|
914
|
+
try {
|
|
915
|
+
await rt.start();
|
|
916
|
+
// 允许
|
|
917
|
+
const r1 = rt.bot.sendApprovalCard("user-a", { requestId: "ev-allow", toolName: "Bash" });
|
|
918
|
+
await waitFor(() => rt.cards().length === 1, { label: "卡片已发" });
|
|
919
|
+
pushCardClick(rt.server, { reqId: "evt-req-1", taskId: r1.taskId, eventKey: "allow:ev-allow" });
|
|
920
|
+
await waitFor(() => rt.decisions.length === 1, { label: "allow 回调" });
|
|
921
|
+
assert.deepEqual(
|
|
922
|
+
{ requestId: rt.decisions[0].requestId, decision: rt.decisions[0].decision, source: rt.decisions[0].meta.source },
|
|
923
|
+
{ requestId: "ev-allow", decision: "allow", source: "wecom-card" }
|
|
924
|
+
);
|
|
925
|
+
const upd1 = await waitFor(() => rt.updates()[0], { label: "卡片更新(allow)" });
|
|
926
|
+
assert.equal(upd1.headers.req_id, "evt-req-1", "★ 更新帧的 req_id 必须是**事件帧**的 req_id");
|
|
927
|
+
assert.equal(upd1.body.response_type, "update_template_card");
|
|
928
|
+
assert.equal(upd1.body.template_card.card_type, "text_notice");
|
|
929
|
+
assert.match(upd1.body.template_card.main_title.title, /已允许/);
|
|
930
|
+
const txt1 = await waitFor(() => rt.texts().find((f) => /已允许一次/.test(f.body.markdown.content)), { label: "允许确认文本" });
|
|
931
|
+
assert.equal(txt1.body.chatid, "user-a");
|
|
932
|
+
|
|
933
|
+
// 拒绝
|
|
934
|
+
const r2 = rt.bot.sendApprovalCard("user-a", { requestId: "ev-deny", toolName: "Write" });
|
|
935
|
+
await waitFor(() => rt.cards().length === 2, { label: "第二张卡片" });
|
|
936
|
+
pushCardClick(rt.server, { reqId: "evt-req-2", taskId: r2.taskId, eventKey: "deny:ev-deny", userid: "user-b" });
|
|
937
|
+
await waitFor(() => rt.decisions.length === 2, { label: "deny 回调" });
|
|
938
|
+
assert.equal(rt.decisions[1].decision, "deny");
|
|
939
|
+
const upd2 = await waitFor(() => rt.updates().find((f) => f.headers.req_id === "evt-req-2"), { label: "卡片更新(deny)" });
|
|
940
|
+
assert.match(upd2.body.template_card.main_title.title, /已拒绝/);
|
|
941
|
+
assert.match(upd2.body.template_card.main_title.desc, /user-b/, "卡片上写清操作人");
|
|
942
|
+
await waitFor(() => rt.texts().find((f) => /已拒绝/.test(f.body.markdown.content)), { label: "拒绝确认文本" });
|
|
943
|
+
// senderId 不进日志(只 hash)
|
|
944
|
+
assert.ok(!rt.lines.join("\n").includes("user-b"), "★ 操作人 userid 不得进日志(只 hash)");
|
|
945
|
+
assert.ok(rt.lines.some((l) => /审批卡片点击/.test(l)));
|
|
946
|
+
assert.equal(rt.bot.status().approvalDecisions, 2);
|
|
947
|
+
} finally {
|
|
948
|
+
await rt.close();
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
test("★ 审批卡片:重复点击幂等(不再回调,回「已处理」);过期后点=按拒绝处理(fail-closed)", async () => {
|
|
953
|
+
// 幂等
|
|
954
|
+
{
|
|
955
|
+
const rt = await setupCardBot();
|
|
956
|
+
try {
|
|
957
|
+
await rt.start();
|
|
958
|
+
const r = rt.bot.sendApprovalCard("user-a", { requestId: "ev-dup" });
|
|
959
|
+
await waitFor(() => rt.cards().length === 1, { label: "卡片" });
|
|
960
|
+
pushCardClick(rt.server, { reqId: "dup-1", taskId: r.taskId, eventKey: "allow:ev-dup" });
|
|
961
|
+
await waitFor(() => rt.decisions.length === 1, { label: "首次点击" });
|
|
962
|
+
pushCardClick(rt.server, { reqId: "dup-2", taskId: r.taskId, eventKey: "allow:ev-dup" });
|
|
963
|
+
await sleep(120);
|
|
964
|
+
assert.equal(rt.decisions.length, 1, "★ 同一张卡重复点击不得再次调回调(幂等)");
|
|
965
|
+
const dupText = await waitFor(() => rt.texts().find((f) => /已经处理过/.test(f.body.markdown.content)), { label: "已处理提示" });
|
|
966
|
+
assert.match(dupText.body.markdown.content, /已允许/);
|
|
967
|
+
const upd2 = await waitFor(() => rt.updates().find((f) => f.headers.req_id === "dup-2"), { label: "重复点击也更新卡片" });
|
|
968
|
+
assert.match(upd2.body.template_card.main_title.title, /已允许/);
|
|
969
|
+
} finally {
|
|
970
|
+
await rt.close();
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
// TTL 过期(注入 40ms)
|
|
974
|
+
{
|
|
975
|
+
const rt = await setupCardBot({ bot: { approvalCardTtlMs: 40 } });
|
|
976
|
+
try {
|
|
977
|
+
await rt.start();
|
|
978
|
+
const r = rt.bot.sendApprovalCard("user-a", { requestId: "ev-old" });
|
|
979
|
+
await waitFor(() => rt.cards().length === 1, { label: "卡片2" });
|
|
980
|
+
await sleep(120); // 超过 40ms TTL
|
|
981
|
+
pushCardClick(rt.server, { reqId: "old-1", taskId: r.taskId, eventKey: "allow:ev-old" });
|
|
982
|
+
await waitFor(() => rt.decisions.length === 1, { label: "过期也回调" });
|
|
983
|
+
assert.equal(rt.decisions[0].decision, "deny", "★ 过期点击必须 fail-closed 按拒绝处理(即使点的是允许)");
|
|
984
|
+
assert.equal(rt.decisions[0].meta.expired, true);
|
|
985
|
+
const upd = await waitFor(() => rt.updates().find((f) => f.headers.req_id === "old-1"), { label: "过期卡片更新" });
|
|
986
|
+
assert.match(upd.body.template_card.main_title.title, /已过期/);
|
|
987
|
+
const txt = await waitFor(() => rt.texts().find((f) => /过期/.test(f.body.markdown.content)), { label: "过期提示" });
|
|
988
|
+
assert.match(txt.body.markdown.content, /按「拒绝」处理/);
|
|
989
|
+
} finally {
|
|
990
|
+
await rt.close();
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
test("★ 审批回调失败:卡片显示「没能送达电脑端」+ 提示到电脑上确认(不假装成功)", async () => {
|
|
996
|
+
const rt = await setupCardBot({ decide: () => ({ ok: false, reason: "事件已作废(可能已在手机上拍板)" }) });
|
|
997
|
+
try {
|
|
998
|
+
await rt.start();
|
|
999
|
+
const r = rt.bot.sendApprovalCard("user-a", { requestId: "ev-fail" });
|
|
1000
|
+
await waitFor(() => rt.cards().length === 1, { label: "卡片" });
|
|
1001
|
+
pushCardClick(rt.server, { reqId: "fail-1", taskId: r.taskId, eventKey: "allow:ev-fail" });
|
|
1002
|
+
await waitFor(() => rt.decisions.length === 1, { label: "回调" });
|
|
1003
|
+
const upd = await waitFor(() => rt.updates().find((f) => f.headers.req_id === "fail-1"), { label: "失败卡片更新" });
|
|
1004
|
+
assert.match(upd.body.template_card.main_title.title, /没能送达/);
|
|
1005
|
+
const txt = await waitFor(() => rt.texts().find((f) => /没能送达电脑端/.test(f.body.markdown.content)), { label: "失败提示" });
|
|
1006
|
+
assert.equal(rt.bot.status().approvalDecisions, 0, "失败不计数为成功");
|
|
1007
|
+
} finally {
|
|
1008
|
+
await rt.close();
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
// ─────────────────────────── ⑩e 预览卡片(自检/演示)───────────────────────────
|
|
1013
|
+
|
|
1014
|
+
test("★ 预览卡片:报文结构 / 点击只更新卡+说明 / **不调 onApprovalDecision** / 可重复点 / 不污染真实审批", async () => {
|
|
1015
|
+
// 纯函数先钉死
|
|
1016
|
+
const ts = 1699999999123;
|
|
1017
|
+
const pure = buildPreviewCard(ts);
|
|
1018
|
+
assert.equal(pure.card_type, "button_interaction");
|
|
1019
|
+
assert.equal(pure.task_id, "apr_preview_9999999123", "task_id = apr_preview_ + 时间戳后 10 位");
|
|
1020
|
+
assert.equal(pure.main_title.title, "预览:需要你在电脑上拍板");
|
|
1021
|
+
assert.equal(pure.main_title.desc, "这是一张预览卡片,不会真的执行操作");
|
|
1022
|
+
assert.deepEqual(pure.button_list.map((b) => b.key), ["allow:preview:9999999123", "deny:preview:9999999123"]);
|
|
1023
|
+
assert.equal(pure.button_list[0].style, 1);
|
|
1024
|
+
assert.equal(pure.button_list[1].style, 2);
|
|
1025
|
+
assert.equal(isPreviewCardEvent("preview:123", ""), true);
|
|
1026
|
+
assert.equal(isPreviewCardEvent("", "apr_preview_123"), true);
|
|
1027
|
+
assert.equal(isPreviewCardEvent("ev-real", "apr_abc"), false);
|
|
1028
|
+
assert.match(buildPreviewDecidedCard({ decision: "deny", who: "业主", ts }).main_title.title, /预览卡片 · 已收到你的选择(拒绝)/);
|
|
1029
|
+
assert.equal(buildPreviewDecidedCard({ decision: "allow", ts }).main_title.desc, "这是预览卡片,没有对应的真实操作");
|
|
1030
|
+
|
|
1031
|
+
const rt = await setupCardBot();
|
|
1032
|
+
try {
|
|
1033
|
+
await rt.start();
|
|
1034
|
+
const r = rt.bot.sendPreviewCard("user-a", ts);
|
|
1035
|
+
assert.equal(r.ok, true);
|
|
1036
|
+
assert.equal(r.taskId, "apr_preview_9999999123");
|
|
1037
|
+
const frame = await waitFor(() => rt.cards()[0], { label: "预览卡片帧" });
|
|
1038
|
+
assert.equal(frame.body.chatid, "user-a");
|
|
1039
|
+
assert.equal(frame.body.msgtype, "template_card");
|
|
1040
|
+
assert.deepEqual(frame.body.template_card, pure, "线上报文与纯函数构造完全一致");
|
|
1041
|
+
|
|
1042
|
+
// 点「允许」:只更新卡 + 一句说明,绝不调 onApprovalDecision
|
|
1043
|
+
pushCardClick(rt.server, { reqId: "pv-1", taskId: r.taskId, eventKey: "allow:preview:9999999123", userid: "user-preview" });
|
|
1044
|
+
const upd = await waitFor(() => rt.updates().find((f) => f.headers.req_id === "pv-1"), { label: "预览卡更新" });
|
|
1045
|
+
assert.equal(upd.body.response_type, "update_template_card");
|
|
1046
|
+
assert.match(upd.body.template_card.main_title.title, /预览卡片 · 已收到你的选择(允许)/);
|
|
1047
|
+
assert.equal(upd.body.template_card.main_title.desc, "这是预览卡片,没有对应的真实操作");
|
|
1048
|
+
assert.equal(upd.body.template_card.card_type, "text_notice");
|
|
1049
|
+
const txt = await waitFor(() => rt.texts().find((f) => /这是预览卡片,没有对应的真实操作/.test(f.body.markdown.content)), { label: "预览说明文本" });
|
|
1050
|
+
assert.equal(txt.body.chatid, "user-a");
|
|
1051
|
+
assert.equal(rt.decisions.length, 0, "★ 预览卡点击绝不调 onApprovalDecision(没有真实操作)");
|
|
1052
|
+
// 再点「拒绝」→ 允许重复点(预览不是幂等的一次性卡)
|
|
1053
|
+
pushCardClick(rt.server, { reqId: "pv-2", taskId: r.taskId, eventKey: "deny:preview:9999999123", userid: "user-preview" });
|
|
1054
|
+
const upd2 = await waitFor(() => rt.updates().find((f) => f.headers.req_id === "pv-2"), { label: "第二次预览点击" });
|
|
1055
|
+
assert.match(upd2.body.template_card.main_title.title, /(拒绝)/);
|
|
1056
|
+
assert.equal(rt.decisions.length, 0);
|
|
1057
|
+
assert.equal(rt.bot.status().previewClicks, 2);
|
|
1058
|
+
assert.equal(rt.bot.status().previews, 1);
|
|
1059
|
+
|
|
1060
|
+
// 预览卡不污染真实审批:随后真实审批卡点击仍走回调
|
|
1061
|
+
const real = rt.bot.sendApprovalCard("user-a", { requestId: "ev-real" });
|
|
1062
|
+
await waitFor(() => rt.cards().length === 2, { label: "真实审批卡" });
|
|
1063
|
+
pushCardClick(rt.server, { reqId: "real-1", taskId: real.taskId, eventKey: "allow:ev-real" });
|
|
1064
|
+
await waitFor(() => rt.decisions.length === 1, { label: "真实审批回调" });
|
|
1065
|
+
assert.equal(rt.decisions[0].requestId, "ev-real");
|
|
1066
|
+
// 操作人 userid 不进日志(只 hash)
|
|
1067
|
+
assert.ok(!rt.lines.join("\n").includes("user-preview"), "★ 预览点击的操作人 userid 也不得进日志");
|
|
1068
|
+
} finally {
|
|
1069
|
+
await rt.close();
|
|
1070
|
+
}
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
test("★ 控制器:previewCardAt 变大 → 发一次预览卡(不重建连接);相同值不重复;无接收方跳过", async () => {
|
|
1074
|
+
const relayDir = tmpRelayDir();
|
|
1075
|
+
const logs = [];
|
|
1076
|
+
const server = await startFakeServer({ onFrame: (f, ws) => { autoAckAuth(f, ws); autoAckPing(f, ws); autoAckSend(f, ws); } });
|
|
1077
|
+
const created = [];
|
|
1078
|
+
const writeCfg = (extra = {}) => fs.writeFileSync(path.join(relayDir, IM_BOTS_FILE), JSON.stringify({
|
|
1079
|
+
version: 1, wecom: { enabled: true, botId: BOT_ID, secret: SECRET, ...extra }
|
|
1080
|
+
}), { mode: 0o600 });
|
|
1081
|
+
const cards = () => server.state.frames.filter((f) => f.cmd === "aibot_send_msg" && f.body && f.body.msgtype === "template_card");
|
|
1082
|
+
const controller = createWecomBotController({
|
|
1083
|
+
relayDir, configPollMs: 30, env: {},
|
|
1084
|
+
log: (level, line) => logs.push(line),
|
|
1085
|
+
createBot: (opts) => {
|
|
1086
|
+
const b = createWecomBot({ ...opts, url: server.url, logger: (level, line) => logs.push(line) });
|
|
1087
|
+
created.push(b);
|
|
1088
|
+
return b;
|
|
1089
|
+
}
|
|
1090
|
+
});
|
|
1091
|
+
try {
|
|
1092
|
+
writeCfg(); // 无 previewCardAt
|
|
1093
|
+
await controller.start();
|
|
1094
|
+
await waitFor(() => controller.status().bot && controller.status().bot.authenticated, { timeoutMs: 5000, label: "连上" });
|
|
1095
|
+
const bot = controller._bot();
|
|
1096
|
+
// 先绑定接收方(入站一句即可;没有 subscriber 也能绑定 chat)
|
|
1097
|
+
server.push({ cmd: "aibot_msg_callback", headers: { req_id: "bind-pv" }, body: { msgid: "mpv1", msgtype: "text", text: { content: "hi" }, from: { userid: "user-a" } } });
|
|
1098
|
+
await waitFor(() => bot.status().boundChatId === "user-a", { label: "绑定接收方" });
|
|
1099
|
+
assert.equal(cards().length, 0);
|
|
1100
|
+
|
|
1101
|
+
// ① previewCardAt 变大 → 发一张,且不重建连接
|
|
1102
|
+
writeCfg({ previewCardAt: 1111 });
|
|
1103
|
+
await waitFor(() => cards().some((f) => f.body.template_card.task_id === "apr_preview_1111"), { timeoutMs: 5000, label: "预览卡 1111" });
|
|
1104
|
+
assert.equal(created.length, 1, "★ 只改 previewCardAt 不得重建连接(连接配置没变)");
|
|
1105
|
+
assert.equal(cards().length, 1);
|
|
1106
|
+
|
|
1107
|
+
// ② 相同值(哪怕 mtime 变了)不重复发
|
|
1108
|
+
writeCfg({ previewCardAt: 1111 });
|
|
1109
|
+
await sleep(250);
|
|
1110
|
+
assert.equal(cards().length, 1, "★ 相同 previewCardAt 不得重复发");
|
|
1111
|
+
|
|
1112
|
+
// ③ 再变大 → 再发一张
|
|
1113
|
+
writeCfg({ previewCardAt: 2222 });
|
|
1114
|
+
await waitFor(() => cards().some((f) => f.body.template_card.task_id === "apr_preview_2222"), { timeoutMs: 5000, label: "预览卡 2222" });
|
|
1115
|
+
assert.equal(cards().length, 2);
|
|
1116
|
+
|
|
1117
|
+
// ④ 状态文件回写「已处理的 previewCardAt」(跨重启防重复)
|
|
1118
|
+
await waitFor(() => { const st = readStatus(relayDir); return st && st.wecom && st.wecom.lastPreviewCardAt === 2222; }, { label: "预览标记" });
|
|
1119
|
+
} finally {
|
|
1120
|
+
await controller.stop();
|
|
1121
|
+
await server.close();
|
|
1122
|
+
fs.rmSync(relayDir, { recursive: true, force: true });
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// ⑤ 没有接收方 → 跳过并记日志(不报错、不发卡)
|
|
1126
|
+
{
|
|
1127
|
+
const relayDir2 = tmpRelayDir();
|
|
1128
|
+
const logs2 = [];
|
|
1129
|
+
const server2 = await startFakeServer({ onFrame: (f, ws) => { autoAckAuth(f, ws); autoAckPing(f, ws); autoAckSend(f, ws); } });
|
|
1130
|
+
try {
|
|
1131
|
+
fs.writeFileSync(path.join(relayDir2, IM_BOTS_FILE), JSON.stringify({
|
|
1132
|
+
version: 1, wecom: { enabled: true, botId: BOT_ID, secret: SECRET, previewCardAt: 3333 }
|
|
1133
|
+
}), { mode: 0o600 });
|
|
1134
|
+
const controller2 = createWecomBotController({
|
|
1135
|
+
relayDir: relayDir2, configPollMs: 30, env: {},
|
|
1136
|
+
log: (level, line) => logs2.push(line),
|
|
1137
|
+
createBot: (opts) => createWecomBot({ ...opts, url: server2.url, logger: (level, line) => logs2.push(line) })
|
|
1138
|
+
});
|
|
1139
|
+
try {
|
|
1140
|
+
await controller2.start();
|
|
1141
|
+
await waitFor(() => controller2.status().bot && controller2.status().bot.authenticated, { timeoutMs: 5000, label: "连上2" });
|
|
1142
|
+
await sleep(200);
|
|
1143
|
+
assert.equal(server2.state.frames.filter((f) => f.cmd === "aibot_send_msg").length, 0, "没有接收方不得发卡");
|
|
1144
|
+
assert.ok(logs2.join("\n").includes("预览卡片跳过"), "要记一条可排查的跳过日志(不报错)");
|
|
1145
|
+
} finally {
|
|
1146
|
+
await controller2.stop();
|
|
1147
|
+
}
|
|
1148
|
+
} finally {
|
|
1149
|
+
await server2.close();
|
|
1150
|
+
fs.rmSync(relayDir2, { recursive: true, force: true });
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
// ─────────────────────────── ⑩d 会话可选绑定(task-24)───────────────────────────
|
|
1156
|
+
|
|
1157
|
+
test("★ 会话绑定优先级:配置固定 → 最近活跃 → sessions[0];状态文件 sessions/activeSessionId/boundSessionId", async () => {
|
|
1158
|
+
const relayDir1 = tmpRelayDir();
|
|
1159
|
+
// ① 配置固定优先
|
|
1160
|
+
{
|
|
1161
|
+
const sub = fakeSubscriber({
|
|
1162
|
+
sessionIds: ["s-live"],
|
|
1163
|
+
sessions: [{ sessionId: "s-live", title: "活跃会话", running: true, updatedAt: 200 }]
|
|
1164
|
+
});
|
|
1165
|
+
const server = await startFakeServer({ onFrame: (f, ws) => { autoAckAuth(f, ws); autoAckPing(f, ws); } });
|
|
1166
|
+
const bot = createWecomBot({
|
|
1167
|
+
botId: BOT_ID, secret: SECRET, url: server.url, relayDir: relayDir1, logger: () => {},
|
|
1168
|
+
subscriber: sub, configuredSessionId: "s-fixed", configuredSessionTitle: "固定会话"
|
|
1169
|
+
});
|
|
1170
|
+
try {
|
|
1171
|
+
bot.start();
|
|
1172
|
+
await waitFor(() => bot.status().authenticated, { label: "认证1" });
|
|
1173
|
+
sub.emit("session-state", { sessionId: "s-live", state: "running", at: Date.now() });
|
|
1174
|
+
server.push({ cmd: "aibot_msg_callback", headers: { req_id: "bind-a" }, body: { msgid: "mb1", msgtype: "text", text: { content: "hi" }, from: { userid: "user-a" } } });
|
|
1175
|
+
await waitFor(() => sub.promptCalls.length === 1, { label: "入站转发1" });
|
|
1176
|
+
assert.equal(sub.promptCalls[0].sessionId, "s-fixed", "★ 配置固定的会话优先级最高");
|
|
1177
|
+
await waitFor(() => {
|
|
1178
|
+
const st = readStatus(relayDir1);
|
|
1179
|
+
return st && st.wecom && st.wecom.boundSessionId === "s-fixed" && Array.isArray(st.wecom.sessions) && st.wecom.sessions.length >= 1;
|
|
1180
|
+
}, { label: "状态文件 boundSessionId + sessions" });
|
|
1181
|
+
const st = readStatus(relayDir1);
|
|
1182
|
+
assert.equal(st.wecom.activeSessionId, "s-live");
|
|
1183
|
+
assert.ok(Array.isArray(st.wecom.sessions) && st.wecom.sessions.length >= 1);
|
|
1184
|
+
assert.deepEqual(Object.keys(st.wecom.sessions[0]).sort(), ["id", "title", "updatedAt"], "sessions 元素只含 id/title/updatedAt");
|
|
1185
|
+
assert.equal(st.wecom.sessions[0].id, "s-fixed", "配置固定的会话补进列表(用户明确选过)");
|
|
1186
|
+
assert.equal(st.wecom.sessions[0].title, "固定会话");
|
|
1187
|
+
} finally {
|
|
1188
|
+
await bot.stop();
|
|
1189
|
+
await server.close();
|
|
1190
|
+
fs.rmSync(relayDir1, { recursive: true, force: true });
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// ② 无配置:最近活跃会话优先
|
|
1195
|
+
{
|
|
1196
|
+
const relayDir = tmpRelayDir();
|
|
1197
|
+
const sub = fakeSubscriber({ sessionIds: ["s-a", "s-b"], sessions: [
|
|
1198
|
+
{ sessionId: "s-a", title: "A", updatedAt: 100 },
|
|
1199
|
+
{ sessionId: "s-b", title: "B", updatedAt: 200 }
|
|
1200
|
+
] });
|
|
1201
|
+
const server = await startFakeServer({ onFrame: (f, ws) => { autoAckAuth(f, ws); autoAckPing(f, ws); } });
|
|
1202
|
+
const bot = createWecomBot({ botId: BOT_ID, secret: SECRET, url: server.url, relayDir, logger: () => {}, subscriber: sub });
|
|
1203
|
+
try {
|
|
1204
|
+
bot.start();
|
|
1205
|
+
await waitFor(() => bot.status().authenticated, { label: "认证2" });
|
|
1206
|
+
sub.emit("session-state", { sessionId: "s-b", state: "running", at: Date.now() });
|
|
1207
|
+
await waitFor(() => bot.status().activeSessionId === "s-b", { label: "活跃会话" });
|
|
1208
|
+
server.push({ cmd: "aibot_msg_callback", headers: { req_id: "bind-b" }, body: { msgid: "mb2", msgtype: "text", text: { content: "hi" }, from: { userid: "user-a" } } });
|
|
1209
|
+
await waitFor(() => sub.promptCalls.length === 1, { label: "入站转发2" });
|
|
1210
|
+
assert.equal(sub.promptCalls[0].sessionId, "s-b", "★ 无配置时用最近活跃会话");
|
|
1211
|
+
await waitFor(() => {
|
|
1212
|
+
const st = readStatus(relayDir);
|
|
1213
|
+
return st && st.wecom && st.wecom.boundSessionId === "s-b" && Array.isArray(st.wecom.sessions) && st.wecom.sessions.length >= 2;
|
|
1214
|
+
}, { label: "状态文件2(bound + sessions)" });
|
|
1215
|
+
const st = readStatus(relayDir);
|
|
1216
|
+
assert.ok(st.wecom.sessions.length <= 10);
|
|
1217
|
+
assert.deepEqual(st.wecom.sessions.map((x) => x.id).sort(), ["s-a", "s-b"].sort());
|
|
1218
|
+
} finally {
|
|
1219
|
+
await bot.stop();
|
|
1220
|
+
await server.close();
|
|
1221
|
+
fs.rmSync(relayDir, { recursive: true, force: true });
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// ③ 无配置、无活跃:sessions[0](按 updatedAt 最新)
|
|
1226
|
+
{
|
|
1227
|
+
const sub = fakeSubscriber({ sessionIds: ["s-1", "s-2"], sessions: [
|
|
1228
|
+
{ sessionId: "s-1", title: "旧", updatedAt: 100 },
|
|
1229
|
+
{ sessionId: "s-2", title: "新", updatedAt: 200 }
|
|
1230
|
+
] });
|
|
1231
|
+
const server = await startFakeServer({ onFrame: (f, ws) => { autoAckAuth(f, ws); autoAckPing(f, ws); } });
|
|
1232
|
+
const bot = createWecomBot({ botId: BOT_ID, secret: SECRET, url: server.url, logger: () => {}, subscriber: sub });
|
|
1233
|
+
try {
|
|
1234
|
+
bot.start();
|
|
1235
|
+
await waitFor(() => bot.status().authenticated, { label: "认证3" });
|
|
1236
|
+
server.push({ cmd: "aibot_msg_callback", headers: { req_id: "bind-c" }, body: { msgid: "mb3", msgtype: "text", text: { content: "hi" }, from: { userid: "user-a" } } });
|
|
1237
|
+
await waitFor(() => sub.promptCalls.length === 1, { label: "入站转发3" });
|
|
1238
|
+
assert.equal(sub.promptCalls[0].sessionId, "s-2", "★ sessions[0] = updatedAt 最新的那个");
|
|
1239
|
+
// 列表上限 10
|
|
1240
|
+
assert.ok(normalizeSessionStatusList(Array.from({ length: 15 }, (_, i) => ({ id: `x${i}` }))).length === 10);
|
|
1241
|
+
} finally {
|
|
1242
|
+
await bot.stop();
|
|
1243
|
+
await server.close();
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
test("控制器:只改 sessionId 也会重建 bot(配置热更新后转发目标立即生效)", async () => {
|
|
1249
|
+
const relayDir = tmpRelayDir();
|
|
1250
|
+
const server = await startFakeServer({ onFrame: (f, ws) => { autoAckAuth(f, ws); autoAckPing(f, ws); } });
|
|
1251
|
+
const imports = [];
|
|
1252
|
+
const controller = createWecomBotController({
|
|
1253
|
+
relayDir, configPollMs: 30, log: () => {}, env: {},
|
|
1254
|
+
createBot: (opts) => {
|
|
1255
|
+
imports.push({ sessionId: opts.configuredSessionId, sessionTitle: opts.configuredSessionTitle, hasDecisionCb: typeof opts.onApprovalDecision === "function" });
|
|
1256
|
+
return createWecomBot({ ...opts, url: server.url });
|
|
1257
|
+
}
|
|
1258
|
+
});
|
|
1259
|
+
try {
|
|
1260
|
+
await controller.start();
|
|
1261
|
+
fs.writeFileSync(path.join(relayDir, IM_BOTS_FILE), JSON.stringify({
|
|
1262
|
+
version: 1, wecom: { enabled: true, botId: BOT_ID, secret: SECRET, sessionId: "s-one", sessionTitle: "一号会话" }
|
|
1263
|
+
}), { mode: 0o600 });
|
|
1264
|
+
await waitFor(() => imports.length >= 1 && controller.status().bot && controller.status().bot.authenticated, { timeoutMs: 5000, label: "起bot" });
|
|
1265
|
+
assert.deepEqual({ id: imports[0].sessionId, title: imports[0].sessionTitle }, { id: "s-one", title: "一号会话" });
|
|
1266
|
+
assert.equal(imports[0].hasDecisionCb, false, "没注入回调时如实没有(卡片点击会回「未就绪」)");
|
|
1267
|
+
// 只改固定会话 → 摘要变化 → 重建
|
|
1268
|
+
fs.writeFileSync(path.join(relayDir, IM_BOTS_FILE), JSON.stringify({
|
|
1269
|
+
version: 1, wecom: { enabled: true, botId: BOT_ID, secret: SECRET, sessionId: "s-two", sessionTitle: "二号会话" }
|
|
1270
|
+
}), { mode: 0o600 });
|
|
1271
|
+
await waitFor(() => imports.length >= 2, { timeoutMs: 5000, label: "改 sessionId 后重建" });
|
|
1272
|
+
assert.deepEqual({ id: imports[1].sessionId, title: imports[1].sessionTitle }, { id: "s-two", title: "二号会话" });
|
|
1273
|
+
} finally {
|
|
1274
|
+
await controller.stop();
|
|
1275
|
+
await server.close();
|
|
1276
|
+
fs.rmSync(relayDir, { recursive: true, force: true });
|
|
1277
|
+
}
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
// ─────────────────────────── ⑪ 配置契约 ───────────────────────────
|
|
1281
|
+
|
|
1282
|
+
test("配置契约:normalizeImBotsConfig 缺字段补默认、非法值当未配置;readImBotsConfig 文件不存在返回 null", () => {
|
|
1283
|
+
const relayDir = tmpRelayDir();
|
|
1284
|
+
try {
|
|
1285
|
+
assert.equal(readImBotsConfig(relayDir), null, "没有配置文件 → null(默认关闭)");
|
|
1286
|
+
const cfg = normalizeImBotsConfig({
|
|
1287
|
+
version: 1,
|
|
1288
|
+
wecom: { enabled: true, botId: " b1 ", secret: "s1", targetChatId: " u1 ", allowFrom: ["a", "a", 3, ""] },
|
|
1289
|
+
extra: { keep: true }
|
|
1290
|
+
});
|
|
1291
|
+
assert.equal(cfg.wecom.botId, "b1");
|
|
1292
|
+
assert.equal(cfg.wecom.secret, "s1");
|
|
1293
|
+
assert.equal(cfg.wecom.url, "");
|
|
1294
|
+
assert.equal(cfg.wecom.targetChatId, "u1");
|
|
1295
|
+
assert.deepEqual(cfg.wecom.allowFrom, ["a"], "允许列表去空/去重/非字符串忽略");
|
|
1296
|
+
assert.equal(cfg.feishu.enabled, false);
|
|
1297
|
+
const broken = normalizeImBotsConfig({ wecom: "not-an-object", feishu: null });
|
|
1298
|
+
assert.equal(broken.wecom.enabled, false);
|
|
1299
|
+
assert.equal(broken.wecom.botId, "");
|
|
1300
|
+
assert.deepEqual(normalizeImBotsConfig(null).wecom.allowFrom, []);
|
|
1301
|
+
|
|
1302
|
+
fs.writeFileSync(path.join(relayDir, IM_BOTS_FILE), JSON.stringify({ wecom: { enabled: true, botId: "b", secret: "s", sessionId: "sid-1", sessionTitle: "我的会话" } }));
|
|
1303
|
+
const read = readImBotsConfig(relayDir);
|
|
1304
|
+
assert.equal(read.wecom.botId, "b");
|
|
1305
|
+
assert.equal(read.wecom.enabled, true);
|
|
1306
|
+
assert.equal(read.wecom.sessionId, "sid-1", "task-24:配置支持固定会话");
|
|
1307
|
+
assert.equal(read.wecom.sessionTitle, "我的会话");
|
|
1308
|
+
assert.equal(normalizeImBotsConfig({ wecom: {} }).wecom.sessionId, "", "缺省 = 跟随活跃会话");
|
|
1309
|
+
// 预览卡片触发时间戳(task-25/小改动):number 保留;非法/缺失 → 0
|
|
1310
|
+
assert.equal(normalizeImBotsConfig({ wecom: {} }).wecom.previewCardAt, 0);
|
|
1311
|
+
assert.equal(normalizeImBotsConfig({ wecom: { previewCardAt: 1700000000123 } }).wecom.previewCardAt, 1700000000123);
|
|
1312
|
+
assert.equal(normalizeImBotsConfig({ wecom: { previewCardAt: "1700000000123" } }).wecom.previewCardAt, 1700000000123, "数字字符串也认");
|
|
1313
|
+
assert.equal(normalizeImBotsConfig({ wecom: { previewCardAt: -5 } }).wecom.previewCardAt, 0, "非法值当未配置");
|
|
1314
|
+
assert.equal(normalizeImBotsConfig({ wecom: { previewCardAt: "abc" } }).wecom.previewCardAt, 0);
|
|
1315
|
+
} finally {
|
|
1316
|
+
fs.rmSync(relayDir, { recursive: true, force: true });
|
|
1317
|
+
}
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
test("日志器脱敏:maskSecrets 抹掉字面量与类似字段;createScopedLogger 有界留痕", () => {
|
|
1321
|
+
const line = maskSecrets("secret=abcd1234 bot_id=b1 Authorization: Bearer tok", ["abcd1234"]);
|
|
1322
|
+
assert.ok(!line.includes("abcd1234"));
|
|
1323
|
+
assert.match(line, /secret=\*\*\*/);
|
|
1324
|
+
const logger = createScopedLogger({ maxLines: 2 });
|
|
1325
|
+
logger.info("a");
|
|
1326
|
+
logger.warn("b");
|
|
1327
|
+
logger.error("c");
|
|
1328
|
+
assert.equal(logger.lines.length, 2, "内存日志必须有界");
|
|
1329
|
+
assert.match(logger.lines[1], /warn/);
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
// ─────────────────────────── ⑫ 控制器:配置热更新 ───────────────────────────
|
|
1333
|
+
|
|
1334
|
+
test("★ 控制器:默认关闭(无配置)→ 启用后连上 → 禁用即停 → 改凭据重连(fs.watchFile 热更新)", async () => {
|
|
1335
|
+
const relayDir = tmpRelayDir();
|
|
1336
|
+
const server = await startFakeServer({ onFrame: (f, ws) => { autoAckAuth(f, ws); autoAckPing(f, ws); } });
|
|
1337
|
+
const imports = []; // 记录每次创建 bot 的配置(验证凭据变化会重建)
|
|
1338
|
+
const controller = createWecomBotController({
|
|
1339
|
+
relayDir,
|
|
1340
|
+
configPollMs: 30,
|
|
1341
|
+
log: () => {},
|
|
1342
|
+
env: {}, // 不受本机环境变量影响
|
|
1343
|
+
createBot: (opts) => {
|
|
1344
|
+
imports.push({ botId: opts.botId, targetChatId: opts.targetChatId, allowFrom: opts.allowFrom });
|
|
1345
|
+
// 打桩只改连接地址:其余逻辑走真实 bot
|
|
1346
|
+
return createWecomBot({ ...opts, url: server.url });
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
try {
|
|
1350
|
+
await controller.start();
|
|
1351
|
+
assert.equal(controller.status().reason, "no_config");
|
|
1352
|
+
assert.equal(controller.status().enabled, false, "没有配置时必须默认关闭(零副作用)");
|
|
1353
|
+
|
|
1354
|
+
// ① 写入启用配置 → 自动起连接
|
|
1355
|
+
fs.writeFileSync(path.join(relayDir, IM_BOTS_FILE), JSON.stringify({
|
|
1356
|
+
version: 1,
|
|
1357
|
+
wecom: { enabled: true, botId: BOT_ID, secret: SECRET, targetChatId: "user-t", allowFrom: ["u1"] }
|
|
1358
|
+
}), { mode: 0o600 });
|
|
1359
|
+
await waitFor(() => controller.status().enabled && controller.status().bot && controller.status().bot.authenticated,
|
|
1360
|
+
{ timeoutMs: 5000, label: "配置生效并认证" });
|
|
1361
|
+
assert.equal(imports.length, 1);
|
|
1362
|
+
assert.deepEqual(imports[0], { botId: BOT_ID, targetChatId: "user-t", allowFrom: ["u1"] });
|
|
1363
|
+
const file = readStatus(relayDir);
|
|
1364
|
+
assert.equal(file.wecom.enabled, true);
|
|
1365
|
+
assert.equal(file.wecom.targetChatId, "user-t", "状态文件要回显 targetChatId(非敏感)");
|
|
1366
|
+
|
|
1367
|
+
// ② 改成 enabled:false → 连接应停止且状态变 disabled
|
|
1368
|
+
fs.writeFileSync(path.join(relayDir, IM_BOTS_FILE), JSON.stringify({
|
|
1369
|
+
version: 1, wecom: { enabled: false, botId: BOT_ID, secret: SECRET }
|
|
1370
|
+
}), { mode: 0o600 });
|
|
1371
|
+
await waitFor(() => !controller.status().enabled, { timeoutMs: 5000, label: "禁用生效" });
|
|
1372
|
+
await waitFor(() => readStatus(relayDir) && readStatus(relayDir).wecom.enabled === false, { label: "状态文件 disabled" });
|
|
1373
|
+
await waitFor(() => server.state.closed >= 1, { label: "连接已关闭" });
|
|
1374
|
+
|
|
1375
|
+
// ③ 换一份凭据重新启用 → 应重建连接(新 bot 实例)
|
|
1376
|
+
fs.writeFileSync(path.join(relayDir, IM_BOTS_FILE), JSON.stringify({
|
|
1377
|
+
version: 1, wecom: { enabled: true, botId: "bot-test-0002", secret: "another-secret-xyz" }
|
|
1378
|
+
}), { mode: 0o600 });
|
|
1379
|
+
await waitFor(() => controller.status().enabled && imports.length >= 2, { timeoutMs: 5000, label: "改凭据后重建" });
|
|
1380
|
+
await waitFor(() => controller.status().bot && controller.status().bot.authenticated, { timeoutMs: 5000, label: "重新认证" });
|
|
1381
|
+
assert.equal(imports[imports.length - 1].botId, "bot-test-0002");
|
|
1382
|
+
// 配置没变时不得反复重建(watchFile 会因 mtime 反复触发)
|
|
1383
|
+
const count = imports.length;
|
|
1384
|
+
await sleep(200);
|
|
1385
|
+
assert.equal(imports.length, count, "配置未变化时不得重复重建连接");
|
|
1386
|
+
} finally {
|
|
1387
|
+
await controller.stop();
|
|
1388
|
+
await server.close();
|
|
1389
|
+
fs.rmSync(relayDir, { recursive: true, force: true });
|
|
1390
|
+
}
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
test("控制器:缺 botId/secret 时不启动并写明确的 disabled 原因;DSH_IM_BOTS=0 硬关闭", async () => {
|
|
1394
|
+
const relayDir = tmpRelayDir();
|
|
1395
|
+
try {
|
|
1396
|
+
fs.writeFileSync(path.join(relayDir, IM_BOTS_FILE), JSON.stringify({
|
|
1397
|
+
version: 1, wecom: { enabled: true, botId: "", secret: "" }
|
|
1398
|
+
}), { mode: 0o600 });
|
|
1399
|
+
const controller = createWecomBotController({ relayDir, log: () => {}, env: {}, configPollMs: 30 });
|
|
1400
|
+
try {
|
|
1401
|
+
await controller.start();
|
|
1402
|
+
assert.equal(controller.status().enabled, false);
|
|
1403
|
+
assert.equal(controller.status().reason, "missing_credentials");
|
|
1404
|
+
const st = readStatus(relayDir);
|
|
1405
|
+
assert.ok(st && st.wecom.enabled === false);
|
|
1406
|
+
assert.match(st.wecom.lastError, /Bot ID \/ Secret/);
|
|
1407
|
+
} finally {
|
|
1408
|
+
await controller.stop();
|
|
1409
|
+
}
|
|
1410
|
+
const off = createWecomBotController({ relayDir, log: () => {}, env: { DSH_IM_BOTS: "0" } });
|
|
1411
|
+
try {
|
|
1412
|
+
await off.start();
|
|
1413
|
+
assert.equal(off.status().enabled, false);
|
|
1414
|
+
assert.equal(off.status().reason, "disabled_by_env");
|
|
1415
|
+
} finally {
|
|
1416
|
+
await off.stop();
|
|
1417
|
+
}
|
|
1418
|
+
} finally {
|
|
1419
|
+
fs.rmSync(relayDir, { recursive: true, force: true });
|
|
1420
|
+
}
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
// ─────────────────────────── ⑬ 原子写独立性 ───────────────────────────
|
|
1424
|
+
|
|
1425
|
+
test("atomicWriteJson:覆盖写、目录自动创建、0600、失败不留临时文件", () => {
|
|
1426
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-wecom-atomic-"));
|
|
1427
|
+
try {
|
|
1428
|
+
const nested = path.join(dir, "a", "b", "x.json");
|
|
1429
|
+
atomicWriteJson(nested, { hello: 1 });
|
|
1430
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(nested, "utf8")), { hello: 1 });
|
|
1431
|
+
if (process.platform !== "win32") assert.equal(fileMode(nested), 0o600);
|
|
1432
|
+
atomicWriteJson(nested, { hello: 2 });
|
|
1433
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(nested, "utf8")), { hello: 2 });
|
|
1434
|
+
assert.deepEqual(fs.readdirSync(path.dirname(nested)).filter((n) => n.includes(".tmp")), []);
|
|
1435
|
+
} finally {
|
|
1436
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1437
|
+
}
|
|
1438
|
+
});
|