@pi-flash-plugins/pf-wxbot 0.0.0-stage → 0.1.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/assets/wx.svg +5 -0
- package/package.json +9 -4
- package/pf-plugin.toml +18 -0
- package/pi/config.mjs +26 -0
- package/pi/design-002.test.mjs +323 -0
- package/pi/fixtures/ec-golden.json +1 -0
- package/pi/fixtures/getupdates-real.json +37 -0
- package/pi/fixtures/qr-golden.json +1 -0
- package/pi/hook.mjs +231 -0
- package/pi/hook.test.mjs +351 -0
- package/pi/index.mjs +648 -0
- package/pi/index.test.mjs +685 -0
- package/pi/inject.mjs +41 -0
- package/pi/inject.test.mjs +55 -0
- package/pi/jsonx.mjs +64 -0
- package/pi/lock.mjs +171 -0
- package/pi/messages.mjs +160 -0
- package/pi/poller.mjs +364 -0
- package/pi/poller.test.mjs +191 -0
- package/pi/qr.mjs +454 -0
- package/pi/qr.test.mjs +101 -0
- package/pi/register.mjs +236 -0
- package/pi/register.test.mjs +232 -0
- package/pi/reply.mjs +49 -0
- package/pi/state.mjs +248 -0
- package/pi/state.test.mjs +138 -0
- package/pi/transport.mjs +335 -0
- package/pi/transport.test.mjs +327 -0
- package/pi/wire.mjs +219 -0
- package/pi/wire.test.mjs +29 -0
- package/plugin.wasm +0 -0
- package/README.md +0 -3
package/pi/register.mjs
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// register —— 扫码注册流程,自 PF 仓 wxprobe src/register.rs **逐条移植**(JS 版)。
|
|
2
|
+
//
|
|
3
|
+
// iLink 协议(对齐 ZCode weixinRegistration.ts):
|
|
4
|
+
// - `GET /ilink/bot/get_bot_qrcode?bot_type=3`(30s 超时,wire.get_qr)
|
|
5
|
+
// - `GET /ilink/bot/get_qrcode_status?qrcode=<enc>`,3s 间隔轮询
|
|
6
|
+
// - 状态归一:数字 0/1/2/3|4 与字符串词表两套
|
|
7
|
+
//
|
|
8
|
+
// 生命周期:ext 的 `session_start` 里起 runQrFlow(异步循环),`session_shutdown`
|
|
9
|
+
// 里取消(返回值 false 语义)。二维码 URL 交 J2 壳渲染(qr.mjs → canvas)。
|
|
10
|
+
|
|
11
|
+
/** 出码(P0 步骤 1)。 */
|
|
12
|
+
export async function begin(wire) {
|
|
13
|
+
const payload = await wire.get_qr("qr-begin", "/get_bot_qrcode", [["bot_type", "3"]]);
|
|
14
|
+
const firstStr = (keys) => {
|
|
15
|
+
for (const k of keys) {
|
|
16
|
+
const v = payload[k];
|
|
17
|
+
if (typeof v === "string" && v !== "") return v;
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
};
|
|
21
|
+
const qrcode = firstStr(["qrcode", "qr_code"]);
|
|
22
|
+
if (qrcode === undefined) {
|
|
23
|
+
throw new Error(`扫码接口未返回 qrcode,真实字段:${JSON.stringify(payload)}`);
|
|
24
|
+
}
|
|
25
|
+
const qr_url = firstStr(["qrcode_img_content", "qrcode_url"]) ?? qrcode;
|
|
26
|
+
const interval = typeof payload.expires_in === "number" ? payload.expires_in : undefined;
|
|
27
|
+
const expires_at_ms = Date.now() + (interval ?? 120) * 1000;
|
|
28
|
+
return { qrcode, qr_url, interval_secs: 3, expires_at_ms };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 扫码状态(P0 步骤 2)。 */
|
|
32
|
+
export async function poll(wire, qrcode) {
|
|
33
|
+
const payload = await wire.get_qr("qr-status", "/get_qrcode_status", [["qrcode", qrcode]]);
|
|
34
|
+
const raw = payload["status"] ?? payload["qrcode_status"] ?? payload["qr_status"];
|
|
35
|
+
const status = normalize(raw);
|
|
36
|
+
switch (status) {
|
|
37
|
+
case "success": {
|
|
38
|
+
let bot_token;
|
|
39
|
+
for (const k of ["bot_token", "token"]) {
|
|
40
|
+
const v = payload[k];
|
|
41
|
+
if (typeof v === "string" && v !== "") { bot_token = v; break; }
|
|
42
|
+
}
|
|
43
|
+
if (bot_token === undefined) {
|
|
44
|
+
throw new Error(`扫码成功但未返回 bot_token,真实字段:${JSON.stringify(payload)}`);
|
|
45
|
+
}
|
|
46
|
+
let bot_id;
|
|
47
|
+
for (const k of ["ilink_bot_id", "bot_id"]) {
|
|
48
|
+
const v = payload[k];
|
|
49
|
+
if (typeof v === "string" && v !== "") { bot_id = v; break; }
|
|
50
|
+
}
|
|
51
|
+
return { kind: "success", bot_token, bot_id };
|
|
52
|
+
}
|
|
53
|
+
case "expired":
|
|
54
|
+
return { kind: "expired" };
|
|
55
|
+
case "error":
|
|
56
|
+
return { kind: "error", message: typeof payload["errmsg"] === "string" ? payload["errmsg"] : "二维码状态接口报错" };
|
|
57
|
+
default: {
|
|
58
|
+
if (status === "scanned") return { kind: "scanned" };
|
|
59
|
+
// normalize 已认的等待词(实测 "wait")不算未知状态:以前会拼成
|
|
60
|
+
// 「未识别的扫码状态」→ 壳白遮二维码
|
|
61
|
+
if (KNOWN_PENDING.has(String(raw).toLowerCase())) return { kind: "pending" };
|
|
62
|
+
const msg = unknownStatusMessage(raw);
|
|
63
|
+
if (msg) return { kind: "error", message: msg };
|
|
64
|
+
return { kind: "pending" };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 词表外的**字符串**状态 → 给 UI 的错误文案(含安装指引)。
|
|
70
|
+
* ZCode 静默按 pending 吞掉未知状态,会把「微信侧未装插件」吞成无限等待
|
|
71
|
+
* (2026-10-24 用户要求识别并告知);数字码仍归 pending。 */
|
|
72
|
+
const KNOWN_PENDING = new Set(["wait", "waiting", "pending", "created"]);
|
|
73
|
+
|
|
74
|
+
export function unknownStatusMessage(raw) {
|
|
75
|
+
if (typeof raw !== "string") return undefined;
|
|
76
|
+
if (raw.toLowerCase().includes("install")) {
|
|
77
|
+
return `微信侧未安装「微信ClawBot」插件:请先在微信安装/启用插件,再重新扫码。(原始状态:${raw})`;
|
|
78
|
+
}
|
|
79
|
+
return `未识别的扫码状态:${raw} —— 若微信提示未安装,请先安装「微信ClawBot」插件再扫。`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** ZCode `normalizeQrStatus`(weixinRegistration.ts:103-130)逐条对应。 */
|
|
83
|
+
export function normalize(status) {
|
|
84
|
+
if (typeof status === "number") {
|
|
85
|
+
if (status === 0) return "pending";
|
|
86
|
+
if (status === 1) return "scanned";
|
|
87
|
+
if (status === 2) return "success";
|
|
88
|
+
if (status === 3 || status === 4) return "expired";
|
|
89
|
+
return "pending";
|
|
90
|
+
}
|
|
91
|
+
if (typeof status === "string") {
|
|
92
|
+
const s = status.toLowerCase();
|
|
93
|
+
if (["confirmed", "confirm", "authorized", "success", "ok"].includes(s)) return "success";
|
|
94
|
+
if (["scaned", "scanned", "scan", "confirmed_wait"].includes(s)) return "scanned";
|
|
95
|
+
if (["expired", "timeout", "cancel", "cancelled", "canceled"].includes(s)) return "expired";
|
|
96
|
+
if (["error", "failed", "fail"].includes(s)) return "error";
|
|
97
|
+
if (["wait", "waiting", "pending", "created"].includes(s)) return "pending";
|
|
98
|
+
return "pending";
|
|
99
|
+
}
|
|
100
|
+
return "pending";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------- 状态落地
|
|
104
|
+
|
|
105
|
+
/** 扫码成功后清掉属于**上一个 bot 会话**的路由字段。
|
|
106
|
+
* 游标/对端/上下文都绑在旧 token 上:换绑后带着它们用新 token 去 getupdates,
|
|
107
|
+
* iLink 以 errcode:-14 拒之(2026-10-23 真机事故)。`session_key` 是 PF 侧
|
|
108
|
+
* 会话归属,与 bot 无关,保留。 */
|
|
109
|
+
export function reset_session_fields(st) {
|
|
110
|
+
for (const k of ["buf", "chat_id", "user_id", "context_token"]) delete st[k];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 重绑落地:**清旧路由字段 + 写新 bot 三件**(2026-10-24 用户定夺)。
|
|
114
|
+
* 必须喂**当场 loadState()** 的内容——严禁攥着旧快照整文件回写(自动续码
|
|
115
|
+
* 时代每个到期 worker 都把 begin 时的快照原样 save,把换绑刚写好的
|
|
116
|
+
* bot_token 盖回旧值——「重绑就废」的根因)。 */
|
|
117
|
+
export function apply_success(st, bot_token, bot_id) {
|
|
118
|
+
st["bot_token"] = bot_token;
|
|
119
|
+
if (bot_id) st["bot_id"] = bot_id;
|
|
120
|
+
else delete st["bot_id"];
|
|
121
|
+
// sendmessage 的 from_user_id 就是 bot 自己,必须随换绑同步
|
|
122
|
+
if (bot_id) st["bot_user_id"] = bot_id;
|
|
123
|
+
else delete st["bot_user_id"];
|
|
124
|
+
reset_session_fields(st);
|
|
125
|
+
delete st["qrcode"];
|
|
126
|
+
// 旧码也要清:留着的话任何丢 token 的瞬间弹窗会画出**上一轮**的死码
|
|
127
|
+
delete st["qrcode_grid"];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 只动 `qrcode` 一个字段的最小写入口:当场新读-改-写,不碰其它字段。 */
|
|
131
|
+
export function set_qrcode_fresh(deps, v) {
|
|
132
|
+
const st = deps.loadState();
|
|
133
|
+
if (v === null || v === undefined) delete st["qrcode"];
|
|
134
|
+
else st["qrcode"] = v;
|
|
135
|
+
deps.saveState(st);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------- 扫码全流程
|
|
139
|
+
|
|
140
|
+
/** 扫码事件。 */
|
|
141
|
+
// Ready{url} / Scanned / Success{bot_id} / Expired / Error{message}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 扫码全流程(异步循环):begin → 每 3s poll → 状态落盘 → 回调事件。
|
|
145
|
+
*
|
|
146
|
+
* deps:{ wire, loadState, saveState, sleep?, deadlineSec? }
|
|
147
|
+
* onEvent(event) 返回 **false** = 接收端被丢(UI 取消扫码)→ 清 qrcode 退出
|
|
148
|
+
* (不检查的话 worker 会空转到死线,2026-10-24 取消语义)。
|
|
149
|
+
*
|
|
150
|
+
* 返回取消函数(幂等,等价于 onEvent 返回 false)。
|
|
151
|
+
*/
|
|
152
|
+
export function runQrFlow(deps, onEvent) {
|
|
153
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
154
|
+
// 服务端实测:出码后 120s 返回 status=expired(dump 325/329 时间差 120s)。
|
|
155
|
+
// 本地死线取同一值,只做兜底;主判据永远是服务端的 expired。
|
|
156
|
+
const deadlineSec = deps.deadlineSec ?? 120;
|
|
157
|
+
let cancelled = false;
|
|
158
|
+
const cancel = () => { cancelled = true; };
|
|
159
|
+
|
|
160
|
+
const done = (async () => {
|
|
161
|
+
let qr;
|
|
162
|
+
try {
|
|
163
|
+
qr = await begin(deps.wire);
|
|
164
|
+
} catch (e) {
|
|
165
|
+
onEvent({ kind: "error", message: String(e?.message ?? e) });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (cancelled || onEvent({ kind: "ready", url: qr.qr_url }) === false) return;
|
|
169
|
+
set_qrcode_fresh(deps, qr.qrcode);
|
|
170
|
+
|
|
171
|
+
// 002·绑定3:**只有真的扫码**才算绑定成功。已绑过的 bot 再要码时,微信侧
|
|
172
|
+
// 常直接回 status=success(token 还是老的那条)——不设门就会「没扫也绑上」
|
|
173
|
+
// 。
|
|
174
|
+
// 判据:本次流程出现过 scanned 事件,或 success 带回的 token 与起点不同。
|
|
175
|
+
// 微信真机状态序列:wait → confirmed(=扫码已确认)→ 服务端 120s 后 expired。
|
|
176
|
+
// `confirmed` 就是扫码成功的证据(同一个 bot 重绑时 token 不变,不能拿 token
|
|
177
|
+
// 变化当判据,也不能要求先出现 scanned —— 实测这轮根本没 scanned)。
|
|
178
|
+
// 2026-10-28 真机:卡 scanned 导致 confirmed 被吞、扫完仍不绑定、最后白遮。
|
|
179
|
+
|
|
180
|
+
const started = Date.now();
|
|
181
|
+
while (!cancelled) {
|
|
182
|
+
let st;
|
|
183
|
+
try {
|
|
184
|
+
st = await poll(deps.wire, qr.qrcode);
|
|
185
|
+
} catch (e) {
|
|
186
|
+
// 状态接口偶发报错/超时**不是**码坏了:退避后继续(§6 坑 4:不能让循环
|
|
187
|
+
// 退出)。事件用 retry(可重试),绝不能报 error —— error 会让壳把二维码
|
|
188
|
+
// 白遮(态3),用户就没法扫了。
|
|
189
|
+
if (onEvent({ kind: "retry", message: String(e?.message ?? e) }) === false) {
|
|
190
|
+
set_qrcode_fresh(deps, null);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
await sleep(3000);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
// 轮询状态落日志
|
|
197
|
+
console.error(`[wxbridge] qr status=${st.kind}`);
|
|
198
|
+
if (cancelled) return; // 取消后回来的在途响应一律丢弃(换人出码时防旧协程抢绑)
|
|
199
|
+
if (st.kind === "success") {
|
|
200
|
+
const fresh = deps.loadState(); // 当场读:严禁旧快照回写
|
|
201
|
+
apply_success(fresh, st.bot_token, st.bot_id);
|
|
202
|
+
deps.saveState(fresh);
|
|
203
|
+
onEvent({ kind: "success", bot_id: st.bot_id });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (st.kind === "expired") {
|
|
207
|
+
set_qrcode_fresh(deps, null);
|
|
208
|
+
onEvent({ kind: "expired" });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (st.kind === "scanned") {
|
|
212
|
+
if (onEvent({ kind: "scanned" }) === false) {
|
|
213
|
+
set_qrcode_fresh(deps, null);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (st.kind === "error") {
|
|
218
|
+
if (onEvent(st) === false) {
|
|
219
|
+
set_qrcode_fresh(deps, null);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if ((Date.now() - started) / 1000 >= deadlineSec) {
|
|
224
|
+
set_qrcode_fresh(deps, null);
|
|
225
|
+
onEvent({ kind: "expired" });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
await sleep(3000);
|
|
229
|
+
}
|
|
230
|
+
})();
|
|
231
|
+
|
|
232
|
+
// 结束信号:任何退出路径(begin 失败/取消/死线/成功)都让调用方放锁。
|
|
233
|
+
// 没有它,begin() 一次网络失败就泄漏运行标志 —— 「刷新后永远不出码」的根因。
|
|
234
|
+
cancel.done = done;
|
|
235
|
+
return cancel;
|
|
236
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// register 单测 —— 与 wxprobe src/register.rs 单测逐条对齐(JS 版)。
|
|
2
|
+
// 跑法:node --test packages/wechat-bridge/pi/register.test.mjs
|
|
3
|
+
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
begin, poll, normalize, unknownStatusMessage,
|
|
9
|
+
apply_success, reset_session_fields, set_qrcode_fresh, runQrFlow,
|
|
10
|
+
} from "./register.mjs";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------- 状态归一(ZCode 词表两套)
|
|
13
|
+
|
|
14
|
+
test("normalize_numeric_status", () => {
|
|
15
|
+
assert.equal(normalize(0), "pending");
|
|
16
|
+
assert.equal(normalize(1), "scanned");
|
|
17
|
+
assert.equal(normalize(2), "success");
|
|
18
|
+
assert.equal(normalize(3), "expired");
|
|
19
|
+
assert.equal(normalize(4), "expired");
|
|
20
|
+
assert.equal(normalize(5), "pending", "表外数字归 pending(ZCode 语义)");
|
|
21
|
+
assert.equal(normalize(undefined), "pending");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("normalize_string_status_wordlist", () => {
|
|
25
|
+
for (const s of ["confirmed", "confirm", "authorized", "success", "ok"]) assert.equal(normalize(s), "success", s);
|
|
26
|
+
for (const s of ["scaned", "scanned", "scan", "confirmed_wait"]) assert.equal(normalize(s), "scanned", s);
|
|
27
|
+
for (const s of ["expired", "timeout", "cancel", "cancelled", "canceled"]) assert.equal(normalize(s), "expired", s);
|
|
28
|
+
for (const s of ["error", "failed", "fail"]) assert.equal(normalize(s), "error", s);
|
|
29
|
+
// 实测:等待态返回 "wait",不在 ZCode 词表,显式归并
|
|
30
|
+
for (const s of ["wait", "waiting", "pending", "created"]) assert.equal(normalize(s), "pending", s);
|
|
31
|
+
assert.equal(normalize("weird_state"), "pending", "词表外字符串归 pending(由 unknownStatusMessage 补文案)");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("unknown_status_messages_carry_install_guidance", () => {
|
|
35
|
+
// 含 install 关键词 → 直接判定未安装并给行动指引
|
|
36
|
+
const m = unknownStatusMessage("not_install");
|
|
37
|
+
assert.ok(m.includes("未安装") && m.includes("ClawBot"), m);
|
|
38
|
+
// 其它未知字符串 → 原文透传 + 同样的指引
|
|
39
|
+
const m2 = unknownStatusMessage("weird_state");
|
|
40
|
+
assert.ok(m2.includes("weird_state") && m2.includes("未安装"), m2);
|
|
41
|
+
// 数字码与缺失 → 不产生错误(ZCode 语义归 pending)
|
|
42
|
+
assert.equal(unknownStatusMessage(5), undefined);
|
|
43
|
+
assert.equal(unknownStatusMessage(undefined), undefined);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------- 重绑语义(Rust 单测同款)
|
|
47
|
+
|
|
48
|
+
test("rebind_clears_old_routing_fields_and_swaps_bot", () => {
|
|
49
|
+
// 重绑 = 清旧信息 + 换新 bot;session_key 是
|
|
50
|
+
// PF 侧归属,不在清理范围
|
|
51
|
+
const st = {
|
|
52
|
+
bot_token: "OLD-TOKEN",
|
|
53
|
+
bot_id: "old@im.bot",
|
|
54
|
+
bot_user_id: "old@im.bot",
|
|
55
|
+
buf: "OLD-CURSOR",
|
|
56
|
+
chat_id: "old@im.wechat",
|
|
57
|
+
user_id: "old@im.wechat",
|
|
58
|
+
context_token: "OLD-CTX",
|
|
59
|
+
session_key: "C:\\s.jsonl",
|
|
60
|
+
qrcode: "deadbeef",
|
|
61
|
+
};
|
|
62
|
+
apply_success(st, "NEW-TOKEN", "new@im.bot");
|
|
63
|
+
assert.equal(st.bot_token, "NEW-TOKEN");
|
|
64
|
+
assert.equal(st.bot_id, "new@im.bot");
|
|
65
|
+
assert.equal(st.bot_user_id, "new@im.bot", "from_user_id 必须跟新 bot 走");
|
|
66
|
+
for (const k of ["buf", "chat_id", "user_id", "context_token", "qrcode", "qrcode_grid"]) {
|
|
67
|
+
assert.ok(st[k] === undefined || st[k] === null, `${k} 必须被清掉`);
|
|
68
|
+
}
|
|
69
|
+
assert.equal(st.session_key, "C:\\s.jsonl", "PF 侧会话归属保留");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("reset_clears_old_bot_routing_fields", () => {
|
|
73
|
+
const st = {
|
|
74
|
+
bot_token: "new-token",
|
|
75
|
+
bot_id: "new@im.bot",
|
|
76
|
+
buf: "OLD-BOT-CURSOR",
|
|
77
|
+
chat_id: "room@im.wechat",
|
|
78
|
+
user_id: "u@im.wechat",
|
|
79
|
+
context_token: "CT",
|
|
80
|
+
session_key: "C:\\sess\\a.jsonl",
|
|
81
|
+
};
|
|
82
|
+
reset_session_fields(st);
|
|
83
|
+
for (const k of ["buf", "chat_id", "user_id", "context_token"]) {
|
|
84
|
+
assert.ok(st[k] === undefined || st[k] === null, `${k} 必须被清掉——留着就是旧 bot 会话的游标/对端`);
|
|
85
|
+
}
|
|
86
|
+
assert.equal(st.session_key, "C:\\sess\\a.jsonl", "PF 侧会话归属保留");
|
|
87
|
+
assert.equal(st.bot_token, "new-token", "新 bot 字段不受影响");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------- 流程(fake wire)
|
|
91
|
+
|
|
92
|
+
/** 脚本化 wire:get_qr 按 (qr-begin→begin / qr-status→status, 调用序) 出剧本。 */
|
|
93
|
+
function fakeWire(script) {
|
|
94
|
+
const counters = { begin: 0, status: 0 };
|
|
95
|
+
return {
|
|
96
|
+
async get_qr(tag, path, query) {
|
|
97
|
+
const key = tag === "qr-begin" ? "begin" : "status";
|
|
98
|
+
const steps = script[key] ?? [];
|
|
99
|
+
const i = counters[key]++;
|
|
100
|
+
if (i >= steps.length) {
|
|
101
|
+
// 剧本演完:状态接口挂起返回 wait(流程自然等待)
|
|
102
|
+
return { status: "wait" };
|
|
103
|
+
}
|
|
104
|
+
const step = steps[i];
|
|
105
|
+
if (step.error) throw step.error;
|
|
106
|
+
return step.payload ?? step;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function fakeState(initial = {}) {
|
|
112
|
+
let st = { ...initial };
|
|
113
|
+
return {
|
|
114
|
+
load: () => ({ ...st }),
|
|
115
|
+
save: (n) => { st = { ...n }; },
|
|
116
|
+
get current() { return { ...st }; },
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 真机状态序列:wait → confirmed(扫码已确认)→ 服务端 120s 后 expired。
|
|
121
|
+
// confirmed 必须当场绑定(同一个 bot 重绑 token 不变,不能拿 token 变化当判据)。
|
|
122
|
+
test("runQrFlow_confirmed_without_scanned_binds_immediately", async () => {
|
|
123
|
+
const events = [];
|
|
124
|
+
const fake = fakeState({ bot_token: "SAME" }); // 旧 token 与新 token 相同(同 bot 重绑)
|
|
125
|
+
const wire = fakeWire({
|
|
126
|
+
begin: [{ qrcode: "QR1", qrcode_img_content: "https://qr/1", expires_in: 120 }],
|
|
127
|
+
status: [
|
|
128
|
+
{ status: "wait" },
|
|
129
|
+
{ status: "confirmed", bot_token: "SAME", ilink_bot_id: "bot@im.bot" },
|
|
130
|
+
{ status: "expired" },
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
runQrFlow(
|
|
134
|
+
{ wire, loadState: fake.load, saveState: fake.save, sleep: () => Promise.resolve() },
|
|
135
|
+
(e) => { events.push(e); },
|
|
136
|
+
);
|
|
137
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
138
|
+
const kinds = events.map((k) => k.kind);
|
|
139
|
+
assert.equal(kinds.filter((k) => k === "success").length, 1, "confirmed = 扫码成功,当场绑定");
|
|
140
|
+
assert.ok(!kinds.includes("expired"), "成功之后不再等到 expired");
|
|
141
|
+
assert.equal(fake.current["bot_token"], "SAME");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("runQrFlow_success_writes_token_and_fires_events", async () => {
|
|
145
|
+
const events = [];
|
|
146
|
+
const fake = fakeState();
|
|
147
|
+
const wire = fakeWire({
|
|
148
|
+
begin: [{ qrcode: "QR1", qrcode_img_content: "https://qr/1", expires_in: 120 }],
|
|
149
|
+
status: [
|
|
150
|
+
{ status: 0 },
|
|
151
|
+
{ status: 1 },
|
|
152
|
+
{ status: 2, bot_token: "TK1", ilink_bot_id: "bot@im.bot" },
|
|
153
|
+
],
|
|
154
|
+
});
|
|
155
|
+
const cancel = runQrFlow(
|
|
156
|
+
{ wire, loadState: fake.load, saveState: fake.save, sleep: () => Promise.resolve() },
|
|
157
|
+
(e) => { events.push(e); },
|
|
158
|
+
);
|
|
159
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
160
|
+
cancel();
|
|
161
|
+
assert.deepEqual(events.map((e) => e.kind), ["ready", "scanned", "success"]);
|
|
162
|
+
assert.equal(events.find((e) => e.kind === "ready").url, "https://qr/1");
|
|
163
|
+
assert.equal(fake.current.bot_token, "TK1", "token 已落盘");
|
|
164
|
+
assert.equal(fake.current.bot_id, "bot@im.bot");
|
|
165
|
+
assert.equal(fake.current.qrcode, undefined, "成功后清 qrcode");
|
|
166
|
+
assert.equal(fake.current.buf, undefined, "旧游标随换绑清理");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("runQrFlow_cancel_during_scanned_clears_qrcode", async () => {
|
|
170
|
+
const fake = fakeState();
|
|
171
|
+
const wire = fakeWire({
|
|
172
|
+
begin: [{ qrcode: "QR2" }],
|
|
173
|
+
status: [{ status: 1 }],
|
|
174
|
+
});
|
|
175
|
+
const cancel = runQrFlow(
|
|
176
|
+
{ wire, loadState: fake.load, saveState: fake.save, sleep: () => Promise.resolve() },
|
|
177
|
+
(e) => (e.kind === "scanned" ? false : undefined), // UI 取消
|
|
178
|
+
);
|
|
179
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
180
|
+
assert.equal(fake.current.qrcode, undefined, "取消后清 qrcode(不空转到死线)");
|
|
181
|
+
cancel();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("runQrFlow_poll_error_keeps_looping_until_deadline", async () => {
|
|
185
|
+
const events = [];
|
|
186
|
+
const fake = fakeState();
|
|
187
|
+
const wire = fakeWire({
|
|
188
|
+
begin: [{ qrcode: "QR3" }],
|
|
189
|
+
status: [{ error: new Error("HTTP 500") }, { error: new Error("HTTP 500") }],
|
|
190
|
+
});
|
|
191
|
+
const cancel = runQrFlow(
|
|
192
|
+
{ wire, loadState: fake.load, saveState: fake.save, sleep: () => Promise.resolve(), deadlineSec: 0.005 },
|
|
193
|
+
(e) => { events.push(e); },
|
|
194
|
+
);
|
|
195
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
196
|
+
assert.ok(events.some((e) => e.kind === "retry"), "状态超时归 retry(不白遮二维码)");
|
|
197
|
+
assert.equal(events[events.length - 1].kind, "expired", "到死线收尾 expired(不静默死)");
|
|
198
|
+
cancel();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("set_qrcode_fresh_touches_only_qrcode_field", () => {
|
|
202
|
+
const fake = fakeState({ bot_token: "tk", buf: "B" });
|
|
203
|
+
const deps = { loadState: fake.load, saveState: fake.save };
|
|
204
|
+
set_qrcode_fresh(deps, "abc");
|
|
205
|
+
assert.equal(fake.current.qrcode, "abc");
|
|
206
|
+
assert.equal(fake.current.bot_token, "tk", "其它字段不动");
|
|
207
|
+
assert.equal(fake.current.buf, "B");
|
|
208
|
+
set_qrcode_fresh(deps, null);
|
|
209
|
+
assert.equal(fake.current.qrcode, undefined);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("runQrFlow_begin_failure_always_settles_done", async () => {
|
|
213
|
+
// begin 一次网络失败必须走完收尾(调用方靠 cancel.done 放锁):
|
|
214
|
+
// 泄漏运行标志 = 刷新/重绑后再也不出码
|
|
215
|
+
const events = [];
|
|
216
|
+
const fake = fakeState();
|
|
217
|
+
const wire = fakeWire({ begin: [{ error: new Error("GET /get_bot_qrcode: timeout") }] });
|
|
218
|
+
const cancel = runQrFlow(
|
|
219
|
+
{ wire, loadState: fake.load, saveState: fake.save, sleep: () => Promise.resolve() },
|
|
220
|
+
(e) => { events.push(e); },
|
|
221
|
+
);
|
|
222
|
+
assert.ok(cancel.done && typeof cancel.done.then === "function", "必须给结束信号");
|
|
223
|
+
await cancel.done;
|
|
224
|
+
assert.deepEqual(events.map((e) => e.kind), ["error"], "只报错,不静默");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("pending_word_wait_is_not_reported_as_unknown", async () => {
|
|
228
|
+
// 实测 2026-10-24:等待态返回字符串 "wait"(ZCode 词表外但 normalize 认)。
|
|
229
|
+
// 若当未知状态报 error,壳会把二维码白遮 —— 用户没码可扫。
|
|
230
|
+
const ev = await poll({ get_qr: async () => ({ status: "wait" }) }, "QR");
|
|
231
|
+
assert.equal(ev.kind, "pending");
|
|
232
|
+
});
|
package/pi/reply.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// reply —— 回复分块排版,自 wxprobe src/format/reply.rs 移植(JS 版)。
|
|
2
|
+
//
|
|
3
|
+
// hook 出站唯一依赖的两个函数:`split_long_reply_text`(3500 分块)与
|
|
4
|
+
// `extract_bot_assistant_response_messages`(CRLF 归一 + 终态冲刷)。
|
|
5
|
+
// 对拍基准 = wxprobe parity/ 的 golden.jsonl(ZCode 真实源码跑出的 71 条),
|
|
6
|
+
// 其中 flush.* 两条与 blocks.* 的 content 段在这里逐字复刻(见 hook.test.mjs)。
|
|
7
|
+
//
|
|
8
|
+
// **暂缺**(与 Rust 版同口径):工具调用摘要 / 变更摘要排版不移植——
|
|
9
|
+
// 002 产品语义里微信只收 agent 发言,工具调用被 hook 过滤(滤工具调用提取发言)。
|
|
10
|
+
|
|
11
|
+
/** ZCode `replyFormatter.ts:48-52` 的四个上限,本文件只用最后一个;
|
|
12
|
+
* 其余随工具摘要排版一并留在 Rust 参考里。 */
|
|
13
|
+
export const MAX_REPLY_MESSAGE_LENGTH = 3500;
|
|
14
|
+
|
|
15
|
+
/** ZCode `splitLongReplyText`(`replyFormatter.ts:266`),reply.rs 逐字移植。
|
|
16
|
+
*
|
|
17
|
+
* 断点取 `max(最后一个换行, 最后一个空格)`,**两者都没有才硬切 3500**;
|
|
18
|
+
* 切出的块与剩余部分都会 `trim`。断点优先级是 `Math.max` —— 意味着
|
|
19
|
+
* 只要窗口内有空格就优先在空格处断,哪怕换行更靠前。
|
|
20
|
+
*
|
|
21
|
+
* 字符计数按码点(`Array.from`),与 Rust `char` 对齐;ZCode 的 UTF-16
|
|
22
|
+
* 切法会在代理对中间产生乱码,码点切法更安全(reply.rs 同款差异说明)。 */
|
|
23
|
+
export function split_long_reply_text(text) {
|
|
24
|
+
const chunks = [];
|
|
25
|
+
let remaining = text.trim();
|
|
26
|
+
let chars = Array.from(remaining);
|
|
27
|
+
while (chars.length > MAX_REPLY_MESSAGE_LENGTH) {
|
|
28
|
+
// ZCode 的 fromIndex 是 3500(含),这里取 0..=3500 这段找断点。
|
|
29
|
+
// lastIndexOf 缺席 = -1,与 Rust rposition 的 None 分支同语义。
|
|
30
|
+
const window = chars.slice(0, MAX_REPLY_MESSAGE_LENGTH + 1).join("");
|
|
31
|
+
const newline = window.lastIndexOf("\n");
|
|
32
|
+
const space = window.lastIndexOf(" ");
|
|
33
|
+
const breakpoint = Math.max(newline, space);
|
|
34
|
+
const end = breakpoint > 0 ? breakpoint : MAX_REPLY_MESSAGE_LENGTH;
|
|
35
|
+
chunks.push(chars.slice(0, end).join("").trim());
|
|
36
|
+
remaining = chars.slice(end).join("").trim();
|
|
37
|
+
chars = Array.from(remaining);
|
|
38
|
+
}
|
|
39
|
+
if (remaining !== "") chunks.push(remaining);
|
|
40
|
+
return chunks;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** ZCode `extractBotAssistantResponseMessages`:非终态不 flush。
|
|
44
|
+
* 返回 `{ messages, rest }`(Rust 元组对位);`rest` 恒为 CRLF 归一后的原文。 */
|
|
45
|
+
export function extract_bot_assistant_response_messages(buffer, force) {
|
|
46
|
+
const normalized = buffer.replace(/\r\n/g, "\n");
|
|
47
|
+
if (!force) return { messages: [], rest: normalized };
|
|
48
|
+
return { messages: split_long_reply_text(normalized), rest: "" };
|
|
49
|
+
}
|