@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/poller.mjs
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// getupdates 响应解析 —— 自 PF 仓 wxprobe src/poller.rs **逐条移植**(JS 版,照答案抄)。
|
|
2
|
+
// 对拍基准:poller.rs 全部单测 + tests/fixtures/getupdates-real.json 真机夹具(随行李)。
|
|
3
|
+
//
|
|
4
|
+
// 对齐 ZCode `providers/weixinProvider.ts`,**所有候选字段名逐条照搬**:
|
|
5
|
+
// - 消息数组 5 候选:`msgs | messages | updates | items | list`
|
|
6
|
+
// - 游标 6 候选:`get_updates_buf | buf | next_buf | nextBuf | getUpdatesBuf | syncKey`
|
|
7
|
+
// - 文本/用户/会话/昵称/消息 id 各自的多级候选(见下)
|
|
8
|
+
// 字段命名保留 Rust 蛇形(与协议 wire 字段同名),便于与参考实现逐条对拍。
|
|
9
|
+
|
|
10
|
+
import { unbig } from "./jsonx.mjs";
|
|
11
|
+
|
|
12
|
+
const MSG_LIST_KEYS = ["msgs", "messages", "updates", "items", "list"];
|
|
13
|
+
const BUF_KEYS = [
|
|
14
|
+
"get_updates_buf",
|
|
15
|
+
"buf",
|
|
16
|
+
"next_buf",
|
|
17
|
+
"nextBuf",
|
|
18
|
+
"getUpdatesBuf",
|
|
19
|
+
"syncKey",
|
|
20
|
+
];
|
|
21
|
+
// 严格照搬 ZCode `readWeixinUserId` 的顶层候选——**不能加裸 `id`**,
|
|
22
|
+
// 否则会把消息 id 当成发送者 id(ZCode 没有这条候选)。
|
|
23
|
+
const USER_KEYS = [
|
|
24
|
+
"from_user_id",
|
|
25
|
+
"from",
|
|
26
|
+
"from_user",
|
|
27
|
+
"fromUser",
|
|
28
|
+
"user",
|
|
29
|
+
"user_id",
|
|
30
|
+
"userId",
|
|
31
|
+
];
|
|
32
|
+
// ZCode 不读回发方向,但 sendmessage 需要 `from_user_id`=机器人自身,这里补一组候选。
|
|
33
|
+
const BOT_KEYS = ["to_user_id", "to", "to_user", "toUser", "receiver", "target_id"];
|
|
34
|
+
const CHAT_KEYS = ["room", "room_id", "roomId", "chat", "chat_id", "chatId"];
|
|
35
|
+
const NAME_KEYS = [
|
|
36
|
+
"name",
|
|
37
|
+
"displayName",
|
|
38
|
+
"nickname",
|
|
39
|
+
"nick_name",
|
|
40
|
+
"remark",
|
|
41
|
+
"sender_name",
|
|
42
|
+
"from_name",
|
|
43
|
+
"title",
|
|
44
|
+
"account",
|
|
45
|
+
"alias",
|
|
46
|
+
];
|
|
47
|
+
const MSG_ID_KEYS = [
|
|
48
|
+
"id",
|
|
49
|
+
"msgid",
|
|
50
|
+
"msgId",
|
|
51
|
+
"msg_id",
|
|
52
|
+
"message_id",
|
|
53
|
+
"new_msg_id",
|
|
54
|
+
"svrMsgId",
|
|
55
|
+
"localId",
|
|
56
|
+
];
|
|
57
|
+
const CONTEXT_KEYS = ["context_token", "contextToken", "context"];
|
|
58
|
+
const ATTACH_ITEM_KEYS = ["image_item", "file_item", "video_item", "audio_item", "media_item"];
|
|
59
|
+
const NESTED_KEYS = ["msg", "message"];
|
|
60
|
+
const TOP_TEXT_KEYS = ["text", "content", "message"];
|
|
61
|
+
const NESTED_ID_KEYS = ["id", "wxid", "user_id", "userId"];
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------- 基础读侧
|
|
64
|
+
|
|
65
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
66
|
+
|
|
67
|
+
/** Rust wire.rs::str_of:字符串(哨兵还原)→ 原样;数字 → String;其余 → undefined。 */
|
|
68
|
+
function str_of(v, key) {
|
|
69
|
+
const x = v[key];
|
|
70
|
+
if (typeof x === "string") return unbig(x);
|
|
71
|
+
if (typeof x === "number") return String(x);
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 依次取第一个非空候选键(Rust wire.rs::first_str 的防御式嗅探);v 缺省(None)→ undefined。 */
|
|
76
|
+
function first_str(v, keys) {
|
|
77
|
+
if (v === undefined || v === null) return undefined;
|
|
78
|
+
for (const k of keys) {
|
|
79
|
+
const s = str_of(v, k);
|
|
80
|
+
if (s !== undefined && s !== "") return s;
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const hasOwn = (v, k) => Object.hasOwn(v, k);
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------- 嗅探(照 poller.rs)
|
|
88
|
+
|
|
89
|
+
/** ZCode `readInboundMessage` 的内层:第一个**存在的** msg/message 键,是对象才取。 */
|
|
90
|
+
function nested(m) {
|
|
91
|
+
for (const k of NESTED_KEYS) {
|
|
92
|
+
if (!hasOwn(m, k)) continue;
|
|
93
|
+
return isObj(m[k]) ? m[k] : undefined;
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function item_list(m, inner) {
|
|
99
|
+
for (const src of [m, inner]) {
|
|
100
|
+
if (src === undefined) continue;
|
|
101
|
+
const list = src["item_list"];
|
|
102
|
+
if (Array.isArray(list) && list.length > 0) return list;
|
|
103
|
+
}
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** ZCode `readWeixinText`:顶层 text/content/message → item_list 拼接 → inner.text/content。 */
|
|
108
|
+
function read_text(m, inner) {
|
|
109
|
+
const top = first_str(m, TOP_TEXT_KEYS);
|
|
110
|
+
if (top !== undefined) return top;
|
|
111
|
+
const parts = item_list(m, inner)
|
|
112
|
+
.map(read_text_item)
|
|
113
|
+
.filter((s) => s !== undefined && s !== "");
|
|
114
|
+
if (parts.length > 0) return parts.join("\n");
|
|
115
|
+
return (inner !== undefined && first_str(inner, TOP_TEXT_KEYS)) || "";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function read_text_item(item) {
|
|
119
|
+
const ti = item["text_item"];
|
|
120
|
+
const t =
|
|
121
|
+
(isObj(ti) ? str_of(ti, "text") : undefined) ??
|
|
122
|
+
str_of(item, "text") ??
|
|
123
|
+
str_of(item, "content");
|
|
124
|
+
return t !== undefined && t !== "" ? t : undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function count_attachments(m, inner) {
|
|
128
|
+
let direct = 0;
|
|
129
|
+
for (const src of [m, inner]) {
|
|
130
|
+
if (src !== undefined && Array.isArray(src["attachments"])) {
|
|
131
|
+
direct += src["attachments"].length;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const fromItems = item_list(m, inner).filter(
|
|
135
|
+
(it) =>
|
|
136
|
+
read_text_item(it) === undefined &&
|
|
137
|
+
(ATTACH_ITEM_KEYS.some((k) => hasOwn(it, k)) || str_of(it, "kind") !== undefined),
|
|
138
|
+
).length;
|
|
139
|
+
return direct + fromItems;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 嵌套 from/sender 对象里的 id 嗅探(readWeixinUserId / bot 反推共用)。 */
|
|
143
|
+
function nested_id(src, keys) {
|
|
144
|
+
if (src === undefined) return undefined;
|
|
145
|
+
for (const k of keys) {
|
|
146
|
+
if (!hasOwn(src, k)) continue;
|
|
147
|
+
const o = src[k];
|
|
148
|
+
if (!isObj(o)) continue;
|
|
149
|
+
const s = first_str(o, NESTED_ID_KEYS);
|
|
150
|
+
if (s !== undefined) return s;
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** ZCode `readWeixinUserId`:顶层候选 → from/sender.id → 内层两级。缺失 = 空串(丢弃信号)。 */
|
|
156
|
+
function read_user_id(m, inner) {
|
|
157
|
+
return (
|
|
158
|
+
first_str(m, USER_KEYS) ??
|
|
159
|
+
nested_id(m, ["from", "sender"]) ??
|
|
160
|
+
first_str(inner, USER_KEYS) ??
|
|
161
|
+
nested_id(inner, ["from", "sender"]) ??
|
|
162
|
+
""
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 机器人自身 id(`sendmessage.from_user_id`):从消息的回发方向反推。 */
|
|
167
|
+
function read_bot_user_id(m, inner) {
|
|
168
|
+
return (
|
|
169
|
+
first_str(m, BOT_KEYS) ??
|
|
170
|
+
nested_id(m, BOT_KEYS) ??
|
|
171
|
+
first_str(inner, BOT_KEYS) ??
|
|
172
|
+
nested_id(inner, BOT_KEYS)
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** ZCode `readWeixinMessageId`:先按字符串取(两级候选),再退回数字。 */
|
|
177
|
+
function read_msg_id(m, inner) {
|
|
178
|
+
const s =
|
|
179
|
+
first_str(m, MSG_ID_KEYS) ??
|
|
180
|
+
(inner !== undefined ? first_str(inner, MSG_ID_KEYS) : undefined);
|
|
181
|
+
if (s !== undefined) return s;
|
|
182
|
+
for (const src of [m, inner]) {
|
|
183
|
+
if (src === undefined) continue;
|
|
184
|
+
for (const k of MSG_ID_KEYS) {
|
|
185
|
+
const v = src[k];
|
|
186
|
+
if (typeof v === "number") return String(v);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** ZCode `readNextBuf`:6 个候选字段名。 */
|
|
193
|
+
function read_next_buf(container) {
|
|
194
|
+
return first_str(container, BUF_KEYS);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function read_inbound(m) {
|
|
198
|
+
const inner = nested(m);
|
|
199
|
+
const text = read_text(m, inner);
|
|
200
|
+
const attachments = count_attachments(m, inner);
|
|
201
|
+
if (text.trim() === "" && attachments === 0) return null; // 空消息丢弃
|
|
202
|
+
const user_id = read_user_id(m, inner);
|
|
203
|
+
if (user_id === "") return null; // ZCode 同语义:缺发送者丢弃,否则回发 to 为空
|
|
204
|
+
return {
|
|
205
|
+
msg_id: read_msg_id(m, inner),
|
|
206
|
+
text,
|
|
207
|
+
user_id,
|
|
208
|
+
bot_user_id: read_bot_user_id(m, inner),
|
|
209
|
+
chat_id: first_str(m, CHAT_KEYS) ?? (inner !== undefined ? first_str(inner, CHAT_KEYS) : undefined),
|
|
210
|
+
name: first_str(m, NAME_KEYS) ?? (inner !== undefined ? first_str(inner, NAME_KEYS) : undefined),
|
|
211
|
+
context_token: first_str(m, CONTEXT_KEYS) ?? (inner !== undefined ? first_str(inner, CONTEXT_KEYS) : undefined),
|
|
212
|
+
attachments,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------- 诊断(P0 产出:字段形状对拍)
|
|
217
|
+
|
|
218
|
+
function keys_of(v) {
|
|
219
|
+
if (isObj(v)) {
|
|
220
|
+
const ks = Object.keys(v);
|
|
221
|
+
return ks.length === 0 ? "(空)" : ks.join(", ");
|
|
222
|
+
}
|
|
223
|
+
return `非对象: ${JSON.stringify(v)}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function probe_text(m) {
|
|
227
|
+
const inner = nested(m);
|
|
228
|
+
const hits = [];
|
|
229
|
+
for (const k of TOP_TEXT_KEYS) {
|
|
230
|
+
if (str_of(m, k) !== undefined) hits.push(`顶层.${k}`);
|
|
231
|
+
}
|
|
232
|
+
for (const [label, src] of [["顶层", m], ["内层", inner]]) {
|
|
233
|
+
if (src === undefined) continue;
|
|
234
|
+
const list = src["item_list"];
|
|
235
|
+
if (!Array.isArray(list)) continue;
|
|
236
|
+
list.forEach((it, ix) => {
|
|
237
|
+
if (read_text_item(it) !== undefined) hits.push(`${label}.item_list[${ix}]`);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return hits.length > 0 ? hits.join(", ") : "(无)";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function diagnose(container, raw_msgs) {
|
|
244
|
+
const out = [];
|
|
245
|
+
out.push(`顶层键: ${keys_of(container)}`);
|
|
246
|
+
const picked = MSG_LIST_KEYS.find((k) => hasOwn(container, k));
|
|
247
|
+
out.push(`消息数组键: ${picked ?? "未命中任何候选"}`);
|
|
248
|
+
const hits = BUF_KEYS.filter((k) => hasOwn(container, k));
|
|
249
|
+
out.push(`游标命中: ${hits.length > 0 ? hits.join(", ") : "(无)"}`);
|
|
250
|
+
const m = raw_msgs[0];
|
|
251
|
+
if (m !== undefined) {
|
|
252
|
+
out.push(`首条消息键: ${keys_of(m)}`);
|
|
253
|
+
const inner = nested(m);
|
|
254
|
+
if (inner !== undefined) out.push(` 内层(msg/message)键: ${keys_of(inner)}`);
|
|
255
|
+
const items = item_list(m, inner);
|
|
256
|
+
if (items.length > 0) {
|
|
257
|
+
out.push(` item_list[${items.length}]: ${items.map((it) => keys_of(it)).join(" | ")}`);
|
|
258
|
+
}
|
|
259
|
+
out.push(` 文本候选命中: ${probe_text(m)}`);
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------- 入口
|
|
265
|
+
|
|
266
|
+
/** getupdates 长轮询一拍(Rust poller.rs::get_updates 同名移植)。 */
|
|
267
|
+
export async function get_updates(wire, token, buf) {
|
|
268
|
+
const payload = await wire.post_poll(
|
|
269
|
+
"getupdates",
|
|
270
|
+
"/getupdates",
|
|
271
|
+
token,
|
|
272
|
+
{ get_updates_buf: buf ?? "" },
|
|
273
|
+
);
|
|
274
|
+
return parse_updates(payload, buf ?? null);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 纯解析:把 getupdates 响应映射成 Updates(Rust poller.rs::parse_updates 同名移植)。
|
|
279
|
+
* 与传输解耦,真机响应才能落成黄金夹具做单测。
|
|
280
|
+
*
|
|
281
|
+
* @param {object} payload getupdates 响应(建议经 jsonx.parse,message_id 需保精度)
|
|
282
|
+
* @param {string|null} previous_buf 入参旧游标;服务端没给新游标时回落(ZCode `readNextBuf(...) ?? buf`)
|
|
283
|
+
*/
|
|
284
|
+
export function parse_updates(payload, previous_buf = null) {
|
|
285
|
+
const container = payload ?? {};
|
|
286
|
+
const raw_msgs = MSG_LIST_KEYS.map((k) => container[k]).find(Array.isArray) ?? [];
|
|
287
|
+
const messages = raw_msgs.map(read_inbound).filter((x) => x !== null);
|
|
288
|
+
return {
|
|
289
|
+
raw_count: raw_msgs.length,
|
|
290
|
+
messages,
|
|
291
|
+
// 服务端没给新游标就沿用旧值——保证「启停不丢不重」。
|
|
292
|
+
next_buf: read_next_buf(container) ?? previous_buf,
|
|
293
|
+
diagnostics: diagnose(container, raw_msgs),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 剥掉 ANSI 转义序列(CSI 色码、nF 两字节转义)—— Rust poller.rs::strip_ansi 同名移植。
|
|
299
|
+
* 微信是纯文本通道,控制符会原样显示成乱码(真机:`\x1b[38;5;5m🔌 MCP: …` 直达用户)。
|
|
300
|
+
*/
|
|
301
|
+
export function strip_ansi(text) {
|
|
302
|
+
let out = "";
|
|
303
|
+
const ch = Array.from(text); // 按 code point 迭代,emoji 不拆
|
|
304
|
+
let i = 0;
|
|
305
|
+
while (i < ch.length) {
|
|
306
|
+
const c = ch[i];
|
|
307
|
+
if (c !== "\x1b") {
|
|
308
|
+
out += c;
|
|
309
|
+
i += 1;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const nx = ch[i + 1];
|
|
313
|
+
if (nx === "[") {
|
|
314
|
+
// CSI:参数/中间字节一路吃,终止字节(@–~,含)为止
|
|
315
|
+
i += 2;
|
|
316
|
+
while (i < ch.length) {
|
|
317
|
+
const t = ch[i];
|
|
318
|
+
i += 1;
|
|
319
|
+
if (t >= "@" && t <= "~") break;
|
|
320
|
+
}
|
|
321
|
+
} else if (nx !== undefined) {
|
|
322
|
+
// 吃一个字符;若是 nF 中间字节(0x20–0x2F)再吃终止字节(0x30–0x7E)
|
|
323
|
+
i += nx >= " " && nx <= "/" ? 3 : 2;
|
|
324
|
+
} else {
|
|
325
|
+
i += 1; // 悬尾 ESC 直接丢弃
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** ZCode `buildWeixinText`:微信各客户端对 LF 处理不一致,发送前统一 CRLF。 */
|
|
332
|
+
export function normalize_crlf(text) {
|
|
333
|
+
return text.replace(/\r\n/g, "\n").replace(/\n/g, "\r\n");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ---------------------------------------------------------------- sendmessage(出站)
|
|
337
|
+
|
|
338
|
+
let SEND_SEQ = 0;
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* sendmessage 回发一条文本(Rust poller.rs::send_text 同名移植)。
|
|
342
|
+
*
|
|
343
|
+
* iLink 协议参考(epiral/weixin-bot PROTOCOL.md):`client_id` = "unique_id"
|
|
344
|
+
* ——**每条消息唯一**。state 里存的是稳定实例 id,这里拼上毫秒 + 序列号
|
|
345
|
+
* 成每条唯一(2026-10-23 真机排障:受理返回 message_id 但手机不投递,
|
|
346
|
+
* 按 ZCode 语义逐项对拍时唯一的协议违规点)。
|
|
347
|
+
*/
|
|
348
|
+
export async function send_text(wire, token, from_user, to_user, text, context_token, client_id) {
|
|
349
|
+
const seq = SEND_SEQ++;
|
|
350
|
+
const millis = Date.now();
|
|
351
|
+
const unique_client_id = `${client_id}-${millis}-${seq}`;
|
|
352
|
+
// ZCode `buildWeixinText`:微信各客户端对 LF 处理不一致,统一 CRLF
|
|
353
|
+
const normalized = normalize_crlf(strip_ansi(text));
|
|
354
|
+
const msg = {
|
|
355
|
+
from_user_id: from_user,
|
|
356
|
+
to_user_id: to_user,
|
|
357
|
+
client_id: unique_client_id,
|
|
358
|
+
message_type: 2,
|
|
359
|
+
message_state: 2,
|
|
360
|
+
item_list: [{ type: 1, text_item: { text: normalized } }],
|
|
361
|
+
};
|
|
362
|
+
if (context_token && context_token !== "") msg.context_token = context_token;
|
|
363
|
+
await wire.post("sendmessage", "/sendmessage", token, { msg });
|
|
364
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// poller 对拍单测 —— 与 PF 仓 wxprobe src/poller.rs 单测**逐条对齐**(JS 版)。
|
|
2
|
+
// 跑法:node --test packages/wechat-bridge/pi/
|
|
3
|
+
// 真机夹具随行李:pi/fixtures/getupdates-real.json。
|
|
4
|
+
// iLink 协议一变,这里最先红。
|
|
5
|
+
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { parse_updates, strip_ansi, normalize_crlf } from "./poller.mjs";
|
|
13
|
+
import { parse, guardBigInt, unbig } from "./jsonx.mjs";
|
|
14
|
+
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const fixtureText = readFileSync(join(here, "fixtures", "getupdates-real.json"), "utf8");
|
|
17
|
+
const realPayload = () => parse(fixtureText);
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------- 真机夹具对拍
|
|
20
|
+
|
|
21
|
+
test("parses_real_getupdates_fixture", () => {
|
|
22
|
+
const up = parse_updates(realPayload(), null);
|
|
23
|
+
assert.equal(up.raw_count, 1, "原始消息数");
|
|
24
|
+
assert.equal(up.messages.length, 1, "解析成功数");
|
|
25
|
+
const m = up.messages[0];
|
|
26
|
+
assert.equal(m.text, "测试文本");
|
|
27
|
+
assert.equal(m.user_id, "user_sandbox@im.wechat");
|
|
28
|
+
assert.equal(m.bot_user_id, "bot_sandbox@im.bot");
|
|
29
|
+
// 19 位大整数:JSON.parse 会丢精度,jsonx 哨兵方案必须精确到个位
|
|
30
|
+
assert.equal(m.msg_id, "7513480159106982024");
|
|
31
|
+
assert.equal(m.context_token, "SANITIZED_CONTEXT_TOKEN");
|
|
32
|
+
assert.equal(m.chat_id, undefined, "私聊没有 room/chat 字段");
|
|
33
|
+
assert.equal(m.attachments, 0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("cursor_taken_from_server_on_first_poll", () => {
|
|
37
|
+
const expected = realPayload()["get_updates_buf"];
|
|
38
|
+
const up = parse_updates(realPayload(), null);
|
|
39
|
+
assert.equal(up.next_buf, expected);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("cursor_falls_back_to_previous_when_server_omits_it", () => {
|
|
43
|
+
// 服务端没给新游标(或给空串)时必须沿用旧值,否则「启停」会把游标冲掉。
|
|
44
|
+
const payload = realPayload();
|
|
45
|
+
payload["get_updates_buf"] = "";
|
|
46
|
+
const up = parse_updates(payload, "PREVIOUS_CURSOR");
|
|
47
|
+
assert.equal(up.next_buf, "PREVIOUS_CURSOR");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("cursor_keeps_previous_when_field_absent_entirely", () => {
|
|
51
|
+
const up = parse_updates({ msgs: [] }, "PREVIOUS_CURSOR");
|
|
52
|
+
assert.equal(up.next_buf, "PREVIOUS_CURSOR");
|
|
53
|
+
assert.equal(up.raw_count, 0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("message_without_sender_is_dropped", () => {
|
|
57
|
+
const payload = {
|
|
58
|
+
msgs: [{ message_id: 1, item_list: [{ type: 1, text_item: { text: "hi" } }] }],
|
|
59
|
+
get_updates_buf: "X",
|
|
60
|
+
};
|
|
61
|
+
const up = parse_updates(payload, null);
|
|
62
|
+
assert.equal(up.raw_count, 1, "原始数组仍计数");
|
|
63
|
+
assert.equal(up.messages.length, 0, "缺发送者的消息必须被丢弃");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("attachment_item_is_counted_and_text_item_is_not", () => {
|
|
67
|
+
// 纯图片消息没有 text,靠附件计数才不会被当成空消息丢掉。
|
|
68
|
+
const payload = {
|
|
69
|
+
msgs: [
|
|
70
|
+
{
|
|
71
|
+
from_user_id: "user_sandbox@im.wechat",
|
|
72
|
+
item_list: [
|
|
73
|
+
{ type: 1, image_item: { media: { url: "https://x/img.png", aes_key: "K" } } },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
get_updates_buf: "X",
|
|
78
|
+
};
|
|
79
|
+
const up = parse_updates(payload, null);
|
|
80
|
+
assert.equal(up.messages.length, 1);
|
|
81
|
+
assert.equal(up.messages[0].attachments, 1);
|
|
82
|
+
assert.equal(up.messages[0].text, "");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("diagnostics_report_the_real_field_names", () => {
|
|
86
|
+
// 直接对拍候选嗅探:命中哪个候选必须打印出来。
|
|
87
|
+
const up = parse_updates(realPayload(), null);
|
|
88
|
+
const joined = up.diagnostics.join("\n");
|
|
89
|
+
assert.ok(joined.includes("消息数组键: msgs"), `候选 1 应命中:${joined}`);
|
|
90
|
+
assert.ok(joined.includes("游标命中: get_updates_buf"), `候选 1 应命中:${joined}`);
|
|
91
|
+
assert.ok(joined.includes("文本候选命中: 顶层.item_list[0]"), joined);
|
|
92
|
+
assert.ok(!joined.includes("未命中任何候选"), joined);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("text_item_wins_over_nested_content", () => {
|
|
96
|
+
// 顶层 text 与 item_list 同时存在时,顶层优先(ZCode readWeixinText 同序)。
|
|
97
|
+
const payload = {
|
|
98
|
+
msgs: [
|
|
99
|
+
{
|
|
100
|
+
from_user_id: "u@x",
|
|
101
|
+
text: "顶层文本",
|
|
102
|
+
item_list: [{ type: 1, text_item: { text: "item 文本" } }],
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
const up = parse_updates(payload, null);
|
|
107
|
+
assert.equal(up.messages[0].text, "顶层文本");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------- 游标语义(at-least-once)
|
|
111
|
+
|
|
112
|
+
test("twenty_start_stop_cycles_never_lose_or_duplicate_the_cursor", () => {
|
|
113
|
+
// 对应验收「重复启停 20 次游标不丢不重」的确定性版本:
|
|
114
|
+
// 每个周期 = load → getupdates → **处理完** → 写回,跑 20 轮。
|
|
115
|
+
const serverCursor = realPayload()["get_updates_buf"];
|
|
116
|
+
let stored = null;
|
|
117
|
+
for (let cycle = 0; cycle < 20; cycle += 1) {
|
|
118
|
+
// 只有第 1 轮服务端发了新游标,其余 19 轮都是空闲(不带该字段)。
|
|
119
|
+
const payload = cycle === 0 ? realPayload() : { msgs: [] };
|
|
120
|
+
const up = parse_updates(payload, stored);
|
|
121
|
+
// 写回发生在本批消息全部处理完之后(ZCode weixinChannelRuntime.ts:155-163)
|
|
122
|
+
if (up.next_buf !== null && up.next_buf !== undefined) stored = up.next_buf;
|
|
123
|
+
}
|
|
124
|
+
assert.equal(
|
|
125
|
+
stored,
|
|
126
|
+
serverCursor,
|
|
127
|
+
"20 次启停后游标必须恰好等于服务端最后一次下发的值——既不能丢,也不能被重复推进",
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("idle_polls_do_not_advance_the_cursor", () => {
|
|
132
|
+
const up = parse_updates({ msgs: [], get_updates_buf: "" }, "CURSOR_V1");
|
|
133
|
+
assert.equal(up.next_buf, "CURSOR_V1", "空闲轮询不得改写游标");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------- strip_ansi
|
|
137
|
+
|
|
138
|
+
test("strip_ansi_removes_sgr_color_codes", () => {
|
|
139
|
+
// 2026-10-23 真机 sendmessage 实发内容:色码直达微信用户
|
|
140
|
+
assert.equal(strip_ansi("\x1b[38;5;5m🔌 MCP: 1 server enabled\x1b[39m"), "🔌 MCP: 1 server enabled");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("strip_ansi_keeps_plain_text_and_newlines", () => {
|
|
144
|
+
const s = "第一行\n第二行 with emoji ✅ no escapes";
|
|
145
|
+
assert.equal(strip_ansi(s), s);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("strip_ansi_handles_trailing_escape_and_two_byte_forms", () => {
|
|
149
|
+
assert.equal(strip_ansi("ab\x1b"), "ab", "悬尾 ESC 直接丢弃");
|
|
150
|
+
assert.equal(strip_ansi("a\x1b(Bb"), "ab", "两字节转义整对丢弃");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("normalize_crlf_unifies_line_endings", () => {
|
|
154
|
+
// ZCode buildWeixinText:微信各客户端对 LF 处理不一致,统一 CRLF
|
|
155
|
+
assert.equal(normalize_crlf("a\r\nb\nc"), "a\r\nb\r\nc");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------- jsonx(大整数保精度)
|
|
159
|
+
|
|
160
|
+
test("jsonx_preserves_19_digit_message_id", () => {
|
|
161
|
+
const raw = '{"message_id":7513480159106982024,"n":13,"f":1.5}';
|
|
162
|
+
const v = parse(raw);
|
|
163
|
+
assert.equal(typeof v.message_id, "string", "超安全区间整数 → 哨兵字符串");
|
|
164
|
+
assert.equal(unbig(v.message_id), "7513480159106982024");
|
|
165
|
+
assert.equal(v.n, 13, "安全区间整数保持 number");
|
|
166
|
+
assert.equal(v.f, 1.5, "浮点不动");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("jsonx_leaves_digits_inside_strings_alone", () => {
|
|
170
|
+
const v = parse('{"s":"12345678901234567890","esc":"a\\"12345678901234567890"}');
|
|
171
|
+
assert.equal(v.s, "12345678901234567890", "字符串值里的长数字不是 JSON 数字,不哨兵化");
|
|
172
|
+
assert.equal(v.esc, 'a"12345678901234567890');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("jsonx_handles_arrays_and_boundaries", () => {
|
|
176
|
+
const v = parse('{"a":[7513480159106982024,9007199254740991,9007199254740993]}');
|
|
177
|
+
assert.equal(unbig(v.a[0]), "7513480159106982024");
|
|
178
|
+
assert.equal(v.a[1], 9007199254740991, "2^53-1 恰好安全,保持 number");
|
|
179
|
+
assert.equal(unbig(v.a[2]), "9007199254740993", "2^53+1 已不安全");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("jsonx_guard_output_is_valid_json_and_unbig_round_trips", () => {
|
|
183
|
+
const raw = '{"message_id":7513480159106982024,"user":"u1"}';
|
|
184
|
+
const guarded = guardBigInt(raw);
|
|
185
|
+
assert.doesNotThrow(() => JSON.parse(guarded), "哨兵化后仍是合法 JSON");
|
|
186
|
+
assert.ok(
|
|
187
|
+
guarded.includes(JSON.stringify("\u0000BI:7513480159106982024")),
|
|
188
|
+
"裸整数应被包成哨兵字符串",
|
|
189
|
+
);
|
|
190
|
+
assert.ok(!guarded.includes("user\":\"\u0000"), "普通字符串值不动");
|
|
191
|
+
});
|