@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/transport.mjs
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// transport —— 微信渠道传输,自 PF 仓 wxprobe src/transport.rs **逐条移植**(JS 版)。
|
|
2
|
+
//
|
|
3
|
+
// **结构** —— 对齐 ZCode:JS 单线程下 90s 长轮询 `await` 期间 sendMessage
|
|
4
|
+
// 仍能并发发出(这就是 ZCode 用两条 Rust 线程模拟的形态;JS 的 async 循环
|
|
5
|
+
// 天然就是双任务,无须线程):
|
|
6
|
+
//
|
|
7
|
+
// ```text
|
|
8
|
+
// [pollLoop] getupdates(90s) ──► batch 队列 ──► 泵 recv()
|
|
9
|
+
// [sendLoop] send(text) 入队 ──► sendmessage
|
|
10
|
+
// ```
|
|
11
|
+
//
|
|
12
|
+
// **游标(§6 坑 2「处理完再写」)**:循环内只在**内存**里推进 cursor,
|
|
13
|
+
// 落盘由 `ack_cursor()` 触发 —— app 处理完一批才 ack。进程中途崩溃则下次
|
|
14
|
+
// 启动从上次 ack 的游标重拉,**可能重复、绝不丢失**(at-least-once)。
|
|
15
|
+
//
|
|
16
|
+
// **生命周期铁律**(pi extensions.md):transport 只在 `session_start` 起、
|
|
17
|
+
// `session_shutdown` 幂等收(stop())。stop 不等待在途 90s 长轮询(Rust
|
|
18
|
+
// 「drop 不 join」的 UI 冻结教训,2026-10-24)——置停止标志后自然到点退出,
|
|
19
|
+
// 未 ack 的游标下个轮询者重收(重复而非丢失,方向正确)。
|
|
20
|
+
|
|
21
|
+
import { get_updates, send_text, parse_updates } from "./poller.mjs";
|
|
22
|
+
import { require_token, config_from_state } from "./config.mjs";
|
|
23
|
+
import { acquire, LEASE_MS, HEARTBEAT_MS } from "./lock.mjs";
|
|
24
|
+
import * as state from "./state.mjs";
|
|
25
|
+
import { Wire, POLL_TIMEOUT_MS } from "./wire.mjs";
|
|
26
|
+
|
|
27
|
+
export { require_token, config_from_state };
|
|
28
|
+
|
|
29
|
+
/** 一次 `getupdates` 拉回的消息批。整批一起交付:`next_buf` 是这批处理完
|
|
30
|
+
* 之后应落盘的游标,app 必须在**全部处理完**后再 ack。 */
|
|
31
|
+
export class Batch {
|
|
32
|
+
constructor(messages, next_buf) {
|
|
33
|
+
this.messages = messages;
|
|
34
|
+
this.next_buf = next_buf;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `errcode:-14 session timeout`:iLink 判这是**过期会话**而非网络抖动。
|
|
39
|
+
*
|
|
40
|
+
* 两种来源(都真机踩过):①换绑后 `buf` 里嵌着旧 bot 的游标(2026-10-23);
|
|
41
|
+
* ②PF 重启后拿上一进程的游标续会话、旧长轮询还挂在服务端(2026-10-24:
|
|
42
|
+
* 重启即全天 -14)。 */
|
|
43
|
+
export function is_session_timeout(msg) {
|
|
44
|
+
return msg.includes("session timeout") || msg.includes("errcode=Some(-14") || /errcode=-14\b/.test(msg);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 指数退避,封顶 30s(Rust `500ms << min(failures,6)` 同式)。 */
|
|
48
|
+
export function next_wait_ms(failures) {
|
|
49
|
+
return Math.min(500 * 2 ** Math.min(failures, 6), 30_000);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 一拍错误的处置决策(pollLoop 骨架的纯函数化,供单测):
|
|
54
|
+
* - `cursor_flush`:拿着旧游标被拒 = 会话还活着,洗掉 buf 用同一 token
|
|
55
|
+
* 重新接洽即可(模块内部自愈,不打扰用户);
|
|
56
|
+
* - `session_dead`:**空游标仍被拒** = 微信服务侧已把 bot 会话作废(长期
|
|
57
|
+
* 不活动过期)。置位交 app 自动回绑定页(002);轮询保持低频重试。
|
|
58
|
+
* - `generic`:网络抖动,退避重试,**不退出**(§6 坑 4:一次失败退出 = bot
|
|
59
|
+
* 静默死掉)。
|
|
60
|
+
*/
|
|
61
|
+
export function classify_poll_error(has_cursor) {
|
|
62
|
+
return has_cursor ? "cursor_flush" : "session_dead";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------- 工厂
|
|
66
|
+
|
|
67
|
+
/** 锁根展示串(避免为展示再引 fs)。 */
|
|
68
|
+
function lock_root_display() {
|
|
69
|
+
return `${process.env.PI_FLASH_DIR || "~/.pi-flash"}/bots-runtime-locks`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------- Transport
|
|
73
|
+
|
|
74
|
+
export class Transport {
|
|
75
|
+
/**
|
|
76
|
+
* 起渠道。失败(已被别的轮询者持有锁)时不起任何循环。
|
|
77
|
+
* deps 可注入(单测);生产用默认。static 才能触达 #start 私有。
|
|
78
|
+
*/
|
|
79
|
+
static async spawn(cfg, deps = {}) {
|
|
80
|
+
const lock =
|
|
81
|
+
deps.lock ??
|
|
82
|
+
(await acquire("weixin-polling", cfg.token, cfg.bot_id, {
|
|
83
|
+
heartbeatMs: deps.heartbeatMs ?? HEARTBEAT_MS,
|
|
84
|
+
leaseMs: deps.leaseMs ?? LEASE_MS,
|
|
85
|
+
}));
|
|
86
|
+
if (!lock) {
|
|
87
|
+
throw new Error(`另一个轮询者已持有该 bot 的锁,见 ${lock_root_display()}`);
|
|
88
|
+
}
|
|
89
|
+
const t = new Transport(cfg, lock, deps);
|
|
90
|
+
t.#start();
|
|
91
|
+
return t;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
constructor(cfg, lock, deps = {}) {
|
|
95
|
+
this.cfg = cfg;
|
|
96
|
+
this._lock = lock;
|
|
97
|
+
this.wire = deps.wire ?? new Wire(state.dump_dir());
|
|
98
|
+
this.loadState = deps.loadState ?? state.load;
|
|
99
|
+
this.saveState = deps.saveState ?? state.save;
|
|
100
|
+
this.getUpdates =
|
|
101
|
+
deps.getUpdates ??
|
|
102
|
+
((token, buf) => get_updates(this.wire, token, buf));
|
|
103
|
+
this.sendText =
|
|
104
|
+
deps.sendText ??
|
|
105
|
+
((token, from, to, text, ctxToken, clientId) =>
|
|
106
|
+
send_text(this.wire, token, from, to, text, ctxToken, clientId));
|
|
107
|
+
this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
108
|
+
this.pollTimeoutMs = deps.pollTimeoutMs ?? POLL_TIMEOUT_MS;
|
|
109
|
+
|
|
110
|
+
this._stopped = false;
|
|
111
|
+
this._last_poll_error = null;
|
|
112
|
+
this._session_dead = false;
|
|
113
|
+
this._warned = { send: false, poll: false, route: false };
|
|
114
|
+
this._batches = [];
|
|
115
|
+
this._waiting = null; // recv() 的等待者
|
|
116
|
+
this._sendQueue = [];
|
|
117
|
+
this._sendWaiting = null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- 生命周期 ----
|
|
121
|
+
|
|
122
|
+
#start() {
|
|
123
|
+
const startCursor = state.get_str(this.loadState(), "buf") ?? null;
|
|
124
|
+
this.#pollLoop(startCursor); // 不 await:双异步任务(ZCode 同款)
|
|
125
|
+
this.#sendLoop();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 幂等收(session_shutdown 语义):置标志、唤醒等待者、释放锁。
|
|
129
|
+
* **不等待在途 90s**——旧轮询到点后见 stopped 自行退出。 */
|
|
130
|
+
stop() {
|
|
131
|
+
if (this._stopped) return;
|
|
132
|
+
this._stopped = true;
|
|
133
|
+
this.#wakeRecv();
|
|
134
|
+
this.#wakeSend();
|
|
135
|
+
this._lock.release();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- 入站面(泵侧) ----
|
|
139
|
+
|
|
140
|
+
/** 非阻塞取一批;空则 null。 */
|
|
141
|
+
try_recv() {
|
|
142
|
+
return this._batches.shift() ?? null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 阻塞取一批;stop 后返回 null(泵自然收尾)。 */
|
|
146
|
+
recv() {
|
|
147
|
+
const b = this.try_recv();
|
|
148
|
+
if (b) return Promise.resolve(b);
|
|
149
|
+
if (this._stopped) return Promise.resolve(null);
|
|
150
|
+
return new Promise((resolve) => {
|
|
151
|
+
this._waiting = resolve;
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 批次处理完后落盘游标(§6 坑 2:**处理完才写**)。 */
|
|
156
|
+
ack_cursor(batch) {
|
|
157
|
+
if (batch.next_buf === null || batch.next_buf === undefined) return;
|
|
158
|
+
const st = this.loadState();
|
|
159
|
+
state.set(st, "buf", batch.next_buf);
|
|
160
|
+
this.saveState(st);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** **处理动作之前**先落路由字段(真机 2026-10-24:首条回执找不到对端
|
|
164
|
+
* 即此——route 时回执已进出站队列,send 线程随即重读状态)。 */
|
|
165
|
+
ack_routing(batch) {
|
|
166
|
+
const m = batch.messages[0];
|
|
167
|
+
if (!m) return;
|
|
168
|
+
const st = this.loadState();
|
|
169
|
+
let dirty = false;
|
|
170
|
+
if (m.chat_id) { state.set(st, "chat_id", m.chat_id); dirty = true; }
|
|
171
|
+
if (m.user_id !== "") { state.set(st, "user_id", m.user_id); dirty = true; }
|
|
172
|
+
if (m.bot_user_id) { state.set(st, "bot_user_id", m.bot_user_id); dirty = true; }
|
|
173
|
+
if (m.context_token) { state.set(st, "context_token", m.context_token); dirty = true; }
|
|
174
|
+
if (dirty) this.saveState(st);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 兼容入口:路由 + 游标一次完成。 */
|
|
178
|
+
ack(batch) {
|
|
179
|
+
this.ack_routing(batch);
|
|
180
|
+
this.ack_cursor(batch);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---- 出站面 ----
|
|
184
|
+
|
|
185
|
+
/** 发一条文本到微信 —— 只入队,实际 HTTP 在 sendLoop 里跑,**绝不阻塞调用方**。 */
|
|
186
|
+
send(text) {
|
|
187
|
+
if (this._stopped) throw new Error("send 线程已退出");
|
|
188
|
+
this._sendQueue.push(text);
|
|
189
|
+
this.#wakeSend();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---- 观测面 ----
|
|
193
|
+
|
|
194
|
+
is_closed() { return this._stopped; }
|
|
195
|
+
poll_error() { return this._last_poll_error; }
|
|
196
|
+
session_dead() { return this._session_dead; }
|
|
197
|
+
|
|
198
|
+
// ---- 内部:双异步任务 ----
|
|
199
|
+
|
|
200
|
+
async #pollLoop(cursor) {
|
|
201
|
+
let failures = 0;
|
|
202
|
+
while (!this._stopped) {
|
|
203
|
+
let up;
|
|
204
|
+
try {
|
|
205
|
+
up = await this.getUpdates(this.cfg.token, cursor);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
const msg = String(e?.message ?? e);
|
|
208
|
+
// -14 分两档(002「服务侧会话过期」)
|
|
209
|
+
if (is_session_timeout(msg) && cursor !== null) {
|
|
210
|
+
cursor = null;
|
|
211
|
+
const st = this.loadState();
|
|
212
|
+
state.set(st, "buf", null);
|
|
213
|
+
this.saveState(st);
|
|
214
|
+
failures = 0;
|
|
215
|
+
// 停 1 秒再试:服务端持续拒绝时别打成热循环(可被 stop 打断)
|
|
216
|
+
await this.#interruptibleSleep(1000);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (is_session_timeout(msg)) this._session_dead = true;
|
|
220
|
+
failures += 1;
|
|
221
|
+
this._last_poll_error = msg;
|
|
222
|
+
this.#warnOnce("poll", `getupdates 失败:${msg}`);
|
|
223
|
+
// 指数退避,封顶 30s;分小段睡以便及时响应 stop
|
|
224
|
+
await this.#interruptibleSleep(next_wait_ms(failures));
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
failures = 0;
|
|
228
|
+
this._last_poll_error = null;
|
|
229
|
+
// 游标只在内存推进;落盘交给泵的 ack_cursor()
|
|
230
|
+
cursor = up.next_buf ?? null;
|
|
231
|
+
if (up.messages.length > 0) {
|
|
232
|
+
this.#deliver(new Batch(up.messages, up.next_buf));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async #sendLoop() {
|
|
238
|
+
while (!this._stopped) {
|
|
239
|
+
let text;
|
|
240
|
+
try {
|
|
241
|
+
text = await this.#nextSend();
|
|
242
|
+
} catch {
|
|
243
|
+
break; // stop 唤醒
|
|
244
|
+
}
|
|
245
|
+
// 每次发送前重读状态:chat_id / bot_user_id 可能在首条入站后才被发现
|
|
246
|
+
const st = this.loadState();
|
|
247
|
+
// 对端解析:群聊 = chat_id(room id);**私聊 = user_id**(入站
|
|
248
|
+
// from_user_id 随 ack_routing 回写)。曾只认 chat_id —— 而私聊(决策 5:
|
|
249
|
+
// 唯一支持形态)chat_id 恒为 None,所有回执被静默丢弃(真机
|
|
250
|
+
// 「发了没反应」,2026-10-23 修复)。
|
|
251
|
+
const to_user = state.get_str(st, "chat_id") ?? state.get_str(st, "user_id");
|
|
252
|
+
const from_user = state.get_str(st, "bot_user_id");
|
|
253
|
+
if (!to_user || !from_user) {
|
|
254
|
+
// **必须出声**:静默丢弃正是「微信收不到回执」迟迟查不出的原因
|
|
255
|
+
this.#warnOnce(
|
|
256
|
+
"route",
|
|
257
|
+
"出站消息被丢弃:状态盘缺 chat_id/user_id / bot_user_id(首条入站后随 ack_routing 回写)",
|
|
258
|
+
);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const client_id = state.ensure_client_id(st);
|
|
262
|
+
const token = state.get_str(st, "bot_token");
|
|
263
|
+
if (!token) break; // 状态被清(换绑中):sendLoop 退出,重绑后 transport 重建
|
|
264
|
+
try {
|
|
265
|
+
await this.sendText(token, from_user, to_user, text, state.get_str(st, "context_token"), client_id);
|
|
266
|
+
} catch (e) {
|
|
267
|
+
// 发送失败同样不能吞:对用户来说就是「发了没反应」
|
|
268
|
+
this.#warnOnce("send", `sendmessage 失败:${String(e?.message ?? e)}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---- 队列与可中断睡眠 ----
|
|
274
|
+
|
|
275
|
+
#deliver(batch) {
|
|
276
|
+
if (this._waiting) {
|
|
277
|
+
const resolve = this._waiting;
|
|
278
|
+
this._waiting = null;
|
|
279
|
+
resolve(batch);
|
|
280
|
+
} else {
|
|
281
|
+
this._batches.push(batch);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
#wakeRecv() {
|
|
286
|
+
if (this._waiting) {
|
|
287
|
+
const resolve = this._waiting;
|
|
288
|
+
this._waiting = null;
|
|
289
|
+
resolve(null);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
#nextSend() {
|
|
294
|
+
const text = this._sendQueue.shift();
|
|
295
|
+
if (text !== undefined) return Promise.resolve(text);
|
|
296
|
+
if (this._stopped) return Promise.reject(new Error("stopped"));
|
|
297
|
+
return new Promise((resolve, reject) => {
|
|
298
|
+
this._sendWaiting = { resolve, reject };
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
#wakeSend() {
|
|
303
|
+
if (this._sendWaiting && this._sendQueue.length > 0) {
|
|
304
|
+
const { resolve } = this._sendWaiting;
|
|
305
|
+
this._sendWaiting = null;
|
|
306
|
+
resolve(this._sendQueue.shift());
|
|
307
|
+
} else if (this._sendWaiting && this._stopped) {
|
|
308
|
+
const { reject } = this._sendWaiting;
|
|
309
|
+
this._sendWaiting = null;
|
|
310
|
+
reject(new Error("stopped"));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** 分 100ms 小段睡,stop 立即返回(Rust poll_loop 同款)。 */
|
|
315
|
+
async #interruptibleSleep(totalMs) {
|
|
316
|
+
let left = totalMs;
|
|
317
|
+
while (left > 0 && !this._stopped) {
|
|
318
|
+
const step = Math.min(100, left);
|
|
319
|
+
await this.sleep(step);
|
|
320
|
+
left -= step;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
#warnOnce(channel, msg) {
|
|
325
|
+
if (this._warned[channel]) return;
|
|
326
|
+
this._warned[channel] = true;
|
|
327
|
+
console.error(`[wxbridge] ${msg}(此提示只出现一次)`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// parse_updates 再导出:泵侧拿到裸 payload 时复用同一解析(transport 单测用)
|
|
332
|
+
export { parse_updates };
|
|
333
|
+
|
|
334
|
+
/** 起渠道(Transport.spawn 的模块级别名,Rust transport::spawn 对位)。 */
|
|
335
|
+
export const spawn = Transport.spawn.bind(Transport);
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// transport 单测 —— 与 wxprobe src/transport.rs 语义逐条对齐(JS 版)。
|
|
2
|
+
// 跑法:node --test packages/wechat-bridge/pi/
|
|
3
|
+
// 全部用注入 deps(fake getUpdates/sendText/state),零真网络。
|
|
4
|
+
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
spawn, Transport, Batch,
|
|
14
|
+
is_session_timeout, next_wait_ms, classify_poll_error,
|
|
15
|
+
} from "./transport.mjs";
|
|
16
|
+
import { require_token } from "./config.mjs";
|
|
17
|
+
import { parse_updates } from "./poller.mjs";
|
|
18
|
+
import { Wire } from "./wire.mjs";
|
|
19
|
+
import { parse } from "./jsonx.mjs";
|
|
20
|
+
|
|
21
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const fixtureText = readFileSync(join(here, "fixtures", "getupdates-real.json"), "utf8");
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------- 测试基建
|
|
25
|
+
|
|
26
|
+
/** 独立临时状态盘(不碰包内 data/)。 */
|
|
27
|
+
function fakeState(initial = {}) {
|
|
28
|
+
let st = { ...initial };
|
|
29
|
+
return {
|
|
30
|
+
load: () => ({ ...st }),
|
|
31
|
+
save: (next) => { st = { ...next }; },
|
|
32
|
+
get current() { return { ...st }; },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 脚本化 poll 序列:逐拍消费 {payload}(成功)或 {error}(失败)或 {hang}(挂起)。
|
|
37
|
+
* payload 走 parse_updates —— 与真 getUpdates(wire.post_poll + parse_updates)同构。 */
|
|
38
|
+
function scriptedGetUpdates(entries, { seen = [] } = {}) {
|
|
39
|
+
let i = 0;
|
|
40
|
+
return async (token, buf) => {
|
|
41
|
+
seen.push(buf ?? null);
|
|
42
|
+
if (i >= entries.length) {
|
|
43
|
+
// 剧本演完:永久挂起(保持循环活着的自然态)
|
|
44
|
+
return new Promise(() => {});
|
|
45
|
+
}
|
|
46
|
+
const step = entries[i++];
|
|
47
|
+
if (step.hang) return new Promise(() => {});
|
|
48
|
+
if (step.error) throw step.error;
|
|
49
|
+
return parse_updates(parse(JSON.stringify(step.payload)), buf ?? null);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const fakeLock = { release() {} };
|
|
54
|
+
const tick = (n = 1) => new Promise((r) => setTimeout(r, n));
|
|
55
|
+
const noSleep = () => Promise.resolve(); // 退避/1s 罚时不真等
|
|
56
|
+
|
|
57
|
+
async function makeTransport({ stateInit = {}, pollScript, sendText = async () => ({}), seen = [] } = {}) {
|
|
58
|
+
const fake = fakeState(stateInit);
|
|
59
|
+
const t = await spawn(
|
|
60
|
+
{ token: "tk", bot_id: "bot", client_id: "c" },
|
|
61
|
+
{
|
|
62
|
+
lock: fakeLock,
|
|
63
|
+
wire: new Wire(null), // 不落 dump(避免测试在包内建 data/)
|
|
64
|
+
loadState: fake.load,
|
|
65
|
+
saveState: fake.save,
|
|
66
|
+
getUpdates: scriptedGetUpdates(pollScript ?? [{ hang: true }], { seen }),
|
|
67
|
+
sendText,
|
|
68
|
+
sleep: noSleep,
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
return { t, fake };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------- 配置面(Rust require_token 单测同款)
|
|
75
|
+
|
|
76
|
+
test("token_must_be_non_empty", () => {
|
|
77
|
+
assert.throws(() => require_token({ bot_token: "" }), /未扫码/);
|
|
78
|
+
assert.throws(() => require_token({ bot_token: " " }), /未扫码/);
|
|
79
|
+
assert.throws(() => require_token({}), /未扫码/);
|
|
80
|
+
assert.equal(require_token({ bot_token: "tk" }), "tk");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("config_from_state_carries_all_fields", async () => {
|
|
84
|
+
const { config_from_state } = await import("./config.mjs");
|
|
85
|
+
const cfg = config_from_state({
|
|
86
|
+
bot_token: "tk", bot_id: "b1", bot_user_id: "bot@im.bot",
|
|
87
|
+
chat_id: "u@im.wechat", context_token: "ctx", client_id: "c0",
|
|
88
|
+
});
|
|
89
|
+
assert.deepEqual(cfg, {
|
|
90
|
+
token: "tk", bot_id: "b1", bot_user: "bot@im.bot",
|
|
91
|
+
chat_user: "u@im.wechat", context_token: "ctx", client_id: "c0",
|
|
92
|
+
});
|
|
93
|
+
const minimal = config_from_state({ bot_token: "tk" });
|
|
94
|
+
assert.equal(minimal.bot_id, "");
|
|
95
|
+
assert.equal(minimal.chat_user, null);
|
|
96
|
+
assert.ok(minimal.client_id.startsWith("wxbridge-"), "client_id 兜底生成");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------- 错误分类(-14 两档)
|
|
100
|
+
|
|
101
|
+
test("is_session_timeout_matches_both_error_shapes", () => {
|
|
102
|
+
assert.ok(is_session_timeout("getupdates: iLink 业务错误 ret=None errcode=-14 session timeout"));
|
|
103
|
+
assert.ok(is_session_timeout("…errcode=Some(-14) session timeout…"), "Rust 错误串形态");
|
|
104
|
+
assert.ok(is_session_timeout("server said: session timeout"));
|
|
105
|
+
assert.ok(!is_session_timeout("POST /getupdates: network timeout"), "网络超时不是会话过期");
|
|
106
|
+
assert.ok(!is_session_timeout("HTTP 500"));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("classify_poll_error_two_tiers", () => {
|
|
110
|
+
assert.equal(classify_poll_error(true), "cursor_flush", "有游标被拒 = 洗游标自愈");
|
|
111
|
+
assert.equal(classify_poll_error(false), "session_dead", "空游标仍拒 = 服务侧作废");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("next_wait_ms_exponential_capped_30s", () => {
|
|
115
|
+
assert.equal(next_wait_ms(0), 500);
|
|
116
|
+
assert.equal(next_wait_ms(1), 1000);
|
|
117
|
+
assert.equal(next_wait_ms(6), 30_000, "500<<6=32000 → 封顶 30s");
|
|
118
|
+
assert.equal(next_wait_ms(50), 30_000);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------- 游标 at-least-once(端到端)
|
|
122
|
+
|
|
123
|
+
test("cursor_written_only_after_ack", async () => {
|
|
124
|
+
const payload = parse(fixtureText);
|
|
125
|
+
const { t, fake } = await makeTransport({
|
|
126
|
+
stateInit: { bot_token: "tk", buf: null },
|
|
127
|
+
pollScript: [{ payload }],
|
|
128
|
+
});
|
|
129
|
+
const batch = await t.recv();
|
|
130
|
+
assert.equal(batch.messages.length, 1);
|
|
131
|
+
assert.equal(batch.next_buf, payload.get_updates_buf);
|
|
132
|
+
// 收到批但**尚未 ack**:盘上必须还没有新游标(崩溃窗口 = 重复而非丢失)
|
|
133
|
+
assert.notEqual(fake.current.buf, payload.get_updates_buf, "处理完才落盘(§6 坑 2)");
|
|
134
|
+
t.ack(batch);
|
|
135
|
+
assert.equal(fake.current.buf, payload.get_updates_buf, "ack 后落盘");
|
|
136
|
+
t.stop();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("crash_without_ack_repulls_same_batch_at_least_once", async () => {
|
|
140
|
+
const payload = parse(fixtureText);
|
|
141
|
+
// 第一条命:收到批、模拟崩溃(不 ack 即 stop)
|
|
142
|
+
const seen1 = [];
|
|
143
|
+
const { t: t1 } = await makeTransport({
|
|
144
|
+
stateInit: { bot_token: "tk", buf: "OLD_CURSOR" },
|
|
145
|
+
pollScript: [{ payload }],
|
|
146
|
+
seen: seen1,
|
|
147
|
+
});
|
|
148
|
+
await t1.recv();
|
|
149
|
+
t1.stop(); // 崩溃:游标未 ack
|
|
150
|
+
|
|
151
|
+
// 第二条命:从盘上旧游标重拉 —— 同一批必须再来(可能重复、绝不丢失)
|
|
152
|
+
const seen2 = [];
|
|
153
|
+
const { t: t2 } = await makeTransport({
|
|
154
|
+
stateInit: { bot_token: "tk", buf: "OLD_CURSOR" },
|
|
155
|
+
pollScript: [{ payload }],
|
|
156
|
+
seen: seen2,
|
|
157
|
+
});
|
|
158
|
+
const batch2 = await t2.recv();
|
|
159
|
+
assert.equal(batch2.messages.length, 1, "未 ack 的批重启后重收(at-least-once)");
|
|
160
|
+
assert.equal(seen2[0], "OLD_CURSOR", "从上次 ack 的游标重拉");
|
|
161
|
+
t2.ack(batch2);
|
|
162
|
+
t2.stop();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("twenty_start_stop_cycles_cursor_stable", async () => {
|
|
166
|
+
// 与 poller 纯函数版同语义的 transport 端到端版:ack 驱动落盘
|
|
167
|
+
const payload = parse(fixtureText);
|
|
168
|
+
const fake = fakeState({ bot_token: "tk" });
|
|
169
|
+
const seen = [];
|
|
170
|
+
const script = [{ payload }, ...Array.from({ length: 19 }, () => ({ payload: { msgs: [] } }))];
|
|
171
|
+
const t = await spawn(
|
|
172
|
+
{ token: "tk", bot_id: "bot" },
|
|
173
|
+
{
|
|
174
|
+
lock: fakeLock, wire: new Wire(null), loadState: fake.load, saveState: fake.save,
|
|
175
|
+
getUpdates: scriptedGetUpdates(script, { seen }),
|
|
176
|
+
sendText: async () => ({}), sleep: noSleep,
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
const first = await t.recv();
|
|
180
|
+
t.ack(first);
|
|
181
|
+
// 之后 19 拍空闲:不产生批,游标保持
|
|
182
|
+
await tick(5);
|
|
183
|
+
assert.equal(fake.current.buf, payload.get_updates_buf, "空闲拍不改写游标");
|
|
184
|
+
t.stop();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------- 双异步(send 不等 poll)
|
|
188
|
+
|
|
189
|
+
test("send_flows_while_long_poll_hangs", async () => {
|
|
190
|
+
// 状态盘已带路由字段(首条入站 ack_routing 之后的常态)
|
|
191
|
+
const sent = [];
|
|
192
|
+
const { t } = await makeTransport({
|
|
193
|
+
stateInit: {
|
|
194
|
+
bot_token: "tk", bot_user_id: "bot@im.bot", user_id: "u@im.wechat",
|
|
195
|
+
chat_id: null, context_token: "ctx", client_id: "c0",
|
|
196
|
+
}, pollScript: [{ hang: true }], // 长轮询永远挂着
|
|
197
|
+
sendText: async (token, from, to, text, ctx, cid) => {
|
|
198
|
+
sent.push({ token, from, to, text, ctx, cid });
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
await tick();
|
|
202
|
+
t.send("你好");
|
|
203
|
+
await tick();
|
|
204
|
+
assert.equal(sent.length, 1, "长轮询挂起期间 sendMessage 照发(双异步核心语义)");
|
|
205
|
+
assert.equal(sent[0].to, "u@im.wechat", "私聊对端 = user_id(chat_id 缺省回退)");
|
|
206
|
+
assert.equal(sent[0].from, "bot@im.bot");
|
|
207
|
+
assert.equal(sent[0].ctx, "ctx");
|
|
208
|
+
assert.equal(sent[0].cid, "c0", "transport 只传稳定实例 id;每条唯一拼装在 poller.send_text 内(Rust 同款分层)");
|
|
209
|
+
t.stop();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("send_text_builds_unique_client_id_and_crlf_payload", async () => {
|
|
213
|
+
// 真 send_text:每条唯一 client_id(实例id-毫秒-序号)+ strip_ansi + CRLF
|
|
214
|
+
const sent = [];
|
|
215
|
+
let seq = 0;
|
|
216
|
+
const fakeWire = { post: async (tag, path, token, body) => { sent.push({ tag, path, token, body }); return {}; } };
|
|
217
|
+
const { send_text } = await import("./poller.mjs");
|
|
218
|
+
await send_text(fakeWire, "tk", "bot@im.bot", "u@im.wechat", "第一行\n\x1b[38;5;5m带色码", "ctx", "c0");
|
|
219
|
+
await send_text(fakeWire, "tk", "bot@im.bot", "u@im.wechat", "第二条", null, "c0");
|
|
220
|
+
assert.equal(sent.length, 2);
|
|
221
|
+
const { body: b1 } = sent[0];
|
|
222
|
+
assert.equal(sent[0].tag, "sendmessage");
|
|
223
|
+
assert.equal(sent[0].path, "/sendmessage");
|
|
224
|
+
assert.equal(sent[0].token, "tk");
|
|
225
|
+
assert.equal(b1.msg.from_user_id, "bot@im.bot");
|
|
226
|
+
assert.equal(b1.msg.to_user_id, "u@im.wechat");
|
|
227
|
+
assert.equal(b1.msg.message_type, 2);
|
|
228
|
+
assert.equal(b1.msg.item_list[0].text_item.text, "第一行\r\n带色码", "LF→CRLF + ANSI 色码剥除");
|
|
229
|
+
assert.equal(b1.msg.context_token, "ctx");
|
|
230
|
+
assert.match(b1.msg.client_id, /^c0-\d+-\d+$/, "实例id-毫秒-序号,每条唯一");
|
|
231
|
+
const { body: b2 } = sent[1];
|
|
232
|
+
assert.ok(!("context_token" in b2.msg), "空 context_token 不进参(Rust if let Some(filter) 同款)");
|
|
233
|
+
assert.notEqual(b1.msg.client_id, b2.msg.client_id, "两条消息 client_id 必不相同");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("send_dropped_with_loud_warning_when_no_peer", async () => {
|
|
237
|
+
const sent = [];
|
|
238
|
+
const errors = [];
|
|
239
|
+
const origErr = console.error;
|
|
240
|
+
console.error = (...a) => errors.push(a.join(" "));
|
|
241
|
+
try {
|
|
242
|
+
const { t } = await makeTransport({
|
|
243
|
+
stateInit: { bot_token: "tk" }, // 无路由字段
|
|
244
|
+
sendText: async (...a) => sent.push(a),
|
|
245
|
+
});
|
|
246
|
+
await tick();
|
|
247
|
+
t.send("没人收");
|
|
248
|
+
await tick();
|
|
249
|
+
assert.equal(sent.length, 0, "缺对端必须丢弃");
|
|
250
|
+
assert.ok(errors.some((s) => s.includes("出站消息被丢弃")), "静默丢弃 = 真机事故根源,必须出声");
|
|
251
|
+
t.stop();
|
|
252
|
+
} finally {
|
|
253
|
+
console.error = origErr;
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("ack_routing_writes_route_fields_before_processing", async () => {
|
|
258
|
+
const payload = parse(fixtureText);
|
|
259
|
+
const { t, fake } = await makeTransport({ pollScript: [{ payload }] });
|
|
260
|
+
const batch = await t.recv();
|
|
261
|
+
t.ack_routing(batch);
|
|
262
|
+
const st = fake.current;
|
|
263
|
+
assert.equal(st.chat_id, undefined, "私聊无 chat_id 不写");
|
|
264
|
+
assert.equal(st.user_id, "user_sandbox@im.wechat");
|
|
265
|
+
assert.equal(st.bot_user_id, "bot_sandbox@im.bot");
|
|
266
|
+
assert.equal(st.context_token, "SANITIZED_CONTEXT_TOKEN");
|
|
267
|
+
assert.equal(st.buf, undefined, "ack_routing 不碰游标(§6 坑 2 分工)");
|
|
268
|
+
t.stop();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------- -14 两档(端到端)
|
|
272
|
+
|
|
273
|
+
test("session_timeout_with_cursor_flushes_cursor_and_self_heals", async () => {
|
|
274
|
+
const seen = [];
|
|
275
|
+
const { t, fake } = await makeTransport({
|
|
276
|
+
stateInit: { bot_token: "tk", buf: "STALE_BOT_CURSOR" },
|
|
277
|
+
pollScript: [
|
|
278
|
+
{ error: new Error("iLink 业务错误 ret=None errcode=-14 session timeout") },
|
|
279
|
+
{ payload: { msgs: [] } }, // 洗完游标后同 token 重新接洽成功
|
|
280
|
+
],
|
|
281
|
+
seen,
|
|
282
|
+
});
|
|
283
|
+
await tick(5);
|
|
284
|
+
assert.equal(fake.current.buf, null || undefined, "旧游标被洗掉");
|
|
285
|
+
assert.equal(seen[0], "STALE_BOT_CURSOR", "第一拍带着旧游标被拒");
|
|
286
|
+
assert.equal(seen[1], null, "第二拍空游标重新接洽(自愈)");
|
|
287
|
+
assert.equal(t.poll_error(), null, "自愈成功即清错");
|
|
288
|
+
assert.equal(t.session_dead(), false, "一档不是会话作废");
|
|
289
|
+
t.stop();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("session_timeout_without_cursor_marks_session_dead", async () => {
|
|
293
|
+
let polls = 0;
|
|
294
|
+
const { t } = await makeTransport({
|
|
295
|
+
stateInit: { bot_token: "tk", buf: null },
|
|
296
|
+
pollScript: Array.from({ length: 30 }, () => ({
|
|
297
|
+
error: new Error("errcode=-14 session timeout"),
|
|
298
|
+
count: ++polls,
|
|
299
|
+
})),
|
|
300
|
+
});
|
|
301
|
+
await tick(10);
|
|
302
|
+
assert.equal(t.session_dead(), true, "空游标仍被拒 = 服务侧会话作废(002:回绑定页)");
|
|
303
|
+
assert.ok(polls > 2, "轮询保持低频重试,不退出(§6 坑 4)");
|
|
304
|
+
assert.ok(t.poll_error()?.includes("session timeout"));
|
|
305
|
+
t.stop();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------- 生命周期(幂等收)
|
|
309
|
+
|
|
310
|
+
test("stop_is_idempotent_and_wakes_recv", async () => {
|
|
311
|
+
const { t } = await makeTransport({});
|
|
312
|
+
const p = t.recv(); // 挂起等待
|
|
313
|
+
t.stop();
|
|
314
|
+
assert.equal(await p, null, "stop 唤醒 recv 返回 null(泵自然收尾)");
|
|
315
|
+
t.stop(); // 二次调用 no-op
|
|
316
|
+
assert.equal(t.is_closed(), true);
|
|
317
|
+
assert.throws(() => t.send("x"), /send 线程已退出/);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("recv_returns_batch_before_stop_null_after", async () => {
|
|
321
|
+
const payload = parse(fixtureText);
|
|
322
|
+
const { t } = await makeTransport({ pollScript: [{ payload }] });
|
|
323
|
+
const b = await t.recv();
|
|
324
|
+
assert.ok(b instanceof Batch);
|
|
325
|
+
t.stop();
|
|
326
|
+
assert.equal(await t.recv(), null);
|
|
327
|
+
});
|