@pi-flash-plugins/pf-wxbot 0.1.3 → 0.2.0
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/package.json +2 -2
- package/pf-plugin.toml +1 -1
- package/pi/index.mjs +33 -58
- package/pi/index.test.mjs +101 -54
- package/pi/lock.mjs +40 -6
- package/pi/lock.test.mjs +125 -0
- package/pi/register.mjs +15 -6
- package/pi/register.test.mjs +12 -3
- package/pi/state.mjs +30 -3
- package/pi/state.test.mjs +22 -0
- package/plugin.wasm +0 -0
package/package.json
CHANGED
package/pf-plugin.toml
CHANGED
package/pi/index.mjs
CHANGED
|
@@ -39,18 +39,6 @@ function default_session_exists(key) {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/** 绑定落地的会话文件键:ownFile → 宿主给的 qr_owner 键(pi 首条消息前
|
|
43
|
-
* getSessionFile() 是 undefined,只写 uuid 会让壳的 icon/badge 永远匹配不上)。 */
|
|
44
|
-
export function resolve_session_file(ownFile, ownerKey) {
|
|
45
|
-
return ownFile ?? ownerKey ?? null;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** 本进程出生时刻(模块加载即定格,早于 session_start 数百毫秒)。卸载
|
|
49
|
-
* 水位线:壳写的 unload_ts > BOOT_TS = 本进程服役期内被停用。与盘上快照
|
|
50
|
-
* 无关,免疫「快照晚于壳写」竞态与整文件回写弄脏序号两类坑。
|
|
51
|
-
*/
|
|
52
|
-
const BOOT_TS = Date.now();
|
|
53
|
-
|
|
54
42
|
const TICK_MS = 200; // 泵节拍(mod.rs 200ms 采样同款)
|
|
55
43
|
const SESSION_CHECK_TICKS = 150; // 会话存在性校验周期(150 拍 ≈ 30s;可注 deps.sessionCheckTicks)
|
|
56
44
|
// 运行中心跳节拍默认 30s(2026-11-01 buglist#1 定案,用户拍板 30s):agent
|
|
@@ -79,10 +67,6 @@ export function create_entry(deps = {}) {
|
|
|
79
67
|
const now = deps.now ?? (() => Date.now());
|
|
80
68
|
const S = deps.state ?? state; // state 模块面(测试注内存盘)
|
|
81
69
|
const sessionExists = deps.sessionExists ?? null; // (sessionKey) => Promise<bool|null>;null = 通道未接(PF 轨)
|
|
82
|
-
const ownKeyOf =
|
|
83
|
-
deps.ownKeyOf ??
|
|
84
|
-
((sessionCtx) => sessionCtx?.sessionManager?.getSessionId?.() ?? null);
|
|
85
|
-
|
|
86
70
|
/** 原子写状态键(读-改-写当场盘,最小面复用 set/save)。 */
|
|
87
71
|
function set_keys(keys) {
|
|
88
72
|
const st = S.load();
|
|
@@ -98,7 +82,6 @@ export function create_entry(deps = {}) {
|
|
|
98
82
|
const snap = new Snapshot({ stat: deps.stat, load: S.load });
|
|
99
83
|
let ctx = null; // session 域上下文(session_start 绑定)
|
|
100
84
|
let ownKey = null; // 本进程会话身份(门控比对源:getSessionId)
|
|
101
|
-
let ownFile = null; // 会话文件路径(门控双口径之二:getSessionFile)
|
|
102
85
|
let wasActive = null; // 上一拍门控结果(null = 未判定;失绑清旗的依据)
|
|
103
86
|
let started_once = false; // 本进程成功起过渠道(「已连接」③判据:重载后首连)
|
|
104
87
|
let announce_next = false; // 待宣告「已连接」:①换绑落地 / ②用户 off→on(to_on 时冲)
|
|
@@ -332,7 +315,7 @@ export function create_entry(deps = {}) {
|
|
|
332
315
|
set_keys({
|
|
333
316
|
// 单变量模型:绑定 id = **PF 会话键**(点 icon 时宿主给的那把),
|
|
334
317
|
// 不是 pi 的 uuid —— 壳/列表/钩子全用同一把键比对
|
|
335
|
-
session_id: ownerKey ??
|
|
318
|
+
session_id: ownerKey ?? ownKey ?? null,
|
|
336
319
|
session_file: null, // 单变量模型:不再存会话文件(壳只认 session_id)
|
|
337
320
|
qr_pending: null,
|
|
338
321
|
qr_request: null,
|
|
@@ -394,42 +377,44 @@ export function create_entry(deps = {}) {
|
|
|
394
377
|
const pendingKey = S.get_str(st, "qr_pending");
|
|
395
378
|
const enabled = S.get_str(st, "enabled");
|
|
396
379
|
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
//
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const uts = Number(S.get_str(st, "unload_ts") ?? 0);
|
|
406
|
-
if (!unloaded && uts > BOOT_TS) {
|
|
380
|
+
// 运行许可(005 §③,宿主协议文件 pf-runtime.json 唯一写者 = 宿主):
|
|
381
|
+
// off → 停渠道 + 宣告「已失联」+ 取消出码 + **待机(进程保留,不自杀)**;
|
|
382
|
+
// on → 待机复活(回 Pending,门控/许可恢复后服务自起),停用→启用循环
|
|
383
|
+
// 无需重启 pi。取代旧 unload_ts 时间戳水位——壳每次 reload 都写时间戳,
|
|
384
|
+
// 出生早于写入的活 ext 一律被冤杀(真机 2026-11-03:更新重载的 ext 被
|
|
385
|
+
// 随后的 unload 水位打死,出码/收发全断)。
|
|
386
|
+
const permitted = S.runtime()?.pf_enabled !== false;
|
|
387
|
+
if (!permitted && !unloaded) {
|
|
407
388
|
unloaded = true;
|
|
408
389
|
if (qrCancel) {
|
|
409
390
|
qrCancel();
|
|
410
391
|
qrCancel = null;
|
|
411
392
|
}
|
|
412
|
-
stop_service(true, "
|
|
393
|
+
stop_service(true, "host-disable");
|
|
394
|
+
note("host-disable idle");
|
|
395
|
+
} else if (permitted && unloaded) {
|
|
396
|
+
unloaded = false;
|
|
397
|
+
if (svc.variant === "stopped" || svc.variant === "off") svc.to_pending();
|
|
398
|
+
announce_next = true; // ⚡②许可恢复:重连后宣告「已连接」
|
|
399
|
+
note("host-enable resume");
|
|
413
400
|
}
|
|
414
401
|
if (unloaded) return;
|
|
415
402
|
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
//
|
|
403
|
+
// 门控(005 §② 单键):绑定键/出码归属都与宿主注入的 PF_SESSION_KEY
|
|
404
|
+
// 规范键比对——uuid 漂移、getSessionFile 未就绪、argv 形态差异全成历史。
|
|
405
|
+
// 门控结果落镜像:
|
|
419
406
|
// active → bound_current=1(仅标记身份,壳不看它)
|
|
420
407
|
// 失绑 → 仅当**自己之前是激活方**才清 bound_current(多会话 ext
|
|
421
408
|
// 共享一份镜像,没绑定过的不得清别人的 1 —— 防抖动)
|
|
422
|
-
// 单变量模型(schema/pf-hooks.json):只认 PF 给的两把 id——page_id(当前页)
|
|
423
|
-
// 与 session_id(已绑定)。ownKey/ownFile 仅用于“我是不是被点名的那页”。
|
|
424
409
|
const sameKey = (a, b) => {
|
|
425
410
|
if (!a || !b) return false;
|
|
426
411
|
const n = (x) => String(x).split(String.fromCharCode(92)).join("/").toLowerCase();
|
|
427
412
|
return n(a) === n(b);
|
|
428
413
|
};
|
|
429
|
-
const active = sameKey(boundKey, ownKey)
|
|
430
|
-
// 出码归属(002·绑定2):壳点 icon 写
|
|
414
|
+
const active = sameKey(boundKey, ownKey);
|
|
415
|
+
// 出码归属(002·绑定2):壳点 icon 写 qr_pending=会话键,**只**让该会话的
|
|
431
416
|
// ext 出码。出码 ≠ 绑定:本会话不因此变 bound_current,灯不亮、徽标不打。
|
|
432
|
-
const asked = sameKey(pendingKey, ownKey)
|
|
417
|
+
const asked = sameKey(pendingKey, ownKey);
|
|
433
418
|
if (active !== wasActive || (active && snap.bound_current !== "1")) {
|
|
434
419
|
if (active) {
|
|
435
420
|
// 只标「我是当前绑定方」;session_file 只由扫码成功写入。
|
|
@@ -563,26 +548,17 @@ export function create_entry(deps = {}) {
|
|
|
563
548
|
pi.on("session_start", (event, sessionCtx) => {
|
|
564
549
|
ctx = sessionCtx;
|
|
565
550
|
shutdown = false;
|
|
566
|
-
//
|
|
567
|
-
|
|
568
|
-
//
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
// 自身身份(004 §7-1):pi ReadonlySessionManager.getSessionId()
|
|
578
|
-
ownKey = ownKeyOf(sessionCtx);
|
|
579
|
-
ownFile = (() => {
|
|
580
|
-
try {
|
|
581
|
-
return sessionCtx?.sessionManager?.getSessionFile?.() ?? null;
|
|
582
|
-
} catch {
|
|
583
|
-
return null;
|
|
584
|
-
}
|
|
585
|
-
})();
|
|
551
|
+
// 自身身份(005 §②):**宿主注入**(PF_SESSION_KEY env,与挂载台账键 /
|
|
552
|
+
// 命令 env session.key 规范键同源)。不再用 uuid/getSessionFile/argv 猜
|
|
553
|
+
// ——首条消息前 undefined + uuid 随重启漂移,门控必失配(真机 2026-11-03)。
|
|
554
|
+
ownKey = deps.env?.PF_SESSION_KEY ?? process.env.PF_SESSION_KEY ?? null;
|
|
555
|
+
// 启动自报家门(005 现场诊断):身份 + 运行许可 + 出码归属,落 svc_log。
|
|
556
|
+
// 身份/许可/数据目录任一错位时,这里就是第一现场(真机 2026-11-03 教训)。
|
|
557
|
+
note(
|
|
558
|
+
`boot key=${ownKey ?? "null"} pf_enabled=${
|
|
559
|
+
S.runtime()?.pf_enabled ?? "null"
|
|
560
|
+
} pending=${S.get_str(S.load(), "qr_pending") ?? "null"}`,
|
|
561
|
+
);
|
|
586
562
|
line.key = ownKey;
|
|
587
563
|
line.name = sessionCtx?.sessionManager?.getSessionName?.() ?? ownKey;
|
|
588
564
|
snap.refresh(true);
|
|
@@ -638,7 +614,6 @@ export function create_entry(deps = {}) {
|
|
|
638
614
|
hook.flush_notice(); // 无下一拍:当场冲(升沿无处入队自灭,降沿直发)
|
|
639
615
|
ctx = null;
|
|
640
616
|
ownKey = null;
|
|
641
|
-
ownFile = null;
|
|
642
617
|
});
|
|
643
618
|
};
|
|
644
619
|
}
|
package/pi/index.test.mjs
CHANGED
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
import { test } from "node:test";
|
|
6
6
|
import assert from "node:assert/strict";
|
|
7
7
|
|
|
8
|
-
import { create_entry
|
|
8
|
+
import { create_entry } from "./index.mjs";
|
|
9
9
|
import { WX_LINK_ON, WX_LINK_OFF } from "./hook.mjs";
|
|
10
10
|
|
|
11
11
|
// ---------------------------------------------------------------- 假件
|
|
12
12
|
|
|
13
|
-
/** 内存状态盘(state.mjs 同面:load/save/get_str/set/base_dir/dump_dir)。
|
|
14
|
-
|
|
13
|
+
/** 内存状态盘(state.mjs 同面:load/save/get_str/set/base_dir/dump_dir/runtime)。
|
|
14
|
+
* `runtimeState`:宿主协议文件 pf-runtime.json 的内存替身(null = 缺文件 = 许可)。 */
|
|
15
|
+
function mem_state(initial = {}, runtimeState = null) {
|
|
15
16
|
let store = { ...initial };
|
|
17
|
+
let rt = runtimeState;
|
|
16
18
|
return {
|
|
17
19
|
store: () => store,
|
|
18
20
|
load: () => ({ ...store }),
|
|
@@ -31,6 +33,10 @@ function mem_state(initial = {}) {
|
|
|
31
33
|
},
|
|
32
34
|
base_dir: () => "<mem>/data",
|
|
33
35
|
dump_dir: () => "<mem>/dump",
|
|
36
|
+
runtime: () => rt,
|
|
37
|
+
set_runtime: (next) => {
|
|
38
|
+
rt = next;
|
|
39
|
+
},
|
|
34
40
|
};
|
|
35
41
|
}
|
|
36
42
|
|
|
@@ -133,9 +139,21 @@ function fake_wire_class(script = () => ({ status: "pending" })) {
|
|
|
133
139
|
};
|
|
134
140
|
}
|
|
135
141
|
|
|
136
|
-
/** 标准装配:ownKey =
|
|
137
|
-
|
|
138
|
-
|
|
142
|
+
/** 标准装配:ownKey = 宿主注入的 PF_SESSION_KEY(005 §②);镜像 initial
|
|
143
|
+
* 与 pf-runtime 协议替身由用例给。 */
|
|
144
|
+
function mk_entry({
|
|
145
|
+
ownKey = "sA",
|
|
146
|
+
state: st = {},
|
|
147
|
+
runtime: rtState = null,
|
|
148
|
+
ctx,
|
|
149
|
+
wire,
|
|
150
|
+
sessionExists,
|
|
151
|
+
deps: extra = {},
|
|
152
|
+
} = {}) {
|
|
153
|
+
const S = mem_state(
|
|
154
|
+
{ bot_token: "T1", bot_id: "bot@im", bot_user_id: "bot@im", ...st },
|
|
155
|
+
rtState,
|
|
156
|
+
);
|
|
139
157
|
const { cls, spawned } = fake_transport_class();
|
|
140
158
|
const f = fake_pi(ctx ? ctx_stub({ ownKey, ...ctx }) : ctx_stub({ ownKey }));
|
|
141
159
|
const directs = []; // 降沿直发记录器(生产 = hook.mjs 真直发;测试绝不碰真盘/真网)
|
|
@@ -144,6 +162,7 @@ function mk_entry({ ownKey = "sA", state: st = {}, ctx, wire, sessionExists, dep
|
|
|
144
162
|
Transport: cls,
|
|
145
163
|
Wire: wire ?? fake_wire_class(),
|
|
146
164
|
sessionExists,
|
|
165
|
+
env: { PF_SESSION_KEY: ownKey }, // 005 §② 身份注入(生产 = spawn env)
|
|
147
166
|
sessionCheckTicks: 2, // 测试用快周期
|
|
148
167
|
sleep: () => new Promise(() => {}), // QR 轮询挂起(不堆计时器)
|
|
149
168
|
tickMs: TICK,
|
|
@@ -171,8 +190,8 @@ test("factory_registers_handlers_and_starts_nothing", () => {
|
|
|
171
190
|
// ---------------------------------------------------------------- 门控(004 §5.2)
|
|
172
191
|
|
|
173
192
|
/** 小工具:起一个 entry 并 fire session_start,返回句柄。 */
|
|
174
|
-
async function start({ ownKey, state: st, ctx, wire, sessionExists, deps } = {}) {
|
|
175
|
-
const h = mk_entry({ ownKey, state: st, ctx, wire, sessionExists, deps });
|
|
193
|
+
async function start({ ownKey, state: st, runtime: rtState, ctx, wire, sessionExists, deps } = {}) {
|
|
194
|
+
const h = mk_entry({ ownKey, state: st, runtime: rtState, ctx, wire, sessionExists, deps });
|
|
176
195
|
await h.fire("session_start", {}, ctx ? ctx_stub({ ownKey, ...ctx }) : ctx_stub({ ownKey }));
|
|
177
196
|
return h;
|
|
178
197
|
}
|
|
@@ -474,6 +493,24 @@ test("session_dead_marks_expired_and_starts_qr_flow", async () => {
|
|
|
474
493
|
h.fire("session_shutdown");
|
|
475
494
|
});
|
|
476
495
|
|
|
496
|
+
test("qr_main_chain —— 壳点 bind 写请求 → ext 消费出码(005 T1 主链路锁)", async () => {
|
|
497
|
+
// 真机 2026-11-03 全部故障的共同受害者就是这条链:壳与 ext 状态一旦劈叉
|
|
498
|
+
// (数据目录/身份/水位任一错位),弹窗照开而码永远不出,此前 158 个单测
|
|
499
|
+
// 全部测不到。锁:未绑定会话点 icon → 壳写 qr_request/qr_pending → ext
|
|
500
|
+
// 消费 → runQrFlow begin → qrcode_grid 落镜像(弹窗有码可画)。
|
|
501
|
+
const KEY = "C:/s/a.jsonl";
|
|
502
|
+
const h = await start({ ownKey: KEY, state: {} }); // 未绑定
|
|
503
|
+
await settle();
|
|
504
|
+
assert.equal(h.spawned.length, 0, "未绑定不起渠道");
|
|
505
|
+
h.S.save({ ...h.S.store(), qr_request: "1", qr_pending: KEY }); // 壳 bind 命令的镜像写
|
|
506
|
+
await wait_for(() => !!h.S.store().qrcode_grid, 3000);
|
|
507
|
+
assert.ok(h.S.store().qrcode_grid, "ext 消费出码请求:QR 网格落镜像");
|
|
508
|
+
assert.equal(h.S.store().qr_request, undefined, "qr_request 已消费清除");
|
|
509
|
+
assert.equal(h.S.store().qr_pending, KEY, "qr_pending 保留(出码即持有归属,扫码成功才清)");
|
|
510
|
+
assert.equal(h.spawned.length, 0, "出码 ≠ 起渠道(点名未绑定态只跑出码流)");
|
|
511
|
+
h.fire("session_shutdown");
|
|
512
|
+
});
|
|
513
|
+
|
|
477
514
|
test("token_cleared_under_running_transport_stops_zombie", async () => {
|
|
478
515
|
const h = await start({ ownKey: "sA", state: { session_id: "sA" } });
|
|
479
516
|
await settle();
|
|
@@ -529,14 +566,6 @@ test("shutdown_stops_transport_idempotent", async () => {
|
|
|
529
566
|
await h.fire("session_shutdown"); // 幂等
|
|
530
567
|
assert.equal(tr.stopped, true, "shutdown 停渠道(不保活:进程没了服务停)");
|
|
531
568
|
});
|
|
532
|
-
test("resolve_session_file —— pi 首条消息前 getSessionFile 未就绪时回落到宿主键", () => {
|
|
533
|
-
// 真机 2026-10-28:切到新会话(还没发过消息)扫码成功,session_file 写 null
|
|
534
|
-
// → 壳按会话文件核对,icon 永远不亮(表现为“第一次切换绑不上,第二次才行”)
|
|
535
|
-
assert.equal(resolve_session_file(null, "C:/s/owner.jsonl"), "C:/s/owner.jsonl");
|
|
536
|
-
assert.equal(resolve_session_file("C:/s/own.jsonl", "C:/s/owner.jsonl"), "C:/s/own.jsonl", "ownFile 优先");
|
|
537
|
-
assert.equal(resolve_session_file(null, null), null);
|
|
538
|
-
});
|
|
539
|
-
|
|
540
569
|
test("switch 关再开必须能恢复通信(Stopped 终态 → 回 Pending)", async () => {
|
|
541
570
|
// 真机 2026-10-28:off 走 stop_service(true) = Stopped,wants_start 恒 false,
|
|
542
571
|
// 打开回来后渠道永远不起 → 消息不回。这里盯状态机的可达性。
|
|
@@ -625,61 +654,79 @@ test("通知① 换绑(on_rebind)→ 停机静默 + 新渠道宣告已连接
|
|
|
625
654
|
h.fire("session_shutdown");
|
|
626
655
|
});
|
|
627
656
|
|
|
628
|
-
test("
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
|
|
657
|
+
test("005 T3 宿主收许可(pf-runtime off)→ 停服 + 失联直发 + 待机;on → 原地复活", async () => {
|
|
658
|
+
// 005 §③:宿主写 pf-runtime.json(唯一写者),ext 泵见 off = 被停用 →
|
|
659
|
+
// 关停 + 宣告失联 + 待机(进程保留,**不自杀**);on = 许可恢复 → 原地
|
|
660
|
+
// 复活(回 Pending,服务自起),停用→启用循环无需重启 pi。取代旧
|
|
661
|
+
// unload_ts 时间戳水位——它在 reload 循环里冤杀出生较早的活 ext
|
|
662
|
+
//(真机 2026-11-03:更新重载的 ext 被随后的 unload 水位打死)。
|
|
663
|
+
const h = await start({
|
|
664
|
+
ownKey: "sA",
|
|
665
|
+
state: { session_id: "sA", chat_id: "c@im" },
|
|
666
|
+
});
|
|
634
667
|
await wait_for(() => (h.spawned[0]?.sends ?? []).includes(WX_LINK_ON));
|
|
635
|
-
h.S.
|
|
668
|
+
h.S.set_runtime({ pf_enabled: false, pf_seq: 2, pf_reason: "disable" }); // 宿主 evict
|
|
636
669
|
await wait_for(() => h.directs.includes(WX_LINK_OFF));
|
|
637
670
|
assert.equal(h.spawned[0].stopped, true, "锁当场释放");
|
|
638
|
-
await settle();
|
|
639
|
-
h.S.save({ ...h.S.store(), enabled: undefined }); // 即便开关再变也不复活
|
|
640
|
-
await settle();
|
|
641
|
-
assert.equal(h.spawned.length, 1, "unloaded 终态:本进程不再起渠道");
|
|
642
671
|
const slog = h.S.store().svc_log ?? [];
|
|
643
|
-
assert.ok(slog.some((l) => l.includes("stop:
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
await
|
|
647
|
-
assert.equal(h.spawned.length,
|
|
672
|
+
assert.ok(slog.some((l) => l.includes("stop:host-disable user=true was_on=true")), "停机原因有痕");
|
|
673
|
+
assert.ok(slog.some((l) => l.includes("host-disable idle")), "待机有痕(非终态)");
|
|
674
|
+
h.S.set_runtime({ pf_enabled: true, pf_seq: 3, pf_reason: "enable" }); // 宿主 attach
|
|
675
|
+
await wait_for(() => (h.spawned[1]?.sends ?? []).includes(WX_LINK_ON), 1500);
|
|
676
|
+
assert.equal(h.spawned.length, 2, "许可恢复:原地复活新渠道(进程未重启)");
|
|
677
|
+
const slog2 = h.S.store().svc_log ?? [];
|
|
678
|
+
assert.ok(slog2.some((l) => l.includes("host-enable resume")), "复活有痕");
|
|
648
679
|
h.fire("session_shutdown");
|
|
649
680
|
});
|
|
650
681
|
|
|
651
|
-
test("
|
|
682
|
+
test("005 T3 boot 期许可已收(宿主先于 ext 出生写 off)→ 隔离不起渠道;on → 复活", async () => {
|
|
683
|
+
// tc7 场景的新协议形态:attach 重启竞态下宿主在 ext 出生前已写 off——
|
|
684
|
+
// 渠道绝不起;许可恢复后原地复活(水位时代的「终态不复活」已成历史)。
|
|
652
685
|
const h = await start({
|
|
653
686
|
ownKey: "sA",
|
|
654
|
-
|
|
655
|
-
|
|
687
|
+
state: { session_id: "sA", chat_id: "c@im" },
|
|
688
|
+
runtime: { pf_enabled: false, pf_seq: 1, pf_reason: "disable" },
|
|
656
689
|
});
|
|
657
690
|
await settle();
|
|
658
691
|
await settle();
|
|
659
|
-
assert.equal(h.spawned.length,
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
692
|
+
assert.equal(h.spawned.length, 0, "boot 期已停用:渠道绝不起");
|
|
693
|
+
const slog = h.S.store().svc_log ?? [];
|
|
694
|
+
assert.ok(slog.some((l) => l.includes("host-disable idle")), "待机有痕");
|
|
695
|
+
h.S.set_runtime({ pf_enabled: true, pf_seq: 2, pf_reason: "enable" });
|
|
696
|
+
await wait_for(() => h.spawned.length === 1, 1500);
|
|
697
|
+
assert.equal(h.spawned.length, 1, "许可恢复:原地复活");
|
|
663
698
|
h.fire("session_shutdown");
|
|
664
699
|
});
|
|
665
700
|
|
|
666
|
-
test("
|
|
667
|
-
// tc7 实测的 attach 重启竞态:壳在 ext 出生与 session_start 之间写入标记,
|
|
668
|
-
// 旧语义(快照序号当基线)会照抄新值、永不触发,服务反而在停用后起来。
|
|
669
|
-
// 新语义:出生后落盘的标记在 session_start 当场置 unloaded,渠道不起。
|
|
701
|
+
test("005 T3 缺协议文件 = 许可(兜底安全,旧 PF 兼容)", async () => {
|
|
670
702
|
const h = await start({
|
|
671
703
|
ownKey: "sA",
|
|
672
|
-
state: {
|
|
673
|
-
|
|
674
|
-
chat_id: "c@im",
|
|
675
|
-
unload_seq: "1",
|
|
676
|
-
unload_ts: String(Date.now() + 50), // 出生后才会出现的标记(未来 50ms)
|
|
677
|
-
},
|
|
704
|
+
state: { session_id: "sA", chat_id: "c@im" },
|
|
705
|
+
runtime: null, // 缺文件
|
|
678
706
|
});
|
|
679
|
-
await
|
|
680
|
-
|
|
681
|
-
assert.equal(h.spawned.length, 0, "boot 期已停用:渠道绝不起");
|
|
682
|
-
const slog = h.S.store().svc_log ?? [];
|
|
683
|
-
assert.ok(slog.some((l) => l.includes("boot-after-unload")), "自我隔离有痕");
|
|
707
|
+
await wait_for(() => (h.spawned[0]?.sends ?? []).includes(WX_LINK_ON));
|
|
708
|
+
assert.deepEqual(h.directs, [], "无 off 不报失联");
|
|
684
709
|
h.fire("session_shutdown");
|
|
685
710
|
});
|
|
711
|
+
|
|
712
|
+
test("005 T2 进程重启模拟:新 ext(env 身份)+ 同镜像 → 服务自恢复", async () => {
|
|
713
|
+
// 真机 2026-11-03 全部故障的回归锁:停用→启用/更新链重启 pi 后,新 ext
|
|
714
|
+
// 进程凭宿主注入的 PF_SESSION_KEY 认回同一会话 → 门控命中 → 服务自起,
|
|
715
|
+
// 无需用户先打字预热(旧身份三钥匙在恢复进程里全落空)。
|
|
716
|
+
const KEY = "C:/s/a.jsonl";
|
|
717
|
+
const first = await start({
|
|
718
|
+
ownKey: KEY,
|
|
719
|
+
state: { session_id: KEY, chat_id: "c@im" },
|
|
720
|
+
});
|
|
721
|
+
await wait_for(() => (first.spawned[0]?.sends ?? []).includes(WX_LINK_ON));
|
|
722
|
+
first.fire("session_shutdown"); // 旧进程消亡(镜像留在「盘上」)
|
|
723
|
+
// 新进程:全新装配,仅 env 身份 + 盘上镜像(上一轮 S 的 store)
|
|
724
|
+
const second = await start({
|
|
725
|
+
ownKey: KEY,
|
|
726
|
+
state: { ...first.S.store(), bot_token: "T1", bot_id: "bot@im", bot_user_id: "bot@im" },
|
|
727
|
+
});
|
|
728
|
+
await wait_for(() => (second.spawned[0]?.sends ?? []).includes(WX_LINK_ON), 1500);
|
|
729
|
+
assert.equal(second.spawned.length, 1, "新 ext 门控命中:服务自恢复");
|
|
730
|
+
assert.equal(second.S.store().bound_current, "1", "激活方标记落回");
|
|
731
|
+
second.fire("session_shutdown");
|
|
732
|
+
});
|
package/pi/lock.mjs
CHANGED
|
@@ -8,9 +8,15 @@
|
|
|
8
8
|
// - 心跳每 HEARTBEAT_MS 刷一次 `lease-<nonce>` 的 mtime;
|
|
9
9
|
// - 抢占时只有「租约新鲜」才让路,否则删掉陈旧锁目录重试。
|
|
10
10
|
//
|
|
11
|
-
// 与 ZCode 的唯一差异(wxprobe
|
|
11
|
+
// 与 ZCode 的唯一差异(wxprobe 同款):以**租约新鲜度**为主判据——心跳 10s
|
|
12
12
|
// 刷一次 mtime,租约 30s 未刷在本机语义上等价于持有者已死。
|
|
13
13
|
//
|
|
14
|
+
// **同机快路**(2026-11-03 补):owner.json 带 `host`,同主机且 pid 已不存在 →
|
|
15
|
+
// 当场接管,不等 30s 租约过期。真机:PF 退出时 pi 子进程被直接杀掉,ext
|
|
16
|
+
// 来不及 `release()`,新实例启动只能干等租约到期(开机到 `on` 实测 18s)——
|
|
17
|
+
// 用户看到的就是「重启后十几秒不给我发已连接」。异主机/旧格式(无 host)
|
|
18
|
+
// 一律回落租约语义,避免网络盘共享数据目录时 PID 号巧合误判。
|
|
19
|
+
//
|
|
14
20
|
// **锁根必须是全局目录**(`~/.pi-flash/bots-runtime-locks/`):真机历史上
|
|
15
21
|
// PF 自带轮询器(remote_control / wxprobe 探针)曾与本插件抢同一个 bot——
|
|
16
22
|
// 那两个实现已移除(微信通信现只有本插件),但锁位仍是**跨实现共享约定**:
|
|
@@ -23,7 +29,7 @@ import {
|
|
|
23
29
|
existsSync, mkdirSync, readFileSync, renameSync,
|
|
24
30
|
rmSync, statSync, utimesSync, writeFileSync,
|
|
25
31
|
} from "node:fs";
|
|
26
|
-
import { homedir } from "node:os";
|
|
32
|
+
import { homedir, hostname } from "node:os";
|
|
27
33
|
import { join } from "node:path";
|
|
28
34
|
import { sha256_hex } from "./wire.mjs";
|
|
29
35
|
|
|
@@ -74,6 +80,7 @@ export async function acquire(namespace, lockKey, botId, { heartbeatMs = HEARTBE
|
|
|
74
80
|
botId,
|
|
75
81
|
nonce: `${Date.now().toString(16)}${process.pid.toString(16)}`,
|
|
76
82
|
createdAt: now_ms(),
|
|
83
|
+
host: hostname(), // 同机 pid 快判的依据(异主机不认识 → 回落租约)
|
|
77
84
|
};
|
|
78
85
|
mkdirSync(join(lock_root(), namespace), { recursive: true });
|
|
79
86
|
|
|
@@ -108,10 +115,12 @@ export async function acquire(namespace, lockKey, botId, { heartbeatMs = HEARTBE
|
|
|
108
115
|
await remove_dir_with_retry(lockPath);
|
|
109
116
|
continue;
|
|
110
117
|
}
|
|
111
|
-
//
|
|
118
|
+
// 竞争分支:先看「同机 pid 已死」快路,再落到租约新鲜度
|
|
119
|
+
// (顺序不能反:pid 快路只是**提前**判死,不改变「活者让路」语义)
|
|
112
120
|
const leaseAt = lease_mtime(lockPath, current.nonce);
|
|
113
121
|
const age = now_ms() - Math.max(leaseAt, 0);
|
|
114
|
-
|
|
122
|
+
const dead_here = is_same_host_dead(current);
|
|
123
|
+
if (!dead_here && leaseAt > 0 && age < leaseMs) return null;
|
|
115
124
|
await remove_dir_with_retry(lockPath);
|
|
116
125
|
}
|
|
117
126
|
return null;
|
|
@@ -124,7 +133,13 @@ function now_ms() {
|
|
|
124
133
|
function write_owner(dir, owner) {
|
|
125
134
|
writeFileSync(
|
|
126
135
|
join(dir, "owner.json"),
|
|
127
|
-
JSON.stringify({
|
|
136
|
+
JSON.stringify({
|
|
137
|
+
pid: owner.pid,
|
|
138
|
+
botId: owner.botId,
|
|
139
|
+
nonce: owner.nonce,
|
|
140
|
+
createdAt: owner.createdAt,
|
|
141
|
+
host: owner.host,
|
|
142
|
+
}, null, 2),
|
|
128
143
|
);
|
|
129
144
|
}
|
|
130
145
|
|
|
@@ -132,12 +147,31 @@ function read_owner(lockPath) {
|
|
|
132
147
|
try {
|
|
133
148
|
const v = JSON.parse(readFileSync(join(lockPath, "owner.json"), "utf8"));
|
|
134
149
|
if (typeof v.pid !== "number" || typeof v.botId !== "string" || typeof v.nonce !== "string") return null;
|
|
135
|
-
return v;
|
|
150
|
+
return v; // host 可缺(旧格式):缺失 = 未知主机 = 回落租约语义
|
|
136
151
|
} catch {
|
|
137
152
|
return null;
|
|
138
153
|
}
|
|
139
154
|
}
|
|
140
155
|
|
|
156
|
+
/** 持有者是不是**本机**上一个已经消失的进程(崩溃/被杀,来不及 release)。
|
|
157
|
+
* 旧格式(无 host)/ 异主机 / pid 仍活 → false(保守:让路给租约)。 */
|
|
158
|
+
export function is_same_host_dead(owner) {
|
|
159
|
+
if (!owner || typeof owner.host !== "string" || owner.host === "") return false;
|
|
160
|
+
if (owner.host !== hostname()) return false;
|
|
161
|
+
return !pid_alive(owner.pid);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** pid 存活探测(跨平台):ESRCH = 不存在;EPERM = 存在但无权限(算活)。 */
|
|
165
|
+
function pid_alive(pid) {
|
|
166
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
167
|
+
try {
|
|
168
|
+
process.kill(pid, 0);
|
|
169
|
+
return true;
|
|
170
|
+
} catch (e) {
|
|
171
|
+
return e?.code === "EPERM";
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
141
175
|
function lease_mtime(lockPath, nonce) {
|
|
142
176
|
try {
|
|
143
177
|
return statSync(join(lockPath, `lease-${nonce}`)).mtimeMs;
|
package/pi/lock.test.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// lock.test —— 本地租约锁:同机死进程快路接管(2026-11-03)。
|
|
2
|
+
//
|
|
3
|
+
// 背景(真机):PF 退出时 pi 子进程被直接杀掉,ext 来不及 `release()`,锁目录
|
|
4
|
+
// 留在 `<数据目录>/bots-runtime-locks/` 里且租约还新鲜(30s)→ 新实例启动只能
|
|
5
|
+
// 干等租约到期,实测「开机 → 服务 on」18s,用户看到的就是「重启后十几秒不给
|
|
6
|
+
// 我发已连接」。修法:owner.json 带 host,**同主机且 pid 已不存在**当场接管。
|
|
7
|
+
// 异主机 / 旧格式(无 host)/ pid 仍活 → 一律回落租约语义(不改变互斥保证)。
|
|
8
|
+
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { mkdtempSync, mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { hostname, tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
|
|
16
|
+
import { acquire, is_same_host_dead, LEASE_MS, HEARTBEAT_MS } from "./lock.mjs";
|
|
17
|
+
|
|
18
|
+
const sha256 = (s) => createHash("sha256").update(s, "utf8").digest("hex");
|
|
19
|
+
|
|
20
|
+
/** 造一份「已存在的锁」:返回 lockPath(不经过 acquire,好自由设定 owner)。
|
|
21
|
+
* 租约 mtime 默认 = 刚心跳过(新鲜);`lease_age_ms` > 0 则改成那么久之前。 */
|
|
22
|
+
function plant_lock(base, ns, key, owner, { lease_age_ms = 0 } = {}) {
|
|
23
|
+
const lockPath = join(base, "bots-runtime-locks", ns, `${sha256(key)}.lock`);
|
|
24
|
+
mkdirSync(lockPath, { recursive: true });
|
|
25
|
+
writeFileSync(join(lockPath, "owner.json"), JSON.stringify(owner, null, 2));
|
|
26
|
+
const lease = join(lockPath, `lease-${owner.nonce}`);
|
|
27
|
+
writeFileSync(lease, "");
|
|
28
|
+
if (lease_age_ms > 0) {
|
|
29
|
+
const t = new Date(Date.now() - lease_age_ms);
|
|
30
|
+
utimesSync(lease, t, t);
|
|
31
|
+
}
|
|
32
|
+
return lockPath;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 一个「肯定不存在」的 pid:向下探测号段,撞到 ESRCH 即停。 */
|
|
36
|
+
function dead_pid() {
|
|
37
|
+
for (let pid = 999_990; pid > 999_900; pid -= 1) {
|
|
38
|
+
let alive = true;
|
|
39
|
+
try {
|
|
40
|
+
process.kill(pid, 0);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
if (e?.code === "ESRCH") alive = false;
|
|
43
|
+
}
|
|
44
|
+
if (!alive) return pid;
|
|
45
|
+
}
|
|
46
|
+
return 999_000;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function with_base(fn) {
|
|
50
|
+
const base = mkdtempSync(join(tmpdir(), "pf-lock-"));
|
|
51
|
+
const prev = process.env.PI_FLASH_DIR;
|
|
52
|
+
process.env.PI_FLASH_DIR = base; // lock_root() 每次读环境
|
|
53
|
+
return Promise.resolve(fn(base)).finally(() => {
|
|
54
|
+
if (prev === undefined) delete process.env.PI_FLASH_DIR;
|
|
55
|
+
else process.env.PI_FLASH_DIR = prev;
|
|
56
|
+
rmSync(base, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const NS = "weixin-polling";
|
|
61
|
+
const KEY = "bot-token-fixture";
|
|
62
|
+
|
|
63
|
+
test("same_host_dead_owner_takes_over_immediately", () =>
|
|
64
|
+
with_base(async (base) => {
|
|
65
|
+
plant_lock(base, NS, KEY, {
|
|
66
|
+
pid: dead_pid(),
|
|
67
|
+
botId: "dead@im.bot",
|
|
68
|
+
nonce: "n1",
|
|
69
|
+
createdAt: Date.now(),
|
|
70
|
+
host: hostname(),
|
|
71
|
+
});
|
|
72
|
+
const t0 = Date.now();
|
|
73
|
+
const lock = await acquire(NS, KEY, "self@im.bot", { heartbeatMs: HEARTBEAT_MS, leaseMs: LEASE_MS });
|
|
74
|
+
const elapsed = Date.now() - t0;
|
|
75
|
+
assert.ok(lock, "同机死持有者必须能立刻接管");
|
|
76
|
+
assert.ok(elapsed < 2000, `接管应即时(实测 ${elapsed}ms)——不得等 30s 租约`);
|
|
77
|
+
lock.release();
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
test("live_owner_still_blocks", () =>
|
|
81
|
+
with_base(async (base) => {
|
|
82
|
+
plant_lock(base, NS, KEY, {
|
|
83
|
+
pid: process.pid, // 自己 = 活
|
|
84
|
+
botId: "live@im.bot",
|
|
85
|
+
nonce: "n2",
|
|
86
|
+
createdAt: Date.now(),
|
|
87
|
+
host: hostname(),
|
|
88
|
+
});
|
|
89
|
+
const lock = await acquire(NS, KEY, "self@im.bot", { heartbeatMs: HEARTBEAT_MS, leaseMs: LEASE_MS });
|
|
90
|
+
assert.equal(lock, null, "租约新鲜且持有者活着 → 必须让路");
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
test("legacy_owner_without_host_falls_back_to_lease", () =>
|
|
94
|
+
with_base(async (base) => {
|
|
95
|
+
plant_lock(base, NS, KEY, {
|
|
96
|
+
pid: dead_pid(), // pid 死了,但旧格式没 host:不敢判死
|
|
97
|
+
botId: "legacy@im.bot",
|
|
98
|
+
nonce: "n3",
|
|
99
|
+
createdAt: Date.now(),
|
|
100
|
+
});
|
|
101
|
+
const lock = await acquire(NS, KEY, "self@im.bot", { heartbeatMs: HEARTBEAT_MS, leaseMs: LEASE_MS });
|
|
102
|
+
assert.equal(lock, null, "旧格式(无 host)回落租约:不能靠 pid 号冒险接管");
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
test("stale_lease_without_pid_fast_path_takes_over", () =>
|
|
106
|
+
with_base(async (base) => {
|
|
107
|
+
plant_lock(
|
|
108
|
+
base,
|
|
109
|
+
NS,
|
|
110
|
+
KEY,
|
|
111
|
+
{ pid: dead_pid(), botId: "old@im.bot", nonce: "n4", createdAt: Date.now() - 60_000, host: hostname() },
|
|
112
|
+
{ lease_age_ms: 60_000 },
|
|
113
|
+
);
|
|
114
|
+
const lock = await acquire(NS, KEY, "self@im.bot", { heartbeatMs: HEARTBEAT_MS, leaseMs: 30_000 });
|
|
115
|
+
assert.ok(lock, "租约过期 → 照旧接管(原有语义不变)");
|
|
116
|
+
lock.release();
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
test("is_same_host_dead_predicates", () => {
|
|
120
|
+
assert.equal(is_same_host_dead(null), false);
|
|
121
|
+
assert.equal(is_same_host_dead({ pid: dead_pid(), host: hostname() }), true);
|
|
122
|
+
assert.equal(is_same_host_dead({ pid: process.pid, host: hostname() }), false);
|
|
123
|
+
assert.equal(is_same_host_dead({ pid: dead_pid(), host: "another-host" }), false);
|
|
124
|
+
assert.equal(is_same_host_dead({ pid: dead_pid() }), false, "无 host = 未知主机");
|
|
125
|
+
});
|
package/pi/register.mjs
CHANGED
|
@@ -102,15 +102,24 @@ export function normalize(status) {
|
|
|
102
102
|
|
|
103
103
|
// ---------------------------------------------------------------- 状态落地
|
|
104
104
|
|
|
105
|
-
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
105
|
+
/** 扫码成功后清掉**绑在旧 bot token / 旧会话上下文**的字段:
|
|
106
|
+
* - `buf`:getUpdates 游标(token 级)。换绑后带旧游标去 getupdates,iLink 以
|
|
107
|
+
* errcode:-14 拒之(2026-10-23 真机事故)——必须清。
|
|
108
|
+
* - `context_token`:上一轮会话上下文(会话级),随旧会话过期——清。
|
|
109
|
+
*
|
|
110
|
+
* **对端 `chat_id` / `user_id` 保留**(2026-11-03 改):它是「谁在用这个微信
|
|
111
|
+
* 机器人」的**用户级**身份,跨 bot 换代稳定。清掉它,新 bot 就再也找不到对端,
|
|
112
|
+
* 「已连接」只能挂在待发队列里等用户第一条入站消息(它才带回 user_id)——
|
|
113
|
+
* 真机:重绑新 bot → 立刻退出 → 重启,十几秒无任何通知,直到用户主动发微信
|
|
114
|
+
* 才「已连接 + agent 回复」一起到(用户当成插件失效)。发送本身不依赖
|
|
115
|
+
* context_token(真机验证:清 ctx、留 user_id,sendmessage 正常受理)。
|
|
116
|
+
* `session_key` 是 PF 侧会话归属,与 bot 无关,保留。 */
|
|
109
117
|
export function reset_session_fields(st) {
|
|
110
|
-
for (const k of ["buf", "
|
|
118
|
+
for (const k of ["buf", "context_token"]) delete st[k];
|
|
111
119
|
}
|
|
112
120
|
|
|
113
|
-
/**
|
|
121
|
+
/** 重绑落地:**清旧会话字段(游标/上下文)+ 写新 bot 三件**(2026-10-24 用户定夺;
|
|
122
|
+
* 对端保留见 [`reset_session_fields`])。
|
|
114
123
|
* 必须喂**当场 loadState()** 的内容——严禁攥着旧快照整文件回写(自动续码
|
|
115
124
|
* 时代每个到期 worker 都把 begin 时的快照原样 save,把换绑刚写好的
|
|
116
125
|
* bot_token 盖回旧值——「重绑就废」的根因)。 */
|
package/pi/register.test.mjs
CHANGED
|
@@ -63,9 +63,11 @@ test("rebind_clears_old_routing_fields_and_swaps_bot", () => {
|
|
|
63
63
|
assert.equal(st.bot_token, "NEW-TOKEN");
|
|
64
64
|
assert.equal(st.bot_id, "new@im.bot");
|
|
65
65
|
assert.equal(st.bot_user_id, "new@im.bot", "from_user_id 必须跟新 bot 走");
|
|
66
|
-
for (const k of ["buf", "
|
|
66
|
+
for (const k of ["buf", "context_token", "qrcode", "qrcode_grid"]) {
|
|
67
67
|
assert.ok(st[k] === undefined || st[k] === null, `${k} 必须被清掉`);
|
|
68
68
|
}
|
|
69
|
+
assert.equal(st.user_id, "old@im.wechat", "对端是用户级身份,跨 bot 保留(否则已连接发不出)");
|
|
70
|
+
assert.equal(st.chat_id, "old@im.wechat", "群聊对端同理保留");
|
|
69
71
|
assert.equal(st.session_key, "C:\\s.jsonl", "PF 侧会话归属保留");
|
|
70
72
|
});
|
|
71
73
|
|
|
@@ -80,9 +82,16 @@ test("reset_clears_old_bot_routing_fields", () => {
|
|
|
80
82
|
session_key: "C:\\sess\\a.jsonl",
|
|
81
83
|
};
|
|
82
84
|
reset_session_fields(st);
|
|
83
|
-
|
|
84
|
-
|
|
85
|
+
// 游标(token 级)/ 上下文(会话级)必须清:带旧游标用新 token 去 getupdates
|
|
86
|
+
// 会被 iLink 以 errcode:-14 拒(2026-10-23 真机事故)
|
|
87
|
+
for (const k of ["buf", "context_token"]) {
|
|
88
|
+
assert.ok(st[k] === undefined || st[k] === null, `${k} 必须被清掉`);
|
|
85
89
|
}
|
|
90
|
+
// 对端必须留:清了新 bot 就找不到人,「已连接」只能等用户先发消息
|
|
91
|
+
//(真机 2026-11-03:重绑→退出→重启,十几秒无通知,直到用户发微信才
|
|
92
|
+
// 「已连接 + 回复」一起到)
|
|
93
|
+
assert.equal(st.user_id, "u@im.wechat", "对端 user_id 保留");
|
|
94
|
+
assert.equal(st.chat_id, "room@im.wechat", "对端 chat_id 保留");
|
|
86
95
|
assert.equal(st.session_key, "C:\\sess\\a.jsonl", "PF 侧会话归属保留");
|
|
87
96
|
assert.equal(st.bot_token, "new-token", "新 bot 字段不受影响");
|
|
88
97
|
});
|
package/pi/state.mjs
CHANGED
|
@@ -10,12 +10,26 @@
|
|
|
10
10
|
// 先写游标会让进程中途失败时跳过未处理消息(weixinChannelRuntime.ts:155-163)。
|
|
11
11
|
|
|
12
12
|
import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
13
|
-
import { dirname, join } from "node:path";
|
|
13
|
+
import { basename, dirname, join } from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { parse } from "./jsonx.mjs";
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
/** 包内 data 目录解析——**两档布局自定位**(005 §①,90-4 契约 `<pkg>/data`
|
|
18
|
+
* 无版本维度):源码/dev 平铺(`<pkg>/pi/` 上跳一级 = 包根)与 market 版本层
|
|
19
|
+
* (`<pkg>/<ver>/pi/` 上跳一级 = 版本目录,须再跳一级)。此前只跳一级,安装
|
|
20
|
+
* 布局下落版本层 `<ver>/data`,与壳(宿主 dataDir=包层)劈叉——出码/收发
|
|
21
|
+
* 全断(真机 2026-11-03,协调层九连故障的原罪)。判据:上跳一级的目录名
|
|
22
|
+
* 以数字开头(semver 版本目录)即再多跳一级;`data/`、`.cache/` 等非版本
|
|
23
|
+
* 目录不受影响。
|
|
24
|
+
* @param {string} pi_dir ext 模块所在目录(…/pi)
|
|
25
|
+
*/
|
|
26
|
+
export function resolve_data_dir(pi_dir) {
|
|
27
|
+
const up1 = join(pi_dir, "..");
|
|
28
|
+
const pkgDir = /^\d/.test(basename(up1)) ? join(up1, "..") : up1;
|
|
29
|
+
return join(pkgDir, "data");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const dataDir = resolve_data_dir(dirname(fileURLToPath(import.meta.url)));
|
|
19
33
|
|
|
20
34
|
/** 包内状态目录(约定①)。 */
|
|
21
35
|
export function base_dir() {
|
|
@@ -30,6 +44,19 @@ export function dump_dir() {
|
|
|
30
44
|
return join(dataDir, "dump");
|
|
31
45
|
}
|
|
32
46
|
|
|
47
|
+
/** 宿主协议文件 `<pkg>/pf-runtime.json`(005 §③,90-4 §8):宿主写、插件
|
|
48
|
+
* 只读的运行许可。缺文件/坏 JSON = 许可(兜底安全)。返回
|
|
49
|
+
* `{pf_enabled, pf_seq, pf_reason}` 或 null。 */
|
|
50
|
+
export function runtime() {
|
|
51
|
+
try {
|
|
52
|
+
const raw = readFileSync(join(dirname(dataDir), "pf-runtime.json"), "utf8");
|
|
53
|
+
const v = parse(raw);
|
|
54
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : null;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
33
60
|
/** 坏 JSON / 缺文件 → {}(宿主把坏盘落成空串,兜底是插件的责任)。 */
|
|
34
61
|
export function load() {
|
|
35
62
|
try {
|
package/pi/state.test.mjs
CHANGED
|
@@ -136,3 +136,25 @@ test("snapshot_missing_file_retries_every_beat", () => {
|
|
|
136
136
|
assert.equal(snap.refresh(false), true, "null 不命中缓存短路");
|
|
137
137
|
assert.equal(snap.refresh(false), true);
|
|
138
138
|
});
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------- data 目录两档布局(005 §①)
|
|
141
|
+
|
|
142
|
+
import { resolve_data_dir } from "./state.mjs";
|
|
143
|
+
|
|
144
|
+
test("resolve_data_dir —— 源码/dev 平铺与 market 版本层都落 <pkg>/data", () => {
|
|
145
|
+
// dev 平铺:<pkg>/pi → 上跳一级 = 包根(目录名非数字开头)
|
|
146
|
+
assert.equal(
|
|
147
|
+
resolve_data_dir("D:/w/packages/pf-wxbot/pi").replaceAll("\\", "/"),
|
|
148
|
+
"D:/w/packages/pf-wxbot/data",
|
|
149
|
+
);
|
|
150
|
+
// market 版本层:<pkg>/<ver>/pi → 上跳一级是版本目录(数字开头)→ 再跳一级
|
|
151
|
+
assert.equal(
|
|
152
|
+
resolve_data_dir("C:/u/.pi-flash/plugins/@x__pf-wxbot/0.1.4/pi").replaceAll("\\", "/"),
|
|
153
|
+
"C:/u/.pi-flash/plugins/@x__pf-wxbot/data",
|
|
154
|
+
);
|
|
155
|
+
// 版本层多段版本号(0.10.2 同样数字开头)
|
|
156
|
+
assert.equal(
|
|
157
|
+
resolve_data_dir("C:/p/@x__w/0.10.2/pi").replaceAll("\\", "/"),
|
|
158
|
+
"C:/p/@x__w/data",
|
|
159
|
+
);
|
|
160
|
+
});
|
package/plugin.wasm
CHANGED
|
Binary file
|