@pi-flash-plugins/pf-wxbot 0.0.0-stage → 0.1.4

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/pi/index.mjs ADDED
@@ -0,0 +1,658 @@
1
+ // index —— pi 扩展入口:transport/register/hook/inject/state 串成一体。
2
+ //
3
+ // **生命周期铁律**(pi extensions.md):工厂内禁起资源;泵只在 `session_start` 起、
4
+ // `session_shutdown` 幂等收。
5
+ //
6
+ // **v4 注入策略(004 §5.2):全局装载 + 泵内门控。**
7
+ // ext 随每个会话进程加载,泵节拍读镜像(mtime 友好),激活条件 =
8
+ // `session_id == 本会话 key`(壳点 icon 时从 PF 命令 env 直写镜像,单写入口)
9
+ // 且 `enabled != off`(弹窗 switch)。否则空转并停渠道——旧 claim 附着机制不再需要。
10
+ // 自身身份 = `sessionCtx.sessionManager.getSessionId()`(pi ReadonlySessionManager,
11
+ // 始终有值;与壳 env 的 session.key 同源是 PF 轨契约,004 §7-1)。
12
+ //
13
+ // **不保活(004 §7-2 定案)**:服务生命周期 = 绑定会话进程生命周期;标签删了
14
+ // 进程没了 session_shutdown 全收,PF 零新增。
15
+ //
16
+ // **off 态**(002 主动断开):enabled=0 → 幂等停 transport,on → 泵上原地重启。
17
+ // 会话被删(off 态 case-1):deps.sessionExists 周期校验(通道归 PF 轨,004 §7-6),
18
+ // 返回 false → 自动关服务 + 删除持有的 sessionId。
19
+
20
+ import { existsSync } from "node:fs";
21
+ import * as state from "./state.mjs";
22
+ import { Service, Health, WxLine, Snapshot } from "./state.mjs";
23
+ import { Wire } from "./wire.mjs";
24
+ import { Transport } from "./transport.mjs";
25
+ import { config_from_state } from "./config.mjs";
26
+ import { runQrFlow } from "./register.mjs";
27
+ import { qr_grid } from "./qr.mjs";
28
+ import { create_hook } from "./hook.mjs";
29
+ import { create_inject } from "./inject.mjs";
30
+ import { status_text } from "./messages.mjs";
31
+
32
+ /** 002 off 态 case-1:绑定键是会话文件路径时直接看盘判断会话是否还在。
33
+ * pi sessionId(uuid)形态无盘面可查 → null(跳过,等 PF 通道)。 */
34
+ function default_session_exists(key) {
35
+ try {
36
+ return /\.jsonl$/i.test(key) ? existsSync(key) : null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
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
+ /** 从进程 argv 解析 `--session <file>`(pi spawn 必带,恢复/新会话皆是)。
49
+ * 第三把身份钥匙:恢复会话的进程里 getSessionFile() 首条消息前是
50
+ * undefined、uuid 又随重启漂移——门控两把旧钥匙同时落空,服务不起、
51
+ * 微信消息无人收(真机 2026-11-03:修复停用重启后必然踩中)。 */
52
+ export function session_file_from_argv(argv = process.argv) {
53
+ const i = argv.findIndex((a) => a === "--session");
54
+ const v = i >= 0 ? argv[i + 1] : undefined;
55
+ return typeof v === "string" && v !== "" ? v : null;
56
+ }
57
+
58
+ /** 本进程出生时刻(模块加载即定格,早于 session_start 数百毫秒)。卸载
59
+ * 水位线:壳写的 unload_ts > BOOT_TS = 本进程服役期内被停用。与盘上快照
60
+ * 无关,免疫「快照晚于壳写」竞态与整文件回写弄脏序号两类坑。
61
+ */
62
+ const BOOT_TS = Date.now();
63
+
64
+ const TICK_MS = 200; // 泵节拍(mod.rs 200ms 采样同款)
65
+ const SESSION_CHECK_TICKS = 150; // 会话存在性校验周期(150 拍 ≈ 30s;可注 deps.sessionCheckTicks)
66
+ // 运行中心跳节拍默认 30s(2026-11-01 buglist#1 定案,用户拍板 30s):agent
67
+ // 连续运行不产出时,微信服务端等不到回执会落「请稍后重试」兜底(真机 10-31:
68
+ // 连续工作数分钟即现;同类生态实测 context_token ≈160s 过期)。计时口径 =
69
+ // **距上次发送消息时间间隔推送心跳通知**。
70
+ const HEARTBEAT_MS = 60_000;
71
+
72
+ /** QR 画布载荷:每行 run-length [[x,len]…](壳 canvas 直画,无 JS 依赖)。 */
73
+ function serialize_grid(grid) {
74
+ const rows = [];
75
+ for (let y = 0; y < grid.size; y += 1) {
76
+ const runs = grid.runs(y).map(([x, len]) => [x, len]);
77
+ rows.push(runs.length ? runs : []);
78
+ }
79
+ return { size: grid.size, rows };
80
+ }
81
+
82
+ /**
83
+ * 入口工厂(deps 可注入 = 单测面)。返回 pi 扩展 factory。
84
+ */
85
+ export function create_entry(deps = {}) {
86
+ const TransportCls = deps.Transport ?? Transport;
87
+ const WireCls = deps.Wire ?? Wire;
88
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
89
+ const now = deps.now ?? (() => Date.now());
90
+ const S = deps.state ?? state; // state 模块面(测试注内存盘)
91
+ const sessionExists = deps.sessionExists ?? null; // (sessionKey) => Promise<bool|null>;null = 通道未接(PF 轨)
92
+ const ownKeyOf =
93
+ deps.ownKeyOf ??
94
+ ((sessionCtx) => sessionCtx?.sessionManager?.getSessionId?.() ?? null);
95
+
96
+ /** 原子写状态键(读-改-写当场盘,最小面复用 set/save)。 */
97
+ function set_keys(keys) {
98
+ const st = S.load();
99
+ for (const [k, v] of Object.entries(keys)) S.set(st, k, v);
100
+ S.save(st);
101
+ }
102
+
103
+ return function wechat_bridge(pi) {
104
+ // ── 纯件(工厂内禁起资源)─────────────────────────────────
105
+ const svc = new Service();
106
+ const health = new Health();
107
+ const line = new WxLine();
108
+ const snap = new Snapshot({ stat: deps.stat, load: S.load });
109
+ let ctx = null; // session 域上下文(session_start 绑定)
110
+ let ownKey = null; // 本进程会话身份(门控比对源:getSessionId)
111
+ let ownFile = null; // 会话文件路径(门控双口径之二:getSessionFile)
112
+ let wasActive = null; // 上一拍门控结果(null = 未判定;失绑清旗的依据)
113
+ let started_once = false; // 本进程成功起过渠道(「已连接」③判据:重载后首连)
114
+ let announce_next = false; // 待宣告「已连接」:①换绑落地 / ②用户 off→on(to_on 时冲)
115
+ let shutdown = false;
116
+ let starting = false;
117
+ let qrCancel = null;
118
+ let qr_seen = null; // 出码协程已写进镜像的码(壳清码 = 要求重出)
119
+ let qr_started = 0; // 本协程起始时刻(兜底:码还没落地就被清)
120
+ // 用户 off 期间服务端积压的入站消息:resume 后**丢弃**(002 主动断开 =
121
+ // 关服务,不补发)。真机 2026-10-28:关一次再开,积压三条一次性全发出去。
122
+ let skip_backlog = false;
123
+ let run_started_at = null;
124
+ let last_heartbeat = 0; // 上次出站时刻(发言/错误回执/⚡通知/心跳卡任一发出即归位;agent_start 归位)
125
+ let pumpTimer = null;
126
+ let tickCount = 0;
127
+ let checking = false;
128
+ let unloaded = false; // 壳卸载标记已处理:进程内粘性终态,不再复活渠道(再启用 = attach 重启新进程)
129
+
130
+ const hook = create_hook({
131
+ loadState: S.load,
132
+ transport: () => svc.transport_handle(),
133
+ directSend: deps.directSend, // 测试注记录器;缺省 = hook.mjs 真直发(降沿「已失联」用)
134
+ trace: (msg) => write_log("notice_log", 24, msg),
135
+ });
136
+
137
+ const inject = create_inject({});
138
+ const sessionExists = deps.sessionExists ?? default_session_exists;
139
+
140
+ /** 注入动作执行面(inject.run_action 的 ops)——最小面。 */
141
+ const ops = {
142
+ isRunning: () => (ctx ? !ctx.isIdle() : false),
143
+ statusText: () => compose_status(),
144
+ send: (text) => svc.transport_handle()?.send(text),
145
+ prompt: (text) => pi.sendUserMessage(text, { deliverAs: "followUp" }),
146
+ abort: () => ctx?.abort(),
147
+ };
148
+
149
+ function compose_status() {
150
+ const cwd = ctx?.cwd ?? "";
151
+ const workspace = cwd.split(/[\\/]/).filter(Boolean).pop() || cwd;
152
+ const model = ctx?.model ? `${ctx.model.provider}/${ctx.model.id}` : "未设置";
153
+ const running = ctx ? !ctx.isIdle() : false;
154
+ return status_text(S.get_str(S.load(), "lang") ?? "zh-CN", {
155
+ workspace,
156
+ model,
157
+ task: { title: line.name ?? "微信会话", id: (ownKey ?? "session").split(/[\\/]/).pop() },
158
+ state: running ? "running" : "completed",
159
+ elapsed_ms: running && run_started_at ? now() - run_started_at : undefined,
160
+ });
161
+ }
162
+
163
+ // ── 诊断痕迹(真机 ext stderr 不可见)───────────────────────
164
+ // 落镜像两个环形日志,wx-state.json 直接可查:
165
+ // - notice_log(hook.trace):启停通知挂起/发出——「挂起」在而「发出」不在
166
+ // = 路由未就绪或渠道被夺/进程被杀抢在冲刷前;
167
+ // - svc_log:渠道起停迁移 + 原因标签——谁在何时停了渠道(面板开关
168
+ // 两 runtime 互搏、30s 重宣告等疑难的定位依据)。
169
+ function write_log(key, cap, msg) {
170
+ try {
171
+ const st = S.load();
172
+ const log = Array.isArray(st[key]) ? st[key] : [];
173
+ log.push(`${new Date().toISOString()} ${msg}`);
174
+ while (log.length > cap) log.shift();
175
+ S.save({ ...st, [key]: log });
176
+ } catch {
177
+ // 痕迹失败不影响主流程
178
+ }
179
+ }
180
+ const note = (msg) => write_log("svc_log", 32, msg);
181
+
182
+ // 真机 ext stderr 不可见:未处理拒绝/异常写进 svc_log,别让进程无声死
183
+ //(2026-11-01 真机:ext 在 unload 写入前后失踪,无任何痕迹可查)。
184
+ // 只记痕迹不吞:uncaughtException 后进程仍按 Node 默认行为退出。
185
+ for (const [sig, tag] of [
186
+ ["unhandledRejection", "rejection"],
187
+ ["uncaughtException", "exception"],
188
+ ]) {
189
+ process.on(sig, (e) => {
190
+ try {
191
+ note(`${tag}:${String((e && (e.stack ?? e.message)) ?? e).slice(0, 160)}`);
192
+ } catch { /* 尽力而为 */ }
193
+ });
194
+ }
195
+
196
+ // ── 服务状态机(唯一启动/关停路径)─────────────────────────
197
+
198
+ function start_service() {
199
+ if (starting) return;
200
+ starting = true;
201
+ const cfg = (() => {
202
+ try {
203
+ return config_from_state(S.load());
204
+ } catch (e) {
205
+ return { error: e };
206
+ }
207
+ })();
208
+ if (cfg.error) {
209
+ starting = false;
210
+ to_failed(`渠道未启动:${cfg.error.message ?? cfg.error}`);
211
+ return;
212
+ }
213
+ TransportCls.spawn(cfg)
214
+ .then((tr) => {
215
+ if (unloaded || shutdown) {
216
+ tr.stop(); // 卸载/关停后 spawn 才落地:渠道当场收,不入状态机
217
+ note("on-suppressed");
218
+ return;
219
+ }
220
+ svc.to_on(tr, cfg.token);
221
+ health.clear();
222
+ set_keys({ svc_error: null });
223
+ note("on");
224
+ // 先冲可能挂着的「已失联」(快速 off→on 时两态都送达),再宣告连接
225
+ hook.flush_notice();
226
+ // ⚡「已连接」宣告(002·通知):③进程重载后首次起渠道(原有绑定链路通畅)
227
+ // / ①换绑落地 / ②用户 off→on(两者经 announce_next)。失败重试不重复宣告
228
+ //(started_once 已置位后只认 announce_next);已知过期(session_expired=1,
229
+ // 只能重扫解除)不宣告 —— 链路不通畅,发必失败(002:链路通畅才宣告)。
230
+ const announce = !started_once || announce_next;
231
+ started_once = true;
232
+ announce_next = false;
233
+ if (announce && S.get_str(S.load(), "session_expired") !== "1") {
234
+ hook.on_start();
235
+ // 连接当场入队(曾等泵下一拍:200ms 内渠道被夺/进程被杀就丢——
236
+ // 真机 2026-11-01 两次「挂起:已连接」无「发出」即此窗口)
237
+ hook.flush_notice();
238
+ }
239
+ })
240
+ .catch((e) => {
241
+ const msg = String(e?.message ?? e);
242
+ // 锁被另一个轮询者占着 = **暂时**(换绑瞬间的两个进程、或旧租约未过期)。
243
+ // 归 Pending 下一拍再试,不写 svc_error —— 否则 icon 会被按灰很久。
244
+ if (msg.includes("另一个轮询者")) {
245
+ console.error(`[wxbridge] 渠道占位中,稍后重试:${msg}`);
246
+ svc.to_pending();
247
+ return;
248
+ }
249
+ // 静默失败是最坏的形态(§6 坑 4):文案进 Failed,冷却后重试
250
+ to_failed(`渠道未启动:${msg}`);
251
+ })
252
+ .finally(() => {
253
+ starting = false;
254
+ });
255
+ }
256
+
257
+ function to_failed(msg) {
258
+ console.error(`[wxbridge] ${msg}`);
259
+ note(`fail:${String(msg).slice(0, 60)}`);
260
+ svc.to_failed(now(), msg);
261
+ set_keys({ svc_error: msg }); // 壳弹窗灰 icon 依据之一
262
+ }
263
+
264
+ /** 停渠道。`user` = 用户显式关停(不再自动拉起);否则落 Pending(重拉)。
265
+ * `why` = 原因标签(svc_log 痕迹)。⚡降沿置位(hook.on_stop → stop_notice
266
+ * 规则):用户关停/卸载且服务在跑 → 待发「已失联」(泵拍冲,直发);
267
+ * 换绑/失门/未在跑一律静默。 */
268
+ function stop_service(user, why = "") {
269
+ const tr = svc.transport_handle();
270
+ const was_on = svc.is_running();
271
+ if (user) svc.to_stopped();
272
+ else svc.to_pending();
273
+ note(`stop:${why} user=${user} was_on=${was_on}`);
274
+ hook.on_stop(user, was_on);
275
+ tr?.stop(); // 幂等:置停止标志即返回,不等在途 90s
276
+ }
277
+
278
+ /** 扫码成功 = 换绑落地:旧错/过期标注一并清,渠道按新 token 重拉。 */
279
+ function on_rebind() {
280
+ health.clear();
281
+ set_keys({ session_expired: null });
282
+ announce_next = true; // ⚡①扫码成功握手:重连后宣告「已连接」
283
+ stop_service(false, "rebind"); // 落 Pending;下一拍 start
284
+ }
285
+
286
+ /** 壳【刷新二维码】/重绑清了镜像里的码:活着的协程不会再发 ready,只有等
287
+ * 180s 死线才续码(表现为「刷新后一直生成中」)—— 直接取消,下一拍重出。 */
288
+ function qr_recycle(st) {
289
+ if (!qrCancel) return;
290
+ const cur_code = S.get_str(st, "qrcode");
291
+ const cur_grid = S.get_str(st, "qrcode_grid");
292
+ if (cur_code) qr_seen = cur_code;
293
+ // 两条判据:①已落地过的码被清(壳刷新/重绑)②协程跑了 1.5s 镜像仍是空
294
+ // (码还没写就被壳的整文件回写盖掉)—— 两种都取消重出,否则要空等死线。
295
+ const cleared = qr_seen && !cur_code && !cur_grid;
296
+ const never_landed = !qr_seen && !cur_code && !cur_grid && now() - qr_started > 8000;
297
+ if (cleared || never_landed) {
298
+ qrCancel();
299
+ qrCancel = null;
300
+ qr_seen = null;
301
+ }
302
+ }
303
+
304
+ /** 出码流程保活:门控内、未绑定/已过期才起;成功/过期后由回调收尾。 */
305
+ function ensure_qr_flow() {
306
+ // 壳点 icon 会写 qr_request=1:即便已绑定也出一张新码(扫了换绑,不扫不动)
307
+ const want = S.get_str(S.load(), "qr_request") === "1";
308
+ if (qrCancel || ((snap.has_token() && !health.expired()) && !want)) return;
309
+ qr_started = now();
310
+ const c = runQrFlow(
311
+ { wire: new WireCls(S.dump_dir()), loadState: S.load, saveState: S.save, sleep: deps.sleep ?? undefined },
312
+ (ev) => {
313
+ if (ev.kind === "ready") {
314
+ set_keys({
315
+ // 出码即持有归属:屏幕上这张码由我出,扫它就绑我(防 A/B 两协程
316
+ // 先后落地导致「显示的码属于 A、qr_owner 指向 B」的错位)
317
+ // 出码即持有归属:屏幕上这张码由我出,扫它就绑我(本 ext 作为
318
+ // 归属方的键 = 当前 qr_pending,原样保留,不改写)
319
+
320
+ qrcode_grid: JSON.stringify(serialize_grid(qr_grid(ev.url))),
321
+ qr_error: null,
322
+ qr_timeout: null, // 新码落地 = 超时态结束(壳 002 态3 → 态1)
323
+ qr_request: null, // 一次点击只出一张码(不再无限续)
324
+ });
325
+ } else if (ev.kind === "already_bound") {
326
+ // 微信复述「早就绑着」——不是本次扫码的结果,不绑定、不亮灯(002·绑定3)
327
+ console.error("[wxbridge] 微信侧报告该 bot 已存在绑定(本次未扫码),继续等扫码");
328
+ } else if (ev.kind === "retry") {
329
+ // 状态轮询偶发超时:码仍然有效,**不要**写 qr_error(壳会白遮二维码)
330
+ console.error(`[wxbridge] 扫码状态重试:${ev.message}`);
331
+ } else if (ev.kind === "error") {
332
+ set_keys({ qr_error: ev.message }); // 壳弹窗态提示
333
+ } else if (ev.kind === "success") {
334
+ // 扫码成功 = 本会话被绑定(002·绑定3/4):**sessionId 在这里落地**。
335
+ // 壳点 icon 只写 qr_owner(要码),不写 sessionId,所以灯在扫码前
336
+ // 不会提前亮(002·状态1 未扫描 = 未启用)。
337
+ // 会话文件路径的三级兜底(真机 2026-10-28:切会话绑定“第一次不成、
338
+ // 第二次成”):pi 在**首条消息前** getSessionFile() 是 undefined,
339
+ // 而壳的 fn_bar/badge 都按会话文件键核对——写 null 就永远匹配不上,
340
+ // 表现为扫码成功但 icon 不亮。q r_owner 正是宿主给的那个键,直接复用。
341
+ const ownerKey = S.get_str(S.load(), "qr_pending");
342
+ set_keys({
343
+ // 单变量模型:绑定 id = **PF 会话键**(点 icon 时宿主给的那把),
344
+ // 不是 pi 的 uuid —— 壳/列表/钩子全用同一把键比对
345
+ session_id: ownerKey ?? ownFile ?? ownKey ?? null,
346
+ session_file: null, // 单变量模型:不再存会话文件(壳只认 session_id)
347
+ qr_pending: null,
348
+ qr_request: null,
349
+ bind_ok: "1",
350
+ awaiting_bind: null,
351
+ qr_timeout: null,
352
+ qr_error: null,
353
+ });
354
+ qrCancel = null;
355
+ snap.refresh(true);
356
+ on_rebind();
357
+ } else if (ev.kind === "expired") {
358
+ // 002 §二维码展示区 态3:扫码超时(白遮旧码 + 框内刷新提示)
359
+ // 同时交还出码归属:一张码一次点击,弹窗关着不再无休止后台续码
360
+ set_keys({ qr_timeout: "1", qr_error: null, qr_pending: null });
361
+ qrCancel = null; // 死线到:下一拍自动续码
362
+ }
363
+ return !shutdown; // false = 接收端被丢 → 清 qrcode 退出
364
+ },
365
+ );
366
+ qrCancel = c;
367
+ // 协程结束(成功/死线/取消/begin 失败)一律释放运行标志;否则一次网络
368
+ // 失败就把出码永久堵死(qrCancel 非空 → 后面每拍早退)。
369
+ const release = () => { if (qrCancel === c) qrCancel = null; };
370
+ if (c && typeof c.done?.then === "function") c.done.then(release, release);
371
+ }
372
+
373
+ /** 会话删除校验(off 态 case-1):PF 说 sessionId 不存在 → 自动关服务 +
374
+ * 删除持有的 sessionId(002 主动断开)。通道未接(sessionExists=null)时跳过。 */
375
+ async function check_session_deleted(boundKey) {
376
+ if (checking || !sessionExists) return;
377
+ checking = true;
378
+ try {
379
+ const exists = await sessionExists(boundKey);
380
+ if (exists === false && !shutdown) {
381
+ console.error(`[wxbridge] 会话 ${boundKey} 已不存在:关停服务并清除绑定`);
382
+ stop_service(true, "session-deleted");
383
+ set_keys({ session_id: null, session_expired: null });
384
+ snap.refresh(true);
385
+ }
386
+ } catch (e) {
387
+ console.error(`[wxbridge] 会话校验失败:${e?.message ?? e}`); // 下个周期再试
388
+ } finally {
389
+ checking = false;
390
+ }
391
+ }
392
+
393
+ /** 一拍:冲通知 → 门控 → off 态 → 会话校验 → 该起则起 → 泵批 → 健康采样。 */
394
+ function tick() {
395
+ tickCount += 1;
396
+ // ⚡启停通知冲刷放最前:off / 失门等早退路径也要走(降沿「已失联」直发、
397
+ // 升沿「已连接」入队都靠这里;置位晚于本拍的下一拍冲,200ms 无感)
398
+ // 实际发出即归位心跳节拍(出站口径:⚡通知也是投递活动)
399
+ if (hook.flush_notice().length > 0) last_heartbeat = now();
400
+ snap.refresh(false); // mtime 友好:没变不重读
401
+ const st = S.load();
402
+ const boundKey = S.get_str(st, "session_id");
403
+ const pageId = S.get_str(st, "page_id");
404
+ const pendingKey = S.get_str(st, "qr_pending");
405
+ const enabled = S.get_str(st, "enabled");
406
+
407
+ // 壳卸载标记(002·通知②;PF 面板停用不回调 session_shutdown 的补信号):
408
+ // 水位 = 本进程出生时刻(BOOT_TS,模块加载即定格,早于 session_start)。壳
409
+ // 写的 unload_ts 比它新 = 插件被停用 → 主动关停 + 宣告「已失联」+ 释放锁
410
+ //(重载秒连),本进程进 unloaded 终态——面板再启用 = 新 runtime(新
411
+ // BOOT_TS),本进程不复活。不用盘上快照比序号:快照本身会晚于壳写
412
+ //(attach 重启竞态:新 ext 的 session_start 落在壳写之后,照抄新值当
413
+ // 基线,检测永不触发——tc7 实测,服务反而在停用后起来);整文件回写
414
+ // 覆盖也会弄脏序号比对(unload_seq 真机从 16 次卸载被打回 "1")。
415
+ const uts = Number(S.get_str(st, "unload_ts") ?? 0);
416
+ if (!unloaded && uts > BOOT_TS) {
417
+ unloaded = true;
418
+ if (qrCancel) {
419
+ qrCancel();
420
+ qrCancel = null;
421
+ }
422
+ stop_service(true, "shell-unload");
423
+ }
424
+ if (unloaded) return;
425
+
426
+ // 门控(双口径):绑定键可能是 pi sessionId(uuid)或会话文件路径——
427
+ // pi 进程重启后 uuid 会漂、首条消息前 getSessionFile() 是 undefined,
428
+ // 单一口径必有失配窗口。门控结果落镜像:
429
+ // active → bound_current=1(仅标记身份,壳不看它)
430
+ // 失绑 → 仅当**自己之前是激活方**才清 bound_current(多会话 ext
431
+ // 共享一份镜像,没绑定过的不得清别人的 1 —— 防抖动)
432
+ // 单变量模型(schema/pf-hooks.json):只认 PF 给的两把 id——page_id(当前页)
433
+ // 与 session_id(已绑定)。ownKey/ownFile 仅用于“我是不是被点名的那页”。
434
+ const sameKey = (a, b) => {
435
+ if (!a || !b) return false;
436
+ const n = (x) => String(x).split(String.fromCharCode(92)).join("/").toLowerCase();
437
+ return n(a) === n(b);
438
+ };
439
+ const active = sameKey(boundKey, ownKey) || sameKey(boundKey, ownFile);
440
+ // 出码归属(002·绑定2):壳点 icon 写 qr_owner=会话键,**只**让该会话的
441
+ // ext 出码。出码 ≠ 绑定:本会话不因此变 bound_current,灯不亮、徽标不打。
442
+ const asked = sameKey(pendingKey, ownKey) || sameKey(pendingKey, ownFile);
443
+ if (active !== wasActive || (active && snap.bound_current !== "1")) {
444
+ if (active) {
445
+ // 只标「我是当前绑定方」;session_file 只由扫码成功写入。
446
+ // 旧 ext 每拍写 session_file 会把换绑结果写回旧会话
447
+ set_keys({ bound_current: "1" });
448
+ } else if (wasActive === true) {
449
+ set_keys({ bound_current: null });
450
+ }
451
+ }
452
+ wasActive = active;
453
+
454
+ // 门控:既不是被绑定会话、也没被点名出码 → 空转(渠道在跑则静默收起)
455
+ if (!active && !asked) {
456
+ if (svc.is_running()) stop_service(false, "gate");
457
+ if (qrCancel) {
458
+ qrCancel();
459
+ qrCancel = null;
460
+ }
461
+ return;
462
+ }
463
+ // 被点名出码但未绑定:只跑出码/扫码轮询,**不**起渠道、不泵消息
464
+ if (!active && asked) {
465
+ if (svc.is_running()) stop_service(false, "gate-asked");
466
+ if (enabled === "0") return;
467
+ qr_recycle(st); // 壳刷新会清码:不复活就空等死线(「刷新后一直生成中」)
468
+ ensure_qr_flow();
469
+ return;
470
+ }
471
+
472
+ // 用户把 switch 打开回来:Stopped 是"不再自动拉起"的终态
473
+ // (wants_start 对 stopped 恒 false)——不显式回 Pending 就永远不起渠道,
474
+ // 表现为「关了再开就不通信」。
475
+ if (svc.variant === "stopped" && enabled !== "0") {
476
+ svc.to_pending();
477
+ skip_backlog = true; // 关服务期间的积压不补发
478
+ announce_next = true; // ⚡②用户 off→on:重连后宣告「已连接」
479
+ }
480
+
481
+ // off 态(002 switch):显式关停,出码流一并撤
482
+ if (enabled === "0") {
483
+ if (svc.is_running()) stop_service(true, "switch-off");
484
+ if (qrCancel) {
485
+ qrCancel();
486
+ qrCancel = null;
487
+ }
488
+ return;
489
+ }
490
+ // 会话被删校验(周期;通道接入后生效)
491
+ if (sessionExists && boundKey && tickCount % (deps.sessionCheckTicks ?? SESSION_CHECK_TICKS) === 0) {
492
+ void check_session_deleted(boundKey);
493
+ }
494
+
495
+ // 换绑检测:渠道手里 token ≠ 盘上 token → 换绑感知重拉
496
+ const started = svc.started_token();
497
+ if (started && snap.token && started !== snap.token) {
498
+ on_rebind();
499
+ }
500
+ // 盘上 token 被清(壳重扫清理/旧版壳刷新)而渠道还敖着内存旧 token:
501
+ // 必须停 —— 否则僵尸轮询者继续收消息,ack 把刚清掉的路由字段整批复活
502
+ // 。
503
+ // 落 Pending(非用户关停):等扫码成功 on_rebind 后自起(无 token 不 start)。
504
+ if (svc.is_running() && !snap.has_token()) {
505
+ stop_service(false, "token-cleared");
506
+ }
507
+ // 该起则起(无 token 时起必然失败——等 QR 流出码、扫码成功
508
+ // on_rebind 后自起,不写 svc_error 噪音)
509
+ if (svc.wants_start() && snap.has_token()) {
510
+ start_service();
511
+ }
512
+ // 服务起来了就把上次的启动失败抹掉:svc_error 是**粘性**的,不清的话
513
+ // 一次「锁被抢」会把 icon 永久按灰。
514
+ if (svc.is_running() && S.get_str(S.load(), "svc_error")) {
515
+ set_keys({ svc_error: null });
516
+ snap.refresh(true);
517
+ }
518
+ // 泵批(try_recv 非阻塞,200ms 节拍)
519
+ const tr = svc.transport_handle();
520
+ if (tr && !tr.is_closed()) {
521
+ for (let batch = tr.try_recv(); batch; batch = tr.try_recv()) {
522
+ run_batches(tr, batch);
523
+ }
524
+ if (tr.session_dead()) {
525
+ // 服务侧已作废本 bot 会话:粘住,只能重扫(002 过期 → 壳灰 icon + 自动弹窗)
526
+ if (!health.expired()) {
527
+ health.set_expired();
528
+ set_keys({ session_expired: "1" });
529
+ ensure_qr_flow(); // 弹窗的码就绪
530
+ }
531
+ }
532
+ // 运行中心跳:距上次出站满节拍回一张状态卡(默认响应,防服务端「请稍后
533
+ // 重试」兜底)。泵拍驱动;发言/错误回执/⚡通知发出即归位(出站口径)。
534
+ if (ops.isRunning() && now() - last_heartbeat >= (deps.heartbeatMs ?? HEARTBEAT_MS)) {
535
+ last_heartbeat = now();
536
+ try {
537
+ tr.send(compose_status());
538
+ } catch (e) {
539
+ console.error(`[wxbridge] 心跳入队失败:${e?.message ?? e}`);
540
+ }
541
+ }
542
+ }
543
+ // qr_request:壳点 icon 要码(已绑定也要,扫了换绑)——不看 token 也要进
544
+ qr_recycle(st);
545
+ if (!snap.has_token() || health.expired() || S.get_str(st, "qr_request") === "1") ensure_qr_flow();
546
+ }
547
+
548
+ /** 整批处理完才 ack 游标(§6 坑 2:中途 ack 会丢消息)。 */
549
+ function run_batches(tr, batch) {
550
+ if (skip_backlog) {
551
+ // 恢复后的第一批 = off 期间的积压:只 ack 不处理(不补发、不回写上下文)
552
+ skip_backlog = false;
553
+ tr.ack_routing(batch);
554
+ tr.ack_cursor(batch);
555
+ return;
556
+ }
557
+ // 路由字段先行:回执在 route 时就出队(首条回执「没反应」根因)
558
+ tr.ack_routing(batch);
559
+ for (const m of batch.messages) {
560
+ try {
561
+ const action = inject.route(m.text ?? "");
562
+ inject.run_action(action, ops);
563
+ } catch (e) {
564
+ // 单条路由失败不弃批(游标还没 ack,弃了反而重收风暴)
565
+ console.error(`[wxbridge] 入站处理失败:${e?.message ?? e}`);
566
+ }
567
+ }
568
+ tr.ack_cursor(batch);
569
+ }
570
+
571
+ // ── 事件接线 ──────────────────────────────────────────────
572
+
573
+ pi.on("session_start", (event, sessionCtx) => {
574
+ ctx = sessionCtx;
575
+ shutdown = false;
576
+ // 卸载水位:出生前落盘的标记属上一轮生命周期,忽略;出生后落进来的
577
+ //(含模块加载与 session_start 之间的窗口)= 本进程已被停用,当场自我
578
+ // 隔离——渠道绝不允许在停用后起来(tc7 实测:boot 期吃标记的 ext 会
579
+ // 照常 spawn 服务,插件已停而渠道照跑)。
580
+ const bootMark = Number(S.get_str(S.load(), "unload_ts") ?? 0);
581
+ if (bootMark > BOOT_TS) {
582
+ unloaded = true;
583
+ note("boot-after-unload");
584
+ }
585
+ // unloaded 进程内粘性,不在此重置:同进程 runtime 替换(session_start 重入)
586
+ // 不得让已停用 ext 复活渠道;重新启用 = attach 重启挂载 = 新进程。
587
+ // 自身身份(004 §7-1):pi ReadonlySessionManager.getSessionId()
588
+ ownKey = ownKeyOf(sessionCtx);
589
+ ownFile = (() => {
590
+ try {
591
+ return sessionCtx?.sessionManager?.getSessionFile?.() ?? null;
592
+ } catch {
593
+ return null;
594
+ }
595
+ })() ?? (deps.argv ? session_file_from_argv(deps.argv) : session_file_from_argv());
596
+ line.key = ownKey;
597
+ line.name = sessionCtx?.sessionManager?.getSessionName?.() ?? ownKey;
598
+ snap.refresh(true);
599
+ // 不在此起渠道:泵首拍门控通过才起(未绑定会话进程永不起 transport)
600
+ pumpTimer = setInterval(() => {
601
+ try {
602
+ tick();
603
+ } catch (e) {
604
+ console.error(`[wxbridge] 泵拍失败:${e?.message ?? e}`);
605
+ }
606
+ }, deps.tickMs ?? TICK_MS);
607
+ if (pumpTimer.unref) pumpTimer.unref();
608
+ });
609
+
610
+ pi.on("agent_start", () => {
611
+ run_started_at = now();
612
+ last_heartbeat = now(); // 节拍从本轮起算:首张卡 = 距运行起点满一节拍
613
+ });
614
+
615
+ pi.on("message_end", (event) => {
616
+ // observe-only:滤工具调用提取发言 → 分块 → 出站队列(不替换消息)
617
+ // 有实际出站即归位心跳节拍(出站口径:发言也算投递活动)
618
+ if (hook.on_message_end(event.message).length > 0) last_heartbeat = now();
619
+ });
620
+
621
+ pi.on("agent_settled", (event) => {
622
+ // 运行彻底落定(自动重试/续跑全停):异常停止的错误补发微信
623
+ // (2026-11-01 buglist#2——错误只上桌面、微信一片沉默,用户只看到没反应)
624
+ // 补发成功同样归位节拍
625
+ if (hook.on_agent_settled(event?.aborted === true).length > 0) last_heartbeat = now();
626
+ });
627
+
628
+ pi.on("session_shutdown", () => {
629
+ // 幂等收:重复调用安全(pi 重试/替换可能多次触发)
630
+ shutdown = true;
631
+ if (pumpTimer) {
632
+ clearInterval(pumpTimer);
633
+ pumpTimer = null;
634
+ }
635
+ if (qrCancel) {
636
+ qrCancel();
637
+ qrCancel = null;
638
+ }
639
+ // ⚡「已失联」(002·通知②):面板卸载/进程终结 = 服务关停。服务在跑(was_on)
640
+ // 就宣告(stop_notice:002 定案,不再要求连接宣告已发出);transport 正要停,
641
+ // 降沿走直发(路由五件在盘上,不依赖渠道)。
642
+ const was_on = svc.is_running();
643
+ note(`shutdown user=true was_on=${was_on}`);
644
+ hook.on_stop(true, was_on);
645
+ const tr = svc.transport_handle();
646
+ tr?.stop(); // 幂等,不等在途 90s
647
+ svc.to_pending(); // 会话替换后新 runtime 重新拉起
648
+ hook.flush_notice(); // 无下一拍:当场冲(升沿无处入队自灭,降沿直发)
649
+ ctx = null;
650
+ ownKey = null;
651
+ ownFile = null;
652
+ });
653
+ };
654
+ }
655
+
656
+ /** 生产入口。 */
657
+ const wechat_bridge = create_entry();
658
+ export default wechat_bridge;