@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/pi/state.mjs ADDED
@@ -0,0 +1,248 @@
1
+ // state —— 状态盘最小面(transport 运行所需),自 wxprobe src/state.rs 移植。
2
+ // 运行态机(Service/Health/WxLine/Snapshot)在文件后半段:自 PF 仓
3
+ // remote_control/state.rs 移植(七字段快照 + mtime 友好,PF壳只读镜像)。
4
+ //
5
+ // **落盘位置 = 包内 data/**(约定①):ext 从包内路径被 -e 挂载,自定位
6
+ // `__dirname/../data/` = 状态目录(与宿主 ChatHost dir 同源,卸载随删——
7
+ // 重装即重绑,语义干净)。锁是唯一例外:必须在全局(lock.mjs 自述)。
8
+ //
9
+ // 游标持久化遵循 ZCode bugfix:**等本批消息全部处理完再写**(transport.ack_cursor),
10
+ // 先写游标会让进程中途失败时跳过未处理消息(weixinChannelRuntime.ts:155-163)。
11
+
12
+ import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { parse } from "./jsonx.mjs";
16
+
17
+ const pkgDir = join(dirname(fileURLToPath(import.meta.url)), "..");
18
+ const dataDir = join(pkgDir, "data");
19
+
20
+ /** 包内状态目录(约定①)。 */
21
+ export function base_dir() {
22
+ return dataDir;
23
+ }
24
+
25
+ export function state_path() {
26
+ return join(dataDir, "wx-state.json");
27
+ }
28
+
29
+ export function dump_dir() {
30
+ return join(dataDir, "dump");
31
+ }
32
+
33
+ /** 坏 JSON / 缺文件 → {}(宿主把坏盘落成空串,兜底是插件的责任)。 */
34
+ export function load() {
35
+ try {
36
+ const raw = readFileSync(state_path(), "utf8");
37
+ const v = parse(raw);
38
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
39
+ } catch {
40
+ return {};
41
+ }
42
+ }
43
+
44
+ /** 原子写(tmp + rename),避免半截 JSON。 */
45
+ export function save(state) {
46
+ try {
47
+ mkdirSync(dataDir, { recursive: true });
48
+ const tmp = `${state_path()}.${process.pid}.tmp`;
49
+ writeFileSync(tmp, JSON.stringify(state, null, 2));
50
+ renameSync(tmp, state_path());
51
+ } catch (e) {
52
+ console.error(`[wxbridge] 写状态失败: ${e?.message ?? e}`);
53
+ }
54
+ }
55
+
56
+ /** trim + 空串视同缺失(wxprobe get_str 同语义)。 */
57
+ export function get_str(state, key) {
58
+ const v = state?.[key];
59
+ if (typeof v !== "string") return undefined;
60
+ const s = v.trim();
61
+ return s === "" ? undefined : s;
62
+ }
63
+
64
+ /** set(state, key, null) = 删除键。 */
65
+ export function set(state, key, value) {
66
+ if (value === null || value === undefined) delete state[key];
67
+ else state[key] = value;
68
+ }
69
+
70
+ /** 新生成的 client_id(对齐 ZCode `zcode-weixin-<uuid>` 形态)。 */
71
+ export function ensure_client_id(state) {
72
+ const existing = get_str(state, "client_id");
73
+ if (existing) return existing;
74
+ const id = `wxbridge-${process.pid.toString(16).padStart(8, "0")}-${(Date.now() & 0xffffffff).toString(16).padStart(8, "0")}`;
75
+ set(state, "client_id", id);
76
+ return id;
77
+ }
78
+
79
+ /** 供排障/测试:整目录拆除(卸载由宿主做,这里只服务自检)。 */
80
+ export function reset_for_test() {
81
+ try { rmSync(join(dataDir, "wx-state.json"), { force: true }); } catch { /* 尽力而为 */ }
82
+ }
83
+
84
+ // ══════════════════════════════════════════════════════════════════
85
+ // 运行态机(remote_control/state.rs 移植):每个状态只有一个真身,
86
+ // 别的模块只读、只走这里的方法改。
87
+
88
+ /** 状态盘 mtime(ms);文件不存在 = null(每拍重试探)。 */
89
+ function default_stat() {
90
+ try {
91
+ return statSync(state_path()).mtimeMs;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /** 启动失败的重试间隔:开机那次若撞上「轮询锁被另一个实例占着 / token 还没
98
+ * 写进盘」,不该整个进程生命周期都不再试。 */
99
+ export const SERVICE_RETRY_MS = 30_000;
100
+
101
+ /** 渠道生命周期。**句柄就在 On 里**——「在跑」= 句柄在,没有第二个 bool
102
+ * 可以撒谎。变体:pending / on / stopped / failed。 */
103
+ export class Service {
104
+ constructor() {
105
+ this.variant = "pending";
106
+ this.transport = null; // On:渠道句柄
107
+ this.token = null; // On:句柄实际持有的 token(换绑检测唯一比对源)
108
+ this.failed_at = 0; // Failed:失败时刻(ms)
109
+ this.err_msg = null; // Failed:失败文案(状态卡④;成功即清)
110
+ }
111
+
112
+ is_running() {
113
+ return this.variant === "on";
114
+ }
115
+
116
+ transport_handle() {
117
+ return this.variant === "on" ? this.transport : null;
118
+ }
119
+
120
+ /** 运行中渠道手里那份 token(换绑检测的唯一比对源)。 */
121
+ started_token() {
122
+ return this.variant === "on" ? this.token : null;
123
+ }
124
+
125
+ /** 本拍该不该起渠道(唯一的「起不起」判据)。 */
126
+ wants_start(now = Date.now()) {
127
+ switch (this.variant) {
128
+ case "pending":
129
+ return true;
130
+ case "failed":
131
+ return now - this.failed_at >= SERVICE_RETRY_MS;
132
+ default: // on / stopped
133
+ return false;
134
+ }
135
+ }
136
+
137
+ /** 失败文案(状态卡④;成功即清)。 */
138
+ error() {
139
+ return this.variant === "failed" ? this.err_msg : null;
140
+ }
141
+
142
+ to_pending() {
143
+ this.variant = "pending";
144
+ this.transport = null;
145
+ this.token = null;
146
+ }
147
+
148
+ to_on(transport, token) {
149
+ this.variant = "on";
150
+ this.transport = transport;
151
+ this.token = token;
152
+ this.err_msg = null;
153
+ }
154
+
155
+ to_stopped() {
156
+ this.variant = "stopped";
157
+ this.transport = null;
158
+ this.token = null;
159
+ }
160
+
161
+ to_failed(at_ms, err) {
162
+ this.variant = "failed";
163
+ this.transport = null;
164
+ this.token = null;
165
+ this.failed_at = at_ms;
166
+ this.err_msg = err;
167
+ }
168
+ }
169
+
170
+ /** 链路健康度:只有「正常 / 服务端已作废本 bot 会话」两态。
171
+ * (长轮询的偶发报错只进 stderr,不是产品状态——2026-10-25 定。)
172
+ * Expired 粘性:置位后只能重新扫码解除(入口串接在重绑成功时清)。 */
173
+ export class Health {
174
+ constructor() {
175
+ this.expired_flag = false;
176
+ }
177
+
178
+ expired() {
179
+ return this.expired_flag;
180
+ }
181
+
182
+ set_expired() {
183
+ this.expired_flag = true;
184
+ }
185
+
186
+ clear() {
187
+ this.expired_flag = false;
188
+ }
189
+ }
190
+
191
+ /** 微信线指针:`key` = 本进程会话身份(pi getSessionId,门控比对源);
192
+ * `name` = 会话名。绑定指针的真身在镜像 `session_id` 键(壳侧单写入口),
193
+ * 这里只是进程内缓存。 */
194
+ export class WxLine {
195
+ constructor() {
196
+ this.key = null;
197
+ this.name = null;
198
+ }
199
+
200
+ is(key) {
201
+ return this.key === key;
202
+ }
203
+ }
204
+
205
+ /** `wx-state.json` 的进程内镜像(门控字段 + mtime)。泵一拍 refresh 一次,
206
+ * 壳/门控只读镜像——门控路径不再额外碰盘。 */
207
+ export class Snapshot {
208
+ #stat;
209
+ #load;
210
+ #mtime = null;
211
+
212
+ constructor(deps = {}) {
213
+ this.token = undefined;
214
+ this.session_id = undefined; // 壳侧键:被绑定的会话 key
215
+ this.bound_current = undefined; // ext 门控结果:"1" = 本会话是被绑定会话
216
+ this.enabled = undefined; // 壳侧键:"0" = 服务关
217
+ this.chat_id = undefined;
218
+ this.user_id = undefined;
219
+ this.#stat = deps.stat ?? default_stat;
220
+ this.#load = deps.load ?? load;
221
+ }
222
+
223
+ /** 盘 → 镜像。`force` = 不看 mtime(刚写完盘时用)。
224
+ * 返回 true = 内容可能变了。 */
225
+ refresh(force = false) {
226
+ const mtime = this.#stat();
227
+ if (!force && mtime !== null && mtime === this.#mtime) return false;
228
+ this.#mtime = mtime;
229
+ const st = this.#load();
230
+ // 与 transport require_token 同口径(空串 = 没 token)
231
+ this.token = get_str(st, "bot_token");
232
+ this.session_id = get_str(st, "session_id");
233
+ this.bound_current = get_str(st, "bound_current");
234
+ this.enabled = get_str(st, "enabled");
235
+ this.chat_id = get_str(st, "chat_id");
236
+ this.user_id = get_str(st, "user_id");
237
+ return true;
238
+ }
239
+
240
+ has_token() {
241
+ return this.token !== undefined;
242
+ }
243
+
244
+ /** 对端投递路由就绪(`chat_id` / `user_id` 随首条入站消息回写)。 */
245
+ routed() {
246
+ return this.chat_id !== undefined || this.user_id !== undefined;
247
+ }
248
+ }
@@ -0,0 +1,138 @@
1
+ // state 运行态机单测 —— remote_control/state.rs + mod.rs 服务状态机测试镜像。
2
+ // 跑法:node --test packages/wechat-bridge/pi/state.test.mjs
3
+
4
+ import { test } from "node:test";
5
+ import assert from "node:assert/strict";
6
+
7
+ import { Service, Health, WxLine, Snapshot, SERVICE_RETRY_MS } from "./state.mjs";
8
+
9
+ // ---------------------------------------------------------------- Service 四变体
10
+
11
+ test("service_pending_wants_start", () => {
12
+ const s = new Service();
13
+ assert.equal(s.variant, "pending", "新实例待启动(泵一拍按开关决定起不起)");
14
+ assert.equal(s.is_running(), false);
15
+ assert.equal(s.wants_start(), true);
16
+ assert.equal(s.transport_handle(), null);
17
+ assert.equal(s.started_token(), null);
18
+ assert.equal(s.error(), null);
19
+ });
20
+
21
+ test("service_on_holds_handle_and_token", () => {
22
+ const s = new Service();
23
+ const tr = { send() {} };
24
+ s.to_on(tr, "TOKEN");
25
+ assert.equal(s.is_running(), true);
26
+ assert.equal(s.transport_handle(), tr, "句柄就在 On 里,没有第二个 bool");
27
+ assert.equal(s.started_token(), "TOKEN");
28
+ assert.equal(s.wants_start(), false, "在跑不该再起");
29
+ assert.equal(s.error(), null, "成功即清");
30
+ });
31
+
32
+ test("service_failed_retries_after_cooldown", () => {
33
+ // mod.rs failed_service_retries_after_cooldown 镜像:开机那次撞上
34
+ // 「锁被占 / token 没写」不该整个进程都不再试。
35
+ const s = new Service();
36
+ const now = 1_000_000;
37
+ s.to_failed(now, "x");
38
+ assert.equal(s.is_running(), false);
39
+ assert.equal(s.wants_start(now), false, "刚失败不立刻重试");
40
+ assert.equal(s.wants_start(now + SERVICE_RETRY_MS), true, "冷却到点允许重试");
41
+ assert.equal(s.wants_start(now + 31_000), true);
42
+ assert.equal(s.error(), "x", "失败文案供状态卡④");
43
+ });
44
+
45
+ test("service_stopped_waits_for_explicit_start", () => {
46
+ const s = new Service();
47
+ s.to_stopped();
48
+ assert.equal(s.is_running(), false);
49
+ assert.equal(s.wants_start(), false, "用户显式关停后本进程不再自动拉起");
50
+ });
51
+
52
+ // ---------------------------------------------------------------- Health 粘性
53
+
54
+ test("health_expired_is_sticky_until_cleared", () => {
55
+ const h = new Health();
56
+ assert.equal(h.expired(), false);
57
+ h.set_expired();
58
+ assert.equal(h.expired(), true, "-14 洗完旧游标仍是它 = 服务端作废,粘住");
59
+ h.clear(); // 只在重绑成功时清(入口串接职责)
60
+ assert.equal(h.expired(), false);
61
+ });
62
+
63
+ // ---------------------------------------------------------------- WxLine
64
+
65
+ test("wx_line_pointer", () => {
66
+ const line = new WxLine();
67
+ assert.equal(line.key, null);
68
+ assert.equal(line.is("draft-1"), false);
69
+ line.key = "draft-1";
70
+ line.name = "pf-wxbot-1760000000000000";
71
+ assert.equal(line.is("draft-1"), true);
72
+ assert.equal(line.is("draft-2"), false);
73
+ });
74
+
75
+ // ---------------------------------------------------------------- Snapshot 七字段 + mtime
76
+
77
+ function mk_snapshot(initial, { mtime = 100, changed = null } = {}) {
78
+ let current = initial;
79
+ let currentMtime = mtime;
80
+ return {
81
+ snap: new Snapshot({
82
+ stat: () => currentMtime,
83
+ load: () => current,
84
+ }),
85
+ /** 模拟外部(transport ack/壳)写盘。 */
86
+ write(next, nextMtime) {
87
+ current = next;
88
+ currentMtime = nextMtime ?? mtime + 1;
89
+ },
90
+ };
91
+ }
92
+
93
+ test("snapshot_reads_fields_with_empty_folding", () => {
94
+ const { snap } = mk_snapshot({
95
+ bot_token: "T",
96
+ session_id: "sA",
97
+ enabled: "0",
98
+ chat_id: "room@chat",
99
+ user_id: "",
100
+ });
101
+ assert.equal(snap.refresh(true), true, "force 首刷");
102
+ assert.equal(snap.has_token(), true);
103
+ assert.equal(snap.session_id, "sA", "绑定指针(壳侧键)");
104
+ assert.equal(snap.enabled, "0", "开关(壳侧键)");
105
+ assert.equal(snap.chat_id, "room@chat");
106
+ assert.equal(snap.user_id, undefined, "空串视同缺失(get_str 口径)");
107
+ assert.equal(snap.routed(), true);
108
+ });
109
+
110
+ test("snapshot_empty_state", () => {
111
+ const { snap } = mk_snapshot({});
112
+ snap.refresh(true);
113
+ assert.equal(snap.has_token(), false);
114
+ assert.equal(snap.token, undefined);
115
+ assert.equal(snap.session_id, undefined, "未绑定");
116
+ assert.equal(snap.enabled, undefined, "开关缺省 = on");
117
+ assert.equal(snap.routed(), false, "chat_id/user_id 都缺 = 路由未就绪");
118
+ });
119
+
120
+ test("snapshot_mtime_gates_reread", () => {
121
+ const w = mk_snapshot({ bot_token: "T" }, { mtime: 100 });
122
+ assert.equal(w.snap.refresh(true), true);
123
+ // mtime 没变:不重读,返回 false(渲染不再碰盘的关键)
124
+ w.write({ bot_token: "CHANGED" }, 100); // 内容变了但 mtime 没动(异常路径)
125
+ assert.equal(w.snap.refresh(false), false);
126
+ assert.equal(w.snap.token, "T", "mtime 未变不重读");
127
+ // mtime 变了:重读并返回 true
128
+ w.write({ bot_token: "NEW" }, 200);
129
+ assert.equal(w.snap.refresh(false), true);
130
+ assert.equal(w.snap.token, "NEW");
131
+ });
132
+
133
+ test("snapshot_missing_file_retries_every_beat", () => {
134
+ // mtime = null(文件不存在)→ 永远重试(等首个入站回写路由字段)
135
+ const snap = new Snapshot({ stat: () => null, load: () => ({}) });
136
+ assert.equal(snap.refresh(false), true, "null 不命中缓存短路");
137
+ assert.equal(snap.refresh(false), true);
138
+ });