@mrrisega/dsh-remote 0.6.11 → 0.6.13

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.
Files changed (48) hide show
  1. package/clients/dsh-remote/dsh-bridge.mjs +123 -2
  2. package/clients/dsh-remote/mobile-adapter.mjs +108 -4
  3. package/clients/dsh-remote/test/bridge-gzip.test.mjs +68 -1
  4. package/clients/dsh-remote/test/mobile-adapter-late-frame.test.mjs +388 -0
  5. package/dsh-setup.mjs +55 -15
  6. package/package.json +4 -4
  7. package/packages/dsh-remote-web/lib/client.js +137 -0
  8. package/packages/dsh-remote-web/lib/fix-prompt.js +160 -0
  9. package/packages/dsh-remote-web/lib/index.js +748 -18
  10. package/packages/dsh-remote-web/lib/offline-update.mjs +66 -0
  11. package/packages/dsh-remote-web/lib/tarball-update.js +442 -0
  12. package/packages/dsh-remote-web/package.json +5 -1
  13. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +1572 -0
  14. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +1991 -0
  15. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +728 -0
  16. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +767 -0
  17. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim.mjs +163 -0
  18. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +1679 -0
  19. package/packages/dsh-remote-web/runtime/clients/dsh-remote/package.json +11 -0
  20. package/packages/dsh-remote-web/runtime/clients/dsh-remote/src/lifecycle.mjs +45 -0
  21. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +2825 -0
  22. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +1984 -0
  23. package/packages/dsh-remote-web/runtime/clients/dsh-web/native.html +2526 -0
  24. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +2390 -0
  25. package/packages/dsh-remote-web/runtime/node_modules/ws/LICENSE +20 -0
  26. package/packages/dsh-remote-web/runtime/node_modules/ws/README.md +548 -0
  27. package/packages/dsh-remote-web/runtime/node_modules/ws/browser.js +8 -0
  28. package/packages/dsh-remote-web/runtime/node_modules/ws/index.js +22 -0
  29. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/buffer-util.js +131 -0
  30. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/constants.js +19 -0
  31. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/event-target.js +292 -0
  32. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/extension.js +203 -0
  33. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/limiter.js +55 -0
  34. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/permessage-deflate.js +530 -0
  35. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/receiver.js +743 -0
  36. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/sender.js +607 -0
  37. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/stream.js +161 -0
  38. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/subprotocol.js +62 -0
  39. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/validation.js +152 -0
  40. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/websocket-server.js +562 -0
  41. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/websocket.js +1407 -0
  42. package/packages/dsh-remote-web/runtime/node_modules/ws/package.json +74 -0
  43. package/packages/dsh-remote-web/runtime/node_modules/ws/wrapper.mjs +21 -0
  44. package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +396 -0
  45. package/packages/dsh-remote-web/test/diag-report.test.mjs +236 -0
  46. package/packages/dsh-remote-web/test/fix-prompt.test.mjs +170 -0
  47. package/packages/dsh-remote-web/test/runtime-bundle.test.mjs +198 -0
  48. package/packages/dsh-remote-web/test/tarball-update.test.mjs +460 -0
@@ -0,0 +1,2825 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-remote 微信机器人通道(ilink)—— bridge 端协议客户端
4
+ *
5
+ * 依据 docs/wechat-bot-channel.md(设计已定稿)§3 协议 / §5 节点 / §8 安全 /
6
+ * §9 状态 / §10 绑定流程 / §11 待验证清单 实现。**零运行时依赖**(只用 node: 内置模块 +
7
+ * 全局 fetch)—— bridge 要跑在用户自己的电脑上,bundle 里不能多出任何第三方包。
8
+ *
9
+ * ── 本模块负责(且只负责)──
10
+ * 1. ilink 协议客户端:get_bot_qrcode / get_qrcode_status / notifystart / notifystop /
11
+ * getupdates / sendmessage。baseUrl 可注入(测试打本地 mock,仓库的
12
+ * scripts/test-net-guard.cjs 会拦掉一切非 loopback 请求)。
13
+ * 2. 扫码绑定状态机(§3 的 8 态,含 need_verifycode 回调式交互、expired×3、
14
+ * binded_redirect 视为成功、-14 冷却退避)。
15
+ * 3. 凭据落盘 <relayDir>/.wechat-account.json(0600 + Windows icacls 收紧,镜像
16
+ * dsh-setup.mjs / dsh-bridge.mjs 的 hardenFile)。
17
+ * 4. 面板状态文件 <relayDir>/.wechat-state.json(§9:**只有已绑定/未绑定两态**)。
18
+ * 5. 零依赖 QR 编码器 → SVG data URL(§10 第 3 步:面板与手机端复用同一接口,
19
+ * 所以编码器放 bridge 侧、不引第三方包)。
20
+ * 6. 通知文案格式化(§5 P0/P1 节点)+ 回执编号注册表 + 入站回复解析。
21
+ *
22
+ * ── 本模块**不做** ──
23
+ * - 不碰 dsh-bridge.mjs 的隧道/WS/事件流(接线是后续步骤,本模块只提供原语);
24
+ * - 不做自由文本对话(v1 明确不做,§1);
25
+ * - 不复用 E2EE 通道(§8:微信走 TLS、DSH 走 loopback,新增 E2eeSession.keyFor 的
26
+ * kind 会牵动浏览器侧字节级对等测试)。
27
+ *
28
+ * ── 安全约定(§8,测试逐条覆盖)──
29
+ * - token 只落 0600 文件,**绝不进任何日志行**(所有日志过 redact());
30
+ * - 对外(面板)只暴露 {bound, botId, boundAt},永不回显 token;
31
+ * - 解绑顺序:停轮询 → notifystop → 删凭据。
32
+ *
33
+ * 用法:node --test clients/dsh-remote/test/wechat-channel.test.mjs
34
+ */
35
+
36
+ import crypto from "node:crypto";
37
+ import fs from "node:fs";
38
+ import path from "node:path";
39
+ import { spawnSync } from "node:child_process";
40
+ import { fileURLToPath } from "node:url";
41
+
42
+ // ===========================================================================
43
+ // 0. 协议常量
44
+ // ===========================================================================
45
+
46
+ /** ilink 默认服务地址;可经 options.baseUrl 注入(测试指向 127.0.0.1 mock)。 */
47
+ export const DEFAULT_ILINK_BASE_URL = "https://ilinkai.weixin.qq.com";
48
+
49
+ /** get_bot_qrcode / get_qrcode_status 的 bot_type(腾讯插件该渠道构建固定为 "3")。 */
50
+ export const DEFAULT_BOT_TYPE = "3";
51
+
52
+ /** iLink-App-Id:腾讯插件取自身 package.json 的顶层 ilink_appid 字段,值为 "bot"。 */
53
+ export const ILINK_APP_ID = "bot";
54
+
55
+ /**
56
+ * iLink-App-ClientVersion:uint32,编码 0x00MMNNPP(major<<16 | minor<<8 | patch)。
57
+ * 腾讯自己的插件发的是**它自己的**版本(2.4.9 → 0x00020409 = 132105);我们不是它,
58
+ * 所以必须发一个**我们自己的、非零**的版本号 —— 留空会被服务端当成缺字段。
59
+ * 这里用本包版本(0.6.9 → 0x00000609 = 1545),并允许 DSH_WECHAT_CLIENT_VERSION 覆盖。
60
+ */
61
+ export const DEFAULT_CLIENT_VERSION = "0.6.9";
62
+
63
+ /** "1.2.3" → 0x00010203(= major<<16 | minor<<8 | patch,高 8 位固定为 0)。 */
64
+ export function buildClientVersion(version) {
65
+ const parts = String(version || "").split(".").map((p) => parseInt(p, 10));
66
+ const major = Number.isFinite(parts[0]) ? parts[0] : 0;
67
+ const minor = Number.isFinite(parts[1]) ? parts[1] : 0;
68
+ const patch = Number.isFinite(parts[2]) ? parts[2] : 0;
69
+ return (((major & 0xff) << 16) | ((minor & 0xff) << 8) | (patch & 0xff)) >>> 0;
70
+ }
71
+
72
+ /** 解析实际要发的 client version 数字(环境变量可覆盖,便于跟服务端灰度对齐)。 */
73
+ export function resolveClientVersion(raw) {
74
+ const v = raw ?? process.env.DSH_WECHAT_CLIENT_VERSION;
75
+ if (v === undefined || v === null || String(v).trim() === "") {
76
+ return buildClientVersion(DEFAULT_CLIENT_VERSION);
77
+ }
78
+ const s = String(v).trim();
79
+ const n = Number(s);
80
+ if (Number.isFinite(n) && /^\d+$/.test(s)) return n >>> 0;
81
+ return buildClientVersion(s) || buildClientVersion(DEFAULT_CLIENT_VERSION);
82
+ }
83
+
84
+ /**
85
+ * 数值协议常量 —— **逐个抄自归档参考实现**,不是猜的:
86
+ * /Users/mac/AIWorkSpace/myFreeWork/dsh-relay-internal/reference/openclaw-weixin/plugin-2.4.9/src/api/types.ts
87
+ * · MessageType (types.ts:64-68) NONE:0 / USER:1 / BOT:2
88
+ * · MessageItemType (types.ts:70-79) NONE:0 TEXT:1 IMAGE:2 VOICE:3 FILE:4 VIDEO:5
89
+ * TOOL_CALL_START:11 TOOL_CALL_RESULT:12
90
+ * · MessageState (types.ts:81-85) NEW:0 / GENERATING:1 / FINISH:2
91
+ * 组装位置:同仓库 src/messaging/send.ts:56-80(buildTextMessageReq:
92
+ * message_type = BOT、message_state = FINISH、item_list = [{type: TEXT, text_item:{text}}])。
93
+ * GetUpdatesResp 的字段名与 ret/errcode/errmsg/longpolling_timeout_ms 见 types.ts:219-232。
94
+ */
95
+ export const MessageType = Object.freeze({ NONE: 0, USER: 1, BOT: 2 });
96
+
97
+ export const MessageItemType = Object.freeze({
98
+ NONE: 0,
99
+ TEXT: 1,
100
+ IMAGE: 2,
101
+ VOICE: 3,
102
+ FILE: 4,
103
+ VIDEO: 5,
104
+ TOOL_CALL_START: 11,
105
+ TOOL_CALL_RESULT: 12
106
+ });
107
+
108
+ export const MessageState = Object.freeze({ NEW: 0, GENERATING: 1, FINISH: 2 });
109
+
110
+ /** 二维码长轮询的客户端超时(§3:`get_qrcode_status` 客户端 35s)。 */
111
+ export const QR_LONG_POLL_TIMEOUT_MS = 35_000;
112
+
113
+ /** getUpdates 长轮询默认客户端超时(参考实现 DEFAULT_LONG_POLL_TIMEOUT_MS)。 */
114
+ export const DEFAULT_UPDATES_TIMEOUT_MS = 35_000;
115
+
116
+ /** 普通请求超时(参考实现 DEFAULT_API_TIMEOUT_MS)。 */
117
+ export const DEFAULT_API_TIMEOUT_MS = 15_000;
118
+
119
+ /** 轻量请求超时(参考实现 DEFAULT_CONFIG_TIMEOUT_MS)。 */
120
+ export const DEFAULT_CONFIG_TIMEOUT_MS = 10_000;
121
+
122
+ /**
123
+ * errcode -14 / "session timeout" = token 失效或会话过期(§3、§7)。
124
+ * 腾讯官方插件把该账号**静默冷却 1 小时**(参考实现 src/api/session-guard.ts:
125
+ * SESSION_PAUSE_DURATION_MS = 60*60*1000)。我们必须同样退避,绝不打爆接口。
126
+ */
127
+ export const STALE_TOKEN_ERRCODE = -14;
128
+ export const SESSION_COOLDOWN_MS = 60 * 60 * 1000;
129
+
130
+ /** 二维码过期后的自动刷新上限(§3:最多 3 次)。 */
131
+ export const MAX_QR_REFRESH_COUNT = 3;
132
+
133
+ /** need_verifycode / verify_code_blocked 之后的重试上限(对齐参考实现)。 */
134
+ export const MAX_VERIFY_ATTEMPTS = 3;
135
+
136
+ /** 扫码状态的 8 态(§3)—— 未知状态一律走「可重试」分支,不崩。 */
137
+ export const QR_STATUSES = Object.freeze([
138
+ "wait",
139
+ "scaned",
140
+ "need_verifycode",
141
+ "confirmed",
142
+ "expired",
143
+ "scaned_but_redirect",
144
+ "verify_code_blocked",
145
+ "binded_redirect"
146
+ ]);
147
+
148
+ export const ACCOUNT_FILE = ".wechat-account.json";
149
+ export const STATE_FILE = ".wechat-state.json";
150
+
151
+ // ===========================================================================
152
+ // 1. 错误类型 + 脱敏
153
+ // ===========================================================================
154
+
155
+ /**
156
+ * 协议/流程错误。`code` 是稳定的机器可读标识,调用方据此分支:
157
+ * bad_options | http_error | bad_response_shape | unexpected_status |
158
+ * bind_missing_bot_id | bind_expired | verify_blocked | verify_timeout |
159
+ * session_expired | cooldown | network
160
+ */
161
+ export class WeChatError extends Error {
162
+ constructor(code, message, detail) {
163
+ super(message);
164
+ this.name = "WeChatError";
165
+ this.code = code;
166
+ if (detail !== undefined) this.detail = detail;
167
+ }
168
+ }
169
+
170
+ const TOKEN_PREFIX_LEN = 6;
171
+
172
+ /**
173
+ * 日志脱敏(§8「永不回显 token」)。三类输入都要能过:
174
+ * - 字符串:原样打回,但把**已知密钥字面量**替换掉(精确匹配,不搞正则误伤);
175
+ * - 对象:递归,敏感 key(见 SENSITIVE_KEYS)值替换为 "***";
176
+ * - undefined/null → 空串。
177
+ * 说明:准确率比覆盖率重要 —— 只要调用方把 token 交给它,就必须被抹掉;
178
+ * 因此除了字段名判断,还有一次「已知 token 字面量」的兜底替换(见 createRedactor)。
179
+ */
180
+ const SENSITIVE_KEYS = /^(token|bot_token|access_token|refresh_token|authorization|context_token|secret|password|qrcode|verify_code|key)$/i;
181
+
182
+ export function redact(value, known = []) {
183
+ const literals = (Array.isArray(known) ? known : [known]).filter(
184
+ (s) => typeof s === "string" && s.length >= 4
185
+ );
186
+ const maskLiterals = (s) => literals.reduce((acc, t) => acc.split(t).join(`${t.slice(0, TOKEN_PREFIX_LEN)}…<redacted>`), s);
187
+
188
+ const walk = (v, depth) => {
189
+ if (v == null) return v === null ? null : undefined;
190
+ if (typeof v === "string") return maskLiterals(v);
191
+ if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") return v;
192
+ if (depth > 4) return "<deep>";
193
+ if (Array.isArray(v)) return v.slice(0, 50).map((x) => walk(x, depth + 1));
194
+ if (v instanceof Error) return maskLiterals(`${v.name}: ${v.message}`);
195
+ if (typeof v === "object") {
196
+ const out = {};
197
+ for (const [k, val] of Object.entries(v)) {
198
+ out[k] = SENSITIVE_KEYS.test(k) ? "***" : walk(val, depth + 1);
199
+ }
200
+ return out;
201
+ }
202
+ return String(v);
203
+ };
204
+ return walk(value, 0);
205
+ }
206
+
207
+ /** token 的展示形态:只给前 6 字符 + 长度,绝不给全文(参考实现 redactToken)。 */
208
+ export function redactToken(token) {
209
+ if (!token) return "(none)";
210
+ const s = String(token);
211
+ if (s.length <= TOKEN_PREFIX_LEN) return `****(len=${s.length})`;
212
+ return `${s.slice(0, TOKEN_PREFIX_LEN)}…(len=${s.length})`;
213
+ }
214
+
215
+ /**
216
+ * 把**文本里**「敏感字段名 = 值」的值打掉。
217
+ *
218
+ * 为什么需要它:`redact()` 只在**对象**上按 key 判断,但真实日志/状态里密钥多半以
219
+ * **字符串**形态出现 —— `JSON.stringify({token:"…"})`、`token=…`、以及拼进错误信息里的 token。
220
+ * 这些字符串进 `redact()` 只会走「已知密钥字面量」那一支,于是**未登记(或登记之前)**的
221
+ * 密钥就原样落进日志与状态文件。
222
+ *
223
+ * 实测踩过两次:① `createLogger` 收到调用方已 stringify 的 JSON,字段名规则完全没生效;
224
+ * ② `markPush(ok, errText)` 把错误原文写进 `.wechat-state.json`,而错误里带着 token。
225
+ */
226
+ export function maskFieldsInText(text) {
227
+ const KEYS =
228
+ "token|bot_token|access_token|refresh_token|authorization|context_token|secret|password|verify_code|qrcode";
229
+ let s = String(text ?? "");
230
+ // 引号形态:`"token":"abc"` / `'token': 'abc'`
231
+ s = s.replace(
232
+ new RegExp(`(["']?)(${KEYS})\\1(\\s*[:=]\\s*)(["'])(?:(?!\\4)[\\s\\S])*?\\4`, "gi"),
233
+ (_m, q1, k, sep, q2) => `${q1}${k}${q1}${sep}${q2}***${q2}`
234
+ );
235
+ // 裸 k=v 形态:`token=abc123`(到空白/分隔符为止;已是 *** 的不动,保证幂等)
236
+ s = s.replace(new RegExp(`\\b(${KEYS})=([^\\s,;&"']+)`, "gi"), (_m, k) => `${k}=***`);
237
+ return s;
238
+ }
239
+
240
+ // ===========================================================================
241
+ // 2. 文件权限:0600 + Windows ACL 收紧
242
+ // ===========================================================================
243
+
244
+ /**
245
+ * 镜像 dsh-setup.mjs:272-287 / dsh-bridge.mjs:215-230 的 hardenFile
246
+ * (**未修改那两个文件**,此处是等价的最小实现,因为本模块必须零依赖、独立可测)。
247
+ *
248
+ * ⚠️ Windows 上 `{ mode: 0o600 }` 是**空操作**(Windows 用 ACL 不用 POSIX mode 位):
249
+ * 实测默认拿到的是用户目录的**继承 ACL**,任何按文件复制的场景(备份/同步盘/杀软
250
+ * 上报/崩溃转储/发给作者的支持包)都会连明文 bot_token 一起被带走。所以显式断开继承、
251
+ * 只授予当前用户;失败只警告一次,绝不影响主流程。
252
+ */
253
+ let hardenWarned = false;
254
+ export function hardenFile(file, onWarn) {
255
+ try {
256
+ fs.chmodSync(file, 0o600);
257
+ } catch {
258
+ /* POSIX 上失败不致命 */
259
+ }
260
+ if (process.platform !== "win32") return;
261
+ const who = [process.env.USERDOMAIN, process.env.USERNAME].filter(Boolean).join("\\");
262
+ // 两个环境变量都取不到时**绝不能静默返回**:那会让人以为文件已加固,实际仍是继承 ACL。
263
+ // 正常 Windows 会话不会走到这里(USERNAME 必然存在),但受限令牌/服务账户下有可能。
264
+ if (!who) {
265
+ if (!hardenWarned) {
266
+ hardenWarned = true;
267
+ const msg = `无法收紧文件权限(USERDOMAIN/USERNAME 均未设置):${file}`;
268
+ if (typeof onWarn === "function") onWarn(msg);
269
+ else console.warn(`⚠️ ${msg}`);
270
+ }
271
+ return;
272
+ }
273
+ let r;
274
+ try {
275
+ r = spawnSync("icacls", [file, "/inheritance:r", "/grant:r", `${who}:F`], {
276
+ windowsHide: true,
277
+ encoding: "utf8",
278
+ timeout: 8000
279
+ });
280
+ } catch (e) {
281
+ r = { status: -1, stderr: e.message };
282
+ }
283
+ if (r.status !== 0 && !hardenWarned) {
284
+ hardenWarned = true;
285
+ const msg = `收紧文件权限失败(${who}):${String(r.stderr || "").trim() || `icacls 退出码 ${r.status}`}`;
286
+ if (typeof onWarn === "function") onWarn(msg);
287
+ else console.warn(`⚠️ ${msg}`);
288
+ }
289
+ }
290
+
291
+ /** 原子写 + 0o600 + hardenFile。写失败不致命(调用方决定是否上报)。 */
292
+ export function writePrivateJson(file, data, onWarn) {
293
+ fs.mkdirSync(path.dirname(file), { recursive: true });
294
+ fs.writeFileSync(file, JSON.stringify(data, null, 2), { mode: 0o600 });
295
+ hardenFile(file, onWarn);
296
+ }
297
+
298
+ /** 读文件的 POSIX 权限位(Windows 上为 null,因为 mode 不可信)。 */
299
+ export function fileMode(file) {
300
+ try {
301
+ return fs.statSync(file).mode & 0o777;
302
+ } catch {
303
+ return null;
304
+ }
305
+ }
306
+
307
+ // ===========================================================================
308
+ // 3. 日志
309
+ // ===========================================================================
310
+
311
+ /**
312
+ * 极简日志器。默认**不写 stdout**(bridge 的 stdout 可能被守护进程捕获/落盘,
313
+ * 不是安全的地方),只把最近 200 行留在内存里给面板/排障用。
314
+ * 每一行都过 redact(..., known) —— known 里的密钥字面量不可能漏出去。
315
+ * 测试正是用 `logger.lines` 断言「token 从未出现在任何日志行」。
316
+ */
317
+ export function createLogger(opts = {}) {
318
+ const max = opts.maxLines ?? 200;
319
+ const lines = [];
320
+ const known = Array.isArray(opts.secrets) ? [...opts.secrets] : [];
321
+ const sink = typeof opts.sink === "function" ? opts.sink : null;
322
+
323
+ const log = (level, ...parts) => {
324
+ try {
325
+ const text = parts
326
+ .map((p) => (typeof p === "string" ? p : JSON.stringify(redact(p, known))))
327
+ .join(" ");
328
+ // ⚠️ 字符串 part 也必须过一遍**字段名**脱敏:调用方常常已经自己 JSON.stringify 了,
329
+ // 那种情况下 redact() 的对象分支根本不会被走到 —— 实测漏 token 的正是这条路径。
330
+ // 顺序:先按字段名抹值,再按已登记密钥抹字面量(两者互补,谁先谁后都不漏)。
331
+ const line = `[wechat] ${level} ${maskKnown(maskFieldsInText(text), known)}`;
332
+ lines.push(line);
333
+ if (lines.length > max) lines.splice(0, lines.length - max);
334
+ if (sink) sink(line);
335
+ } catch {
336
+ /* 日志失败不能影响业务 */
337
+ }
338
+ };
339
+ const maskKnown = (s, secrets) =>
340
+ secrets.filter((t) => typeof t === "string" && t.length >= 4).reduce(
341
+ (acc, t) => acc.split(t).join(`${t.slice(0, TOKEN_PREFIX_LEN)}…<redacted>`),
342
+ s
343
+ );
344
+
345
+ return {
346
+ lines,
347
+ /** 把新的密钥登记进脱敏集合(保存 / 收到 token 时调用)。 */
348
+ addSecret(secret) {
349
+ if (typeof secret === "string" && secret.length >= 4) known.push(secret);
350
+ },
351
+ debug: (...a) => log("debug", ...a),
352
+ info: (...a) => log("info", ...a),
353
+ warn: (...a) => log("warn", ...a),
354
+ error: (...a) => log("error", ...a)
355
+ };
356
+ }
357
+
358
+ // ===========================================================================
359
+ // 4. 协议客户端
360
+ // ===========================================================================
361
+
362
+ function ensureTrailingSlash(url) {
363
+ return String(url).endsWith("/") ? String(url) : `${String(url)}/`;
364
+ }
365
+
366
+ /** X-WECHAT-UIN:随机 uint32 → 十进制串 → base64(参考实现 api.ts:222-225)。 */
367
+ function randomWechatUin() {
368
+ return Buffer.from(String(crypto.randomBytes(4).readUInt32BE(0)), "utf-8").toString("base64");
369
+ }
370
+
371
+ /**
372
+ * `scaned_but_redirect` 给的 redirect_host 会直接拼进 URL 并携带 bot_token,
373
+ * 所以这里做一次收紧:只接受 https、只接受主机名(可带端口),任何路径/查询/凭据
374
+ * 一律拒绝 —— 未公开协议里这是「服务端可控输入」,不该无条件信任。
375
+ */
376
+ export function normalizeRedirectHost(host) {
377
+ const raw = String(host ?? "").trim();
378
+ if (!raw) return null;
379
+ let url;
380
+ try {
381
+ url = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
382
+ } catch {
383
+ return null;
384
+ }
385
+ if (url.protocol !== "https:") return null;
386
+ if (url.username || url.password) return null;
387
+ if (url.pathname !== "/" && url.pathname !== "") return null;
388
+ if (url.search || url.hash) return null;
389
+ if (!/^[a-z0-9.-]+(:\d+)?$/i.test(url.host)) return null;
390
+ return url.host;
391
+ }
392
+
393
+ /** 让"超时"这一控制流与其他网络错误可区分。 */
394
+ function timeoutSignal(ms, external) {
395
+ const controller = new AbortController();
396
+ const timer = ms > 0 ? setTimeout(() => controller.abort(), ms) : null;
397
+ const onExternal = () => controller.abort();
398
+ if (external) {
399
+ if (external.aborted) controller.abort();
400
+ else external.addEventListener("abort", onExternal, { once: true });
401
+ }
402
+ return {
403
+ signal: controller.signal,
404
+ cleanup() {
405
+ if (timer) clearTimeout(timer);
406
+ if (external) external.removeEventListener("abort", onExternal);
407
+ },
408
+ get timedOut() {
409
+ return controller.signal.aborted && !(external && external.aborted);
410
+ }
411
+ };
412
+ }
413
+
414
+ /**
415
+ * ilink 协议客户端。
416
+ *
417
+ * @param {object} [opts]
418
+ * @param {string} [opts.baseUrl] 注入式基址(测试指向 http://127.0.0.1:<port>)。
419
+ * @param {string} [opts.token] bot_token;设置后所有请求带 Authorization: Bearer。
420
+ * @param {number} [opts.clientVersion] iLink-App-ClientVersion(uint32)。
421
+ * @param {object} [opts.logger] createLogger() 产物;默认新建一个。
422
+ * @param {Function} [opts.fetch] 注入 fetch(测试可选;默认全局 fetch)。
423
+ */
424
+ export class IlinkClient {
425
+ constructor(opts = {}) {
426
+ this.baseUrl = String(opts.baseUrl || DEFAULT_ILINK_BASE_URL).replace(/\/+$/, "");
427
+ this.token = opts.token ? String(opts.token) : "";
428
+ this.botType = String(opts.botType || DEFAULT_BOT_TYPE);
429
+ this.clientVersion = resolveClientVersion(opts.clientVersion);
430
+ this.logger = opts.logger || createLogger();
431
+ this.fetchImpl = opts.fetch || ((...a) => globalThis.fetch(...a));
432
+ if (this.token) this.logger.addSecret(this.token);
433
+ }
434
+
435
+ /** 换基址(scaned_but_redirect 用)。 */
436
+ setBaseUrl(url) {
437
+ this.baseUrl = String(url).replace(/\/+$/, "");
438
+ }
439
+
440
+ /** 设置/更新 token(绑定成功后调用)。 */
441
+ setToken(token) {
442
+ this.token = token ? String(token) : "";
443
+ if (this.token) this.logger.addSecret(this.token);
444
+ }
445
+
446
+ /** 公共请求头(参考实现 api.ts:228-254)。 */
447
+ commonHeaders() {
448
+ return {
449
+ "iLink-App-Id": ILINK_APP_ID,
450
+ "iLink-App-ClientVersion": String(this.clientVersion)
451
+ };
452
+ }
453
+
454
+ headers() {
455
+ const h = {
456
+ "Content-Type": "application/json",
457
+ AuthorizationType: "ilink_bot_token",
458
+ "X-WECHAT-UIN": randomWechatUin(),
459
+ ...this.commonHeaders()
460
+ };
461
+ if (this.token) h.Authorization = `Bearer ${this.token}`;
462
+ return h;
463
+ }
464
+
465
+ /**
466
+ * 底层 JSON 请求。**不抛 HTTP/超时之外的错**:响应体解析失败一律
467
+ * `WeChatError("bad_response_shape")` —— 未公开协议下这是常见路径,不是异常。
468
+ *
469
+ * @returns {Promise<{json:any, raw:string, status:number}>}
470
+ */
471
+ async request({ method, endpoint, body, timeoutMs, label, abortSignal }) {
472
+ const url = new URL(endpoint, ensureTrailingSlash(this.baseUrl)).toString();
473
+ const t = timeoutSignal(timeoutMs ?? 0, abortSignal);
474
+ const init = {
475
+ method,
476
+ headers: method === "GET" ? this.commonHeaders() : this.headers(),
477
+ signal: t.signal
478
+ };
479
+ if (body !== undefined) init.body = typeof body === "string" ? body : JSON.stringify(body);
480
+ let res;
481
+ try {
482
+ res = await this.fetchImpl(url, init);
483
+ } catch (err) {
484
+ t.cleanup();
485
+ if (t.timedOut) throw new WeChatError("timeout", `${label}: 客户端超时(${timeoutMs}ms)`, { url });
486
+ throw new WeChatError("network", `${label}: 网络错误 ${redact(err.message, [this.token])}`, {
487
+ url,
488
+ code: err && err.code
489
+ });
490
+ }
491
+ t.cleanup();
492
+ let raw = "";
493
+ try {
494
+ raw = await res.text();
495
+ } catch (err) {
496
+ throw new WeChatError("network", `${label}: 读取响应失败`, { url });
497
+ }
498
+ if (!res.ok) {
499
+ throw new WeChatError("http_error", `${label}: HTTP ${res.status}`, { url, status: res.status });
500
+ }
501
+ let json;
502
+ try {
503
+ json = JSON.parse(raw);
504
+ } catch {
505
+ throw new WeChatError("bad_response_shape", `${label}: 响应不是合法 JSON`, {
506
+ url,
507
+ preview: redact(raw.slice(0, 200), [this.token])
508
+ });
509
+ }
510
+ return { json, raw, status: res.status };
511
+ }
512
+
513
+ /** 形状校验:必须是普通对象(数组/字符串/null 都算"形状不认识")。 */
514
+ static expectObject(json, label) {
515
+ if (json === null || typeof json !== "object" || Array.isArray(json)) {
516
+ throw new WeChatError(
517
+ "bad_response_shape",
518
+ `${label}: 响应形状不认识(期望对象,收到 ${json === null ? "null" : Array.isArray(json) ? "array" : typeof json})`
519
+ );
520
+ }
521
+ return json;
522
+ }
523
+
524
+ /**
525
+ * 取绑定二维码。
526
+ * POST {baseUrl}/ilink/bot/get_bot_qrcode?bot_type=3
527
+ * body {"local_token_list":[...]} —— 我们保留已绑过的 token 列表(空数组也算合法)。
528
+ * @returns {Promise<{qrcode:string, qrcode_img_content:string, raw:object}>}
529
+ */
530
+ async getBotQrcode({ localTokenList = [], botType = this.botType, timeoutMs = DEFAULT_API_TIMEOUT_MS, signal } = {}) {
531
+ const endpoint = `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`;
532
+ this.logger.info(`getBotQrcode: base=${this.baseUrl} bot_type=${botType} local_tokens=${localTokenList.length}`);
533
+ const { json } = await this.request({
534
+ method: "POST",
535
+ endpoint,
536
+ body: { local_token_list: localTokenList },
537
+ timeoutMs,
538
+ label: "getBotQrcode",
539
+ abortSignal: signal
540
+ });
541
+ const obj = IlinkClient.expectObject(json, "getBotQrcode");
542
+ if (typeof obj.qrcode !== "string" || !obj.qrcode) {
543
+ throw new WeChatError("bad_response_shape", "getBotQrcode: 响应缺 qrcode", {
544
+ keys: Object.keys(obj).slice(0, 20)
545
+ });
546
+ }
547
+ return {
548
+ qrcode: obj.qrcode,
549
+ qrcode_img_content: typeof obj.qrcode_img_content === "string" ? obj.qrcode_img_content : "",
550
+ ret: obj.ret,
551
+ raw: obj
552
+ };
553
+ }
554
+
555
+ /**
556
+ * 长轮询扫码状态(§3:客户端超时 35s)。
557
+ * GET {baseUrl}/ilink/bot/get_qrcode_status?qrcode=&verify_code=
558
+ *
559
+ * **超时或网络错误一律返回 {status:"wait"}**(可重试),绝不抛 —— 参考实现
560
+ * login-qr.ts:128-158 的语义,长轮询超时是正常控制流。
561
+ * 但**形状不认识**(非对象 / status 非字符串)要抛 WeChatError,好让上层区分
562
+ * 「服务端改协议了」与「这次没消息」。
563
+ */
564
+ async pollQrcodeStatus({ qrcode, verifyCode, timeoutMs = QR_LONG_POLL_TIMEOUT_MS, signal, baseUrl } = {}) {
565
+ if (typeof qrcode !== "string" || !qrcode) {
566
+ throw new WeChatError("bad_options", "pollQrcodeStatus: 缺少 qrcode");
567
+ }
568
+ let endpoint = `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`;
569
+ if (verifyCode) endpoint += `&verify_code=${encodeURIComponent(verifyCode)}`;
570
+ const client = baseUrl ? withBase(this, baseUrl) : this;
571
+ try {
572
+ const { json } = await client.request({
573
+ method: "GET",
574
+ endpoint,
575
+ timeoutMs,
576
+ label: "pollQrcodeStatus",
577
+ abortSignal: signal
578
+ });
579
+ const obj = IlinkClient.expectObject(json, "pollQrcodeStatus");
580
+ if (typeof obj.status !== "string" || !obj.status) {
581
+ throw new WeChatError("bad_response_shape", "pollQrcodeStatus: 响应缺 status", {
582
+ keys: Object.keys(obj).slice(0, 20)
583
+ });
584
+ }
585
+ return { ...obj, status: obj.status };
586
+ } catch (err) {
587
+ if (err instanceof WeChatError && (err.code === "timeout" || err.code === "network")) {
588
+ this.logger.debug(`pollQrcodeStatus: ${err.code}, 返回 wait 继续轮询`);
589
+ return { status: "wait", transient: true };
590
+ }
591
+ throw err;
592
+ }
593
+ }
594
+
595
+ /** 上报通道客户端上线。POST ilink/bot/msg/notifystart */
596
+ async notifyStart({ timeoutMs = DEFAULT_CONFIG_TIMEOUT_MS, signal } = {}) {
597
+ return this.notify("ilink/bot/msg/notifystart", "notifyStart", timeoutMs, signal);
598
+ }
599
+
600
+ /** 上报通道客户端下线。POST ilink/bot/msg/notifystop */
601
+ async notifyStop({ timeoutMs = DEFAULT_CONFIG_TIMEOUT_MS, signal } = {}) {
602
+ return this.notify("ilink/bot/msg/notifystop", "notifyStop", timeoutMs, signal);
603
+ }
604
+
605
+ async notify(endpoint, label, timeoutMs, signal) {
606
+ const { json } = await this.request({
607
+ method: "POST",
608
+ endpoint,
609
+ body: { base_info: this.baseInfo() },
610
+ timeoutMs,
611
+ label,
612
+ abortSignal: signal
613
+ });
614
+ const obj = IlinkClient.expectObject(json, label);
615
+ this.logger.debug(`${label}: ret=${obj.ret} errcode=${obj.errcode ?? ""}`);
616
+ return obj;
617
+ }
618
+
619
+ baseInfo() {
620
+ return { channel_version: DEFAULT_CLIENT_VERSION };
621
+ }
622
+
623
+ /**
624
+ * 长轮询收消息。POST ilink/bot/getupdates,body {get_updates_buf}。
625
+ * 返回 {msgs, get_updates_buf, longpolling_timeout_ms, ret, errcode, errmsg}。
626
+ *
627
+ * - 客户端超时 → 空响应(ret 0、msgs []、游标原样返回),让调用方直接重试;
628
+ * - `errcode -14` → **由调用方处理冷却**(见 ensureSessionCooldown);此处只回传,
629
+ * 除非 opts.throwOnSessionExpired 为 true。
630
+ */
631
+ async getUpdates({ buf = "", timeoutMs = DEFAULT_UPDATES_TIMEOUT_MS, signal, throwOnSessionExpired = false } = {}) {
632
+ let json;
633
+ try {
634
+ ({ json } = await this.request({
635
+ method: "POST",
636
+ endpoint: "ilink/bot/getupdates",
637
+ body: { get_updates_buf: buf ?? "", base_info: this.baseInfo() },
638
+ timeoutMs,
639
+ label: "getUpdates",
640
+ abortSignal: signal
641
+ }));
642
+ } catch (err) {
643
+ if (err instanceof WeChatError && err.code === "timeout") {
644
+ this.logger.debug(`getUpdates: 客户端超时(${timeoutMs}ms),空响应重试`);
645
+ return { msgs: [], get_updates_buf: buf ?? "", ret: 0, errcode: 0, errmsg: "", timedOut: true };
646
+ }
647
+ throw err;
648
+ }
649
+ const obj = IlinkClient.expectObject(json, "getUpdates");
650
+ const out = {
651
+ msgs: Array.isArray(obj.msgs) ? obj.msgs : [],
652
+ get_updates_buf: typeof obj.get_updates_buf === "string" ? obj.get_updates_buf : (buf ?? ""),
653
+ longpolling_timeout_ms:
654
+ Number.isFinite(obj.longpolling_timeout_ms) && obj.longpolling_timeout_ms > 0
655
+ ? obj.longpolling_timeout_ms
656
+ : undefined,
657
+ ret: Number.isFinite(obj.ret) ? obj.ret : 0,
658
+ errcode: Number.isFinite(obj.errcode) ? obj.errcode : 0,
659
+ errmsg: typeof obj.errmsg === "string" ? obj.errmsg : ""
660
+ };
661
+ if (out.errcode === STALE_TOKEN_ERRCODE || /session timeout/i.test(out.errmsg)) {
662
+ this.logger.warn(`getUpdates: errcode=${out.errcode} errmsg="${out.errmsg}" → token 失效/会话过期`);
663
+ if (throwOnSessionExpired) {
664
+ throw new WeChatError("session_expired", "getUpdates: session timeout(errcode -14)", out);
665
+ }
666
+ }
667
+ return out;
668
+ }
669
+
670
+ /**
671
+ * 发一条文本消息。POST ilink/bot/sendmessage
672
+ * body {msg:{from_user_id:"", to_user_id, client_id, message_type: BOT,
673
+ * message_state: FINISH, item_list:[{type: TEXT, text_item:{text}}]}}
674
+ * 字段与常量来源见本文件 MessageType / MessageItemType / MessageState 的注释。
675
+ */
676
+ async sendMessage({ to, text, clientId, timeoutMs = DEFAULT_API_TIMEOUT_MS, signal } = {}) {
677
+ if (!to) throw new WeChatError("bad_options", "sendMessage: 缺少 to(to_user_id)");
678
+ const msg = {
679
+ from_user_id: "",
680
+ to_user_id: String(to),
681
+ client_id: clientId || newClientId(),
682
+ message_type: MessageType.BOT,
683
+ message_state: MessageState.FINISH,
684
+ item_list: text ? [{ type: MessageItemType.TEXT, text_item: { text: String(text) } }] : []
685
+ };
686
+ const { json } = await this.request({
687
+ method: "POST",
688
+ endpoint: "ilink/bot/sendmessage",
689
+ body: { msg, base_info: this.baseInfo() },
690
+ timeoutMs,
691
+ label: "sendMessage",
692
+ abortSignal: signal
693
+ });
694
+ const obj = IlinkClient.expectObject(json, "sendMessage");
695
+ if (obj.ret && obj.ret !== 0) {
696
+ // 注意:errmsg 可能带上下文信息,过一遍 redact 再抛(内容里可能回显 token)。
697
+ throw new WeChatError("http_error", `sendMessage: ret=${obj.ret} errmsg=${redact(obj.errmsg || "(none)", [this.token])}`);
698
+ }
699
+ if (obj.errcode === STALE_TOKEN_ERRCODE) {
700
+ throw new WeChatError("session_expired", "sendMessage: session timeout(errcode -14)", obj);
701
+ }
702
+ return obj;
703
+ }
704
+ }
705
+
706
+ /** 用另一个 baseUrl 复用同一个 client 的配置(不改动 this,避免并发串台)。 */
707
+ function withBase(client, baseUrl) {
708
+ const c = new IlinkClient({
709
+ baseUrl,
710
+ token: client.token,
711
+ botType: client.botType,
712
+ clientVersion: client.clientVersion,
713
+ logger: client.logger,
714
+ fetch: client.fetchImpl
715
+ });
716
+ return c;
717
+ }
718
+
719
+ /** client_id:腾讯侧用它做去重,随机 uuid 即可。 */
720
+ export function newClientId() {
721
+ return crypto.randomUUID();
722
+ }
723
+
724
+ // ===========================================================================
725
+ // 5. 冷却退避(-14 / session timeout)
726
+ // ===========================================================================
727
+
728
+ /**
729
+ * 账号级冷却闸门(§3:腾讯官方插件把该账号静默冷却 1 小时)。
730
+ * 内存态即可 —— 冷却窗口跨重启丢失只会导致「多打一次接口」,不会更糟。
731
+ * 测试正是数 mock 的请求次数来证明「不会打爆」。
732
+ */
733
+ export class SessionCooldown {
734
+ constructor({ cooldownMs = SESSION_COOLDOWN_MS, clock = Date.now } = {}) {
735
+ this.cooldownMs = cooldownMs;
736
+ this.clock = clock;
737
+ this.until = 0;
738
+ this.reason = "";
739
+ }
740
+
741
+ /** 进入冷却;返回本次冷到什么时候。 */
742
+ arm(reason = "session timeout(errcode -14)") {
743
+ this.until = this.clock() + this.cooldownMs;
744
+ this.reason = reason;
745
+ return this.until;
746
+ }
747
+
748
+ active() {
749
+ return this.clock() < this.until;
750
+ }
751
+
752
+ remainingMs() {
753
+ return Math.max(0, this.until - this.clock());
754
+ }
755
+
756
+ /** 剩余分钟数(向上取整,用于文案)。 */
757
+ remainingMinutes() {
758
+ return Math.ceil(this.remainingMs() / 60_000);
759
+ }
760
+
761
+ clear() {
762
+ this.until = 0;
763
+ this.reason = "";
764
+ }
765
+
766
+ /** 在冷却期内的请求一律拒绝(调用点:notifystart / getupdates / sendmessage)。 */
767
+ assertActive() {
768
+ if (!this.active()) return;
769
+ throw new WeChatError(
770
+ "cooldown",
771
+ `账号冷却中:${this.reason},还剩约 ${this.remainingMinutes()} 分钟再试`
772
+ );
773
+ }
774
+ }
775
+
776
+ /**
777
+ * 判断某次响应是否代表「token 失效 / 会话过期」(errcode -14 或 errmsg session timeout)。
778
+ * 供 bridge 在 getupdates / sendmessage 的返回上统一判断,然后 arm 冷却。
779
+ */
780
+ export function isSessionExpired(resp) {
781
+ if (!resp || typeof resp !== "object") return false;
782
+ if (resp.errcode === STALE_TOKEN_ERRCODE || resp.ret === STALE_TOKEN_ERRCODE) return true;
783
+ return typeof resp.errmsg === "string" && /session timeout/i.test(resp.errmsg);
784
+ }
785
+
786
+ // ===========================================================================
787
+ // 6. 零依赖 QR 编码器(→ SVG data URL)
788
+ // ===========================================================================
789
+ /*
790
+ * 为什么自己写(§10 第 3 步 + 交付要求):
791
+ * 面板(桌面插件)和手机端网页都要渲染同一个二维码;把编码器放在 bridge 侧、
792
+ * 以 `data:image/svg+xml;base64,…` 形式下发,两个前端就能复用同一份实现,
793
+ * 浏览器 bundle 里**一个第三方包都不用加**(SVG 只是 <rect>,比 PNG 简单得多)。
794
+ *
795
+ * 实现范围:字节模式 + 纠错等级 M(交付要求的「至少 M」)+ 版本 1..40 自动选择。
796
+ * 结构:功能图形(finder/separator/timing/alignment/dark module/format/version info)
797
+ * → 数据按「两列一组、自下而上、蛇形」落位 → 8 种掩码算罚分取最优 → 定型。
798
+ * 校验:test/wechat-channel.test.mjs 用**独立参考实现(Nayuki qrcodegen,Python)
799
+ * 在开发期逐模块比对** + 模块内解码器往返(格式位/掩码/交错/RS 全部真解一遍)。
800
+ */
801
+
802
+ /** 纠错等级:仅 M(交付要求「至少 M」);format bits 的映射见 QR 规范表 25。 */
803
+ export const QR_EC_LEVEL_M = 0;
804
+
805
+ /**
806
+ * 版本 1..40 的「每块纠错码字数」(等级 M)。
807
+ * 数值原样取自 QR 规范中 M 列(经独立参考实现 Nayuki qrcodegen.py
808
+ * `_ECC_CODEWORDS_PER_BLOCK[1]` 交叉核对 —— 不是猜的)。
809
+ */
810
+ const QR_ECC_CODEWORDS_PER_BLOCK_M = [
811
+ 0, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28,
812
+ 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28
813
+ ];
814
+
815
+ /** 版本 1..40 的**纠错块数**(等级 M);同样取自 QR 规范 M 列(同上交叉核对)。 */
816
+ const QR_NUM_EC_BLOCKS_M = [
817
+ 0, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25,
818
+ 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49
819
+ ];
820
+
821
+ /**
822
+ * 版本 1..40 的**剩余位**数(数据区装不满 8 位码字时的补齐位数,规范表 1)。
823
+ * 直接用几何法算:剩余位 = 数据模块数 mod 8 —— 这样它永远和上面的功能图形定义一致
824
+ * (手抄这张表极易整体错位一个版本,V6 就会因此少算一个码字)。
825
+ */
826
+ const qrRemainderBits = (version) => dataModuleCount(version) % 8;
827
+
828
+ // ---- 矩阵构造(码字总数也要用它算,所以放在 qrBlocks 之前) ----
829
+
830
+ function makeMatrix(size) {
831
+ return {
832
+ size,
833
+ modules: Array.from({ length: size }, () => new Array(size).fill(false)),
834
+ fn: Array.from({ length: size }, () => new Array(size).fill(false))
835
+ };
836
+ }
837
+
838
+ function setFn(m, x, y, dark) {
839
+ if (x < 0 || y < 0 || x >= m.size || y >= m.size) return;
840
+ m.modules[y][x] = !!dark;
841
+ m.fn[y][x] = true;
842
+ }
843
+
844
+ function drawFinder(m, cx, cy) {
845
+ for (let dy = -4; dy <= 4; dy++) {
846
+ for (let dx = -4; dx <= 4; dx++) {
847
+ const dist = Math.max(Math.abs(dx), Math.abs(dy));
848
+ setFn(m, cx + dx, cy + dy, dist !== 2 && dist !== 4);
849
+ }
850
+ }
851
+ }
852
+
853
+ function drawAlignment(m, cx, cy) {
854
+ for (let dy = -2; dy <= 2; dy++) {
855
+ for (let dx = -2; dx <= 2; dx++) {
856
+ setFn(m, cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
857
+ }
858
+ }
859
+ }
860
+
861
+ /** 版本 1..40 的对齐图形中心坐标(规范附录 E)。 */
862
+ function qrAlignmentPositions(version) {
863
+ if (version === 1) return [];
864
+ const size = version * 4 + 17;
865
+ const numAlign = Math.floor(version / 7) + 2;
866
+ // 间距:把 [6, size-7] 均分成 numAlign-1 段,向上取最近偶数(版本 32 是规范里的特例)。
867
+ const step = version === 32 ? 26 : Math.ceil((size - 13) / (numAlign - 1) / 2) * 2;
868
+ const result = [6];
869
+ for (let pos = size - 7; result.length < numAlign; pos -= step) result.splice(1, 0, pos);
870
+ return result;
871
+ }
872
+
873
+ /**
874
+ * 只画功能图形(不含格式位/版本位之外的任何数据),用于**数出数据模块数**。
875
+ * 用途:算每版本码字总数 —— 规范公式在个别版本上容易记错,直接几何计数最稳。
876
+ */
877
+ function functionPatternMatrix(version) {
878
+ const m = makeMatrix(version * 4 + 17);
879
+ for (let i = 0; i < m.size; i++) {
880
+ setFn(m, 6, i, i % 2 === 0);
881
+ setFn(m, i, 6, i % 2 === 0);
882
+ }
883
+ drawFinder(m, 3, 3);
884
+ drawFinder(m, m.size - 4, 3);
885
+ drawFinder(m, 3, m.size - 4);
886
+ const pos = qrAlignmentPositions(version);
887
+ const n = pos.length;
888
+ for (let i = 0; i < n; i++) {
889
+ for (let j = 0; j < n; j++) {
890
+ if ((i === 0 && j === 0) || (i === 0 && j === n - 1) || (i === n - 1 && j === 0)) continue;
891
+ drawAlignment(m, pos[i], pos[j]);
892
+ }
893
+ }
894
+ // 格式位(两份,含 dark module)+ 版本位
895
+ for (let i = 0; i <= 5; i++) setFn(m, 8, i, false);
896
+ setFn(m, 8, 7, false);
897
+ setFn(m, 8, 8, false);
898
+ setFn(m, 7, 8, false);
899
+ for (let i = 9; i <= 14; i++) setFn(m, 14 - i, 8, false);
900
+ for (let i = 0; i <= 7; i++) setFn(m, m.size - 1 - i, 8, false);
901
+ for (let i = 8; i <= 14; i++) setFn(m, 8, m.size - 15 + i, false);
902
+ setFn(m, 8, m.size - 8, true);
903
+ if (version >= 7) {
904
+ for (let i = 0; i < 18; i++) {
905
+ const a = m.size - 11 + (i % 3);
906
+ const b = Math.floor(i / 3);
907
+ setFn(m, a, b, false);
908
+ setFn(m, b, a, false);
909
+ }
910
+ }
911
+ return m;
912
+ }
913
+
914
+ /** 可放数据的模块数 = 总模块数 − 功能图形模块数(含格式/版本位与 dark module)。 */
915
+ function dataModuleCount(version) {
916
+ const m = functionPatternMatrix(version);
917
+ let used = 0;
918
+ for (const row of m.fn) for (const c of row) if (c) used++;
919
+ return m.size * m.size - used;
920
+ }
921
+
922
+ const totalCodewordsCache = new Map();
923
+ /** 每版本码字总数 = (数据模块数 − 剩余位) ÷ 8。 */
924
+ function qrTotalCodewords(version) {
925
+ if (totalCodewordsCache.has(version)) return totalCodewordsCache.get(version);
926
+ const total = (dataModuleCount(version) - qrRemainderBits(version)) / 8;
927
+ totalCodewordsCache.set(version, total);
928
+ return total;
929
+ }
930
+
931
+ /** 把数据码字切成交错用的块(短块在前,与 QR 规范表 9 的排布一致)。 */
932
+ export function qrBlocks(version) {
933
+ if (!Number.isInteger(version) || version < 1 || version > 40) {
934
+ throw new WeChatError("bad_options", `QR: 版本越界 ${version}`);
935
+ }
936
+ const total = qrTotalCodewords(version);
937
+ const numBlocks = QR_NUM_EC_BLOCKS_M[version];
938
+ const ecLen = QR_ECC_CODEWORDS_PER_BLOCK_M[version];
939
+ const rawData = total - ecLen * numBlocks;
940
+ // QR 规范的块结构是「先短块、后长块」,短块数量 = 块数 − 余数、长块数量 = 余数
941
+ // (例如 V6-M:2 块 43 codewords + 2 块 42)。反过来写会构造出错误的块划分,
942
+ // 纠错码字随之全错 —— 但**总码字数仍然对得上**,所以容量自检发现不了它。
943
+ const shortLen = Math.floor(rawData / numBlocks);
944
+ const numLong = rawData % numBlocks;
945
+ const numShort = numBlocks - numLong;
946
+ return { total, numBlocks, ecLen, rawData, shortLen, numShort, longLen: shortLen + 1 };
947
+ }
948
+
949
+ /** 字节模式数据码字数(4 bit 模式指示 + 8/16 bit 字符数 + 字节 + 补齐)。 */
950
+ export function qrDataCapacityBytes(version) {
951
+ const { rawData } = qrBlocks(version);
952
+ const ccBits = version <= 9 ? 8 : 16;
953
+ return Math.max(0, rawData - Math.ceil((4 + ccBits) / 8));
954
+ }
955
+
956
+ /** 选版本:能装下就用最小的;超出版本 40 上限则报错(不静默截断)。 */
957
+ export function pickQrVersion(byteLength, minVersion = 1) {
958
+ for (let v = Math.max(1, minVersion); v <= 40; v++) {
959
+ if (byteLength <= qrDataCapacityBytes(v)) return v;
960
+ }
961
+ throw new WeChatError(
962
+ "bad_options",
963
+ `QR: 内容过长(${byteLength} 字节),超过版本 40-M 的容量上限`
964
+ );
965
+ }
966
+
967
+ // ---- GF(256) XOR 运算(QR 用 0x11D 多项式) ----
968
+
969
+ function gfMul(x, y) {
970
+ let z = 0;
971
+ for (let i = 7; i >= 0; i--) {
972
+ z = (z << 1) ^ ((z >>> 7) * 0x11d);
973
+ z ^= ((y >>> i) & 1) * x;
974
+ }
975
+ return z & 0xff;
976
+ }
977
+
978
+ /**
979
+ * 生成多项式 ∏(x − α^i),i = 0..degree-1。
980
+ * 返回**降幂**系数(g[0] = x^degree 的系数 … g[degree] = 1),
981
+ * 与 QR 规范表 A.1 的排布一致 —— 例如 degree=2 → [1, 3, 2]。
982
+ */
983
+ function rsGeneratorPoly(degree) {
984
+ let result = [1];
985
+ let root = 1;
986
+ for (let i = 0; i < degree; i++) {
987
+ const next = new Array(result.length + 1).fill(0);
988
+ for (let j = 0; j < result.length; j++) {
989
+ next[j] ^= gfMul(result[j], root);
990
+ next[j + 1] ^= result[j];
991
+ }
992
+ result = next;
993
+ root = gfMul(root, 0x02);
994
+ }
995
+ return result.reverse(); // 升幂 → 降幂
996
+ }
997
+
998
+ /** 带余除法求纠错码字。返回除数多项式去掉最高次后的余式。 */
999
+ export function rsRemainder(data, degree) {
1000
+ const divisor = rsGeneratorPoly(degree);
1001
+ const result = new Array(degree).fill(0);
1002
+ for (const b of data) {
1003
+ const factor = b ^ result.shift();
1004
+ result.push(0);
1005
+ for (let i = 0; i < degree; i++) result[i] ^= gfMul(divisor[i + 1], factor);
1006
+ }
1007
+ return result;
1008
+ }
1009
+
1010
+ /** 字节 → 数据码字(模式指示 0100、字符数、内容、终止符、补齐 0xEC/0x11)。 */
1011
+ export function qrEncodeBytes(bytes, version) {
1012
+ const { rawData } = qrBlocks(version);
1013
+ const ccBits = version <= 9 ? 8 : 16;
1014
+ const bits = [];
1015
+ const push = (val, len) => {
1016
+ for (let i = len - 1; i >= 0; i--) bits.push((val >>> i) & 1);
1017
+ };
1018
+ push(0x4, 4); // 字节模式
1019
+ push(bytes.length, ccBits);
1020
+ for (const b of bytes) push(b, 8);
1021
+ for (let i = 0; i < 4 && bits.length < rawData * 8; i++) bits.push(0); // 终止符
1022
+ while (bits.length % 8 !== 0) bits.push(0);
1023
+ const out = [];
1024
+ for (let i = 0; i < bits.length; i += 8) {
1025
+ out.push(bits.slice(i, i + 8).reduce((acc, bit) => (acc << 1) | bit, 0));
1026
+ }
1027
+ for (let pad = 0xec; out.length < rawData; pad ^= 0xec ^ 0x11) out.push(pad);
1028
+ return out;
1029
+ }
1030
+
1031
+ /** 数据码字 → 全部码字(按块算 RS,再交错;短块在前)。 */
1032
+ export function qrAddEcc(dataCodewords, version) {
1033
+ const { numBlocks, ecLen, shortLen, numShort, rawData } = qrBlocks(version);
1034
+ if (dataCodewords.length !== rawData) {
1035
+ throw new WeChatError("bad_options", `QR: 数据码字数不符(期望 ${rawData},收到 ${dataCodewords.length})`);
1036
+ }
1037
+ const dataBlocks = [];
1038
+ const ecBlocks = [];
1039
+ let k = 0;
1040
+ for (let i = 0; i < numBlocks; i++) {
1041
+ const len = shortLen + (i < numShort ? 0 : 1);
1042
+ const dat = dataCodewords.slice(k, k + len);
1043
+ k += len;
1044
+ dataBlocks.push(dat);
1045
+ ecBlocks.push(rsRemainder(dat, ecLen));
1046
+ }
1047
+ const result = [];
1048
+ const maxData = Math.max(...dataBlocks.map((b) => b.length));
1049
+ for (let i = 0; i < maxData; i++) {
1050
+ for (const blk of dataBlocks) if (i < blk.length) result.push(blk[i]);
1051
+ }
1052
+ for (let i = 0; i < ecLen; i++) {
1053
+ for (const blk of ecBlocks) result.push(blk[i]);
1054
+ }
1055
+ return result;
1056
+ }
1057
+
1058
+ // ---- 矩阵内容(功能图形与矩阵构造在文件上方,因为码字总数也依赖它) ----
1059
+
1060
+ /** 15 bit 格式信息:(ecLevel<<3 | mask) 做 BCH(15,5),再异或 0x5412。 */
1061
+ function qrFormatBits(ecLevel, mask) {
1062
+ const data = (ecLevel << 3) | mask;
1063
+ let rem = data;
1064
+ for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
1065
+ return ((data << 10) | rem) ^ 0x5412;
1066
+ }
1067
+
1068
+ /** 18 bit 版本信息(仅版本 ≥ 7):版本号做 BCH(18,6)。 */
1069
+ function qrVersionBits(version) {
1070
+ let rem = version;
1071
+ for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
1072
+ return (version << 12) | rem;
1073
+ }
1074
+
1075
+ function drawFormatBits(m, mask) {
1076
+ const bits = qrFormatBits(QR_EC_LEVEL_M, mask);
1077
+ const bit = (i) => ((bits >>> i) & 1) !== 0;
1078
+ // 第一份:左上 finder 周围
1079
+ for (let i = 0; i <= 5; i++) setFn(m, 8, i, bit(i));
1080
+ setFn(m, 8, 7, bit(6));
1081
+ setFn(m, 8, 8, bit(7));
1082
+ setFn(m, 7, 8, bit(8));
1083
+ for (let i = 9; i <= 14; i++) setFn(m, 14 - i, 8, bit(i));
1084
+ // 第二份:右上(行 8)/ 左下(列 8)
1085
+ for (let i = 0; i <= 7; i++) setFn(m, m.size - 1 - i, 8, bit(i));
1086
+ for (let i = 8; i <= 14; i++) setFn(m, 8, m.size - 15 + i, bit(i));
1087
+ setFn(m, 8, m.size - 8, true); // dark module
1088
+ }
1089
+
1090
+ function drawVersionBits(m, version) {
1091
+ if (version < 7) return;
1092
+ const bits = qrVersionBits(version);
1093
+ for (let i = 0; i < 18; i++) {
1094
+ const dark = ((bits >>> i) & 1) !== 0;
1095
+ const a = m.size - 11 + (i % 3);
1096
+ const b = Math.floor(i / 3);
1097
+ setFn(m, a, b, dark);
1098
+ setFn(m, b, a, dark);
1099
+ }
1100
+ }
1101
+
1102
+ /** 在给定矩阵上补齐功能图形(复用 functionPatternMatrix,避免两处定义漂移)。 */
1103
+ function drawFunctionPatterns(m, version) {
1104
+ const fn = functionPatternMatrix(version);
1105
+ for (let y = 0; y < m.size; y++) {
1106
+ for (let x = 0; x < m.size; x++) {
1107
+ if (fn.fn[y][x]) setFn(m, x, y, fn.modules[y][x]);
1108
+ }
1109
+ }
1110
+ // 版本 ≥ 7 还有两块 18 bit 版本信息(functionPatternMatrix 只把它们标成功能位,
1111
+ // 并没有填值 —— 这里必须真正画上,否则那 36 个模块会一直保持浅色)。
1112
+ drawVersionBits(m, version);
1113
+ }
1114
+
1115
+ /**
1116
+ * 把码字按「两列一组、自下而上、蛇形」落到非功能模块上,并应用掩码。
1117
+ * 掩码索引:0 (x+y)%2 · 1 y%2 · 2 x%3 · 3 (x+y)%3 · 4 (x/3+y/2)%2 · 5 x*y%2+x*y%3 · 6 (x*y%2+x*y%3)%2 · 7 ((x+y)%2+x*y%3)%2
1118
+ *
1119
+ * 数据区末尾可能多出 0..7 个**剩余位**(装不满一个码字)。这些位仍属于符号本体,
1120
+ * 掩码对它们同样生效,所以它们的最终取值就是掩码位本身(写 0 再掩码 = 掩码位)。
1121
+ * 这里显式按掩码写,与参考实现逐模块一致,避免"剩余位永远浅色"。
1122
+ */
1123
+ function drawCodewords(m, codewords, mask) {
1124
+ const size = m.size;
1125
+ let i = 0;
1126
+ // 竖直 timing pattern 在 x=6,不参与数据区:右列走到它时整体左移一列,
1127
+ // 使第 5 列与第 4 列配对(否则第 6 列会被当成数据列)。
1128
+ for (let right = size - 1; right >= 1; right -= 2) {
1129
+ if (right === 6) right = 5;
1130
+ for (let vert = 0; vert < size; vert++) {
1131
+ for (let j = 0; j < 2; j++) {
1132
+ const x = right - j;
1133
+ const upward = ((right + 1) & 2) === 0;
1134
+ const y = upward ? size - 1 - vert : vert;
1135
+ if (m.fn[y][x]) continue;
1136
+ const masked = maskFn(mask, x, y);
1137
+ if (i >= codewords.length * 8) {
1138
+ m.modules[y][x] = masked; // 剩余位:0 ^ 掩码
1139
+ continue;
1140
+ }
1141
+ const bit = ((codewords[i >>> 3] >>> (7 - (i & 7))) & 1) !== 0;
1142
+ // 掩码是 XOR:module = bit ^ maskFn(x, y)(规范 §8.8.1)
1143
+ m.modules[y][x] = masked ? !bit : bit;
1144
+ i++;
1145
+ }
1146
+ }
1147
+ }
1148
+ }
1149
+
1150
+ function maskFn(mask, x, y) {
1151
+ switch (mask) {
1152
+ case 0: return (x + y) % 2 === 0;
1153
+ case 1: return y % 2 === 0;
1154
+ case 2: return x % 3 === 0;
1155
+ case 3: return (x + y) % 3 === 0;
1156
+ case 4: return (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0;
1157
+ case 5: return ((x * y) % 2) + ((x * y) % 3) === 0;
1158
+ case 6: return (((x * y) % 2) + ((x * y) % 3)) % 2 === 0;
1159
+ case 7: return (((x + y) % 2) + ((x * y) % 3)) % 2 === 0;
1160
+ default: throw new WeChatError("bad_options", `QR: 掩码越界 ${mask}`);
1161
+ }
1162
+ }
1163
+
1164
+ /** 掩码罚分(规范 §8.8.2 四条规则,N1=3 N2=3 N3=40 N4=10)。 */
1165
+ function penaltyScore(m) {
1166
+ const size = m.size;
1167
+ const mod = (x, y) => m.modules[y][x];
1168
+ let result = 0;
1169
+
1170
+ // 规则 1:行/列上连续同色 ≥5
1171
+ for (let y = 0; y < size; y++) {
1172
+ let runColor = false;
1173
+ let runLen = 0;
1174
+ for (let x = 0; x < size; x++) {
1175
+ if (x === 0 || mod(x, y) !== runColor) {
1176
+ runColor = mod(x, y);
1177
+ runLen = 1;
1178
+ } else {
1179
+ runLen++;
1180
+ if (runLen === 5) result += 3;
1181
+ else if (runLen > 5) result++;
1182
+ }
1183
+ }
1184
+ }
1185
+ for (let x = 0; x < size; x++) {
1186
+ let runColor = false;
1187
+ let runLen = 0;
1188
+ for (let y = 0; y < size; y++) {
1189
+ if (y === 0 || mod(x, y) !== runColor) {
1190
+ runColor = mod(x, y);
1191
+ runLen = 1;
1192
+ } else {
1193
+ runLen++;
1194
+ if (runLen === 5) result += 3;
1195
+ else if (runLen > 5) result++;
1196
+ }
1197
+ }
1198
+ }
1199
+
1200
+ // 规则 2:2×2 同色块
1201
+ for (let y = 0; y < size - 1; y++) {
1202
+ for (let x = 0; x < size - 1; x++) {
1203
+ const c = mod(x, y);
1204
+ if (c === mod(x + 1, y) && c === mod(x, y + 1) && c === mod(x + 1, y + 1)) result += 3;
1205
+ }
1206
+ }
1207
+
1208
+ // 规则 3:finder 状 1:1:3:1:1 且一侧有 4 个浅色模块
1209
+ const pattern = [true, false, true, true, true, false, true];
1210
+ const matches = (get, i, len) => {
1211
+ for (let k = 0; k < 7; k++) if (get(i + k) !== pattern[k]) return false;
1212
+ let before = true;
1213
+ for (let k = 1; k <= 4; k++) before = before && (i - k < 0 || get(i - k) === false);
1214
+ let after = true;
1215
+ for (let k = 7; k <= 10; k++) after = after && (i + k >= len || get(i + k) === false);
1216
+ return before || after;
1217
+ };
1218
+ for (let y = 0; y < size; y++) {
1219
+ const row = (i) => mod(i, y);
1220
+ for (let x = 0; x + 7 <= size; x++) if (matches(row, x, size)) result += 40;
1221
+ }
1222
+ for (let x = 0; x < size; x++) {
1223
+ const col = (i) => mod(x, i);
1224
+ for (let y = 0; y + 7 <= size; y++) if (matches(col, y, size)) result += 40;
1225
+ }
1226
+
1227
+ // 规则 4:深色比例偏离 50% 的步长
1228
+ let dark = 0;
1229
+ for (const row of m.modules) for (const c of row) if (c) dark++;
1230
+ const total = size * size;
1231
+ const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
1232
+ result += Math.max(0, k) * 10;
1233
+ return result;
1234
+ }
1235
+
1236
+ /**
1237
+ * 编码为 QR 矩阵(布尔二维数组,[y][x] === true 表示深色)。
1238
+ * @param {string} text
1239
+ * @param {object} [opts] { minVersion, mask } —— mask 指定时跳过自动选优(测试/对比用)
1240
+ * @returns {{size:number, modules:boolean[][], version:number, mask:number, ecLevel:number}}
1241
+ */
1242
+ export function qrEncode(text, opts = {}) {
1243
+ const bytes = Buffer.from(String(text ?? ""), "utf-8");
1244
+ const version = pickQrVersion(bytes.length, opts.minVersion ?? 1);
1245
+ const codewords = qrAddEcc(qrEncodeBytes(bytes, version), version);
1246
+
1247
+ let best = null;
1248
+ const masks = Number.isInteger(opts.mask) && opts.mask >= 0 && opts.mask <= 7 ? [opts.mask] : [0, 1, 2, 3, 4, 5, 6, 7];
1249
+ for (const mask of masks) {
1250
+ const m = makeMatrix(version * 4 + 17);
1251
+ drawFunctionPatterns(m, version);
1252
+ drawCodewords(m, codewords, mask);
1253
+ drawFormatBits(m, mask);
1254
+ const score = penaltyScore(m);
1255
+ if (!best || score < best.score) best = { score, mask, m };
1256
+ }
1257
+ return { size: best.m.size, modules: best.m.modules, version, mask: best.mask, ecLevel: QR_EC_LEVEL_M };
1258
+ }
1259
+
1260
+ /** QR 矩阵 → SVG 源码(每行 run-length 合并成若干 <rect>,保持文件小且可读)。 */
1261
+ export function qrToSvg(qr, opts = {}) {
1262
+ const size = qr.size;
1263
+ const quiet = Math.max(0, Math.min(16, opts.quietZone ?? 4));
1264
+ const scale = opts.scale ?? 4;
1265
+ const dim = (size + quiet * 2) * scale;
1266
+ const parts = [
1267
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${dim}" height="${dim}" viewBox="0 0 ${dim} ${dim}" shape-rendering="crispEdges">`,
1268
+ `<rect width="${dim}" height="${dim}" fill="#ffffff"/>`,
1269
+ `<g fill="#000000">`
1270
+ ];
1271
+ for (let y = 0; y < size; y++) {
1272
+ let x = 0;
1273
+ while (x < size) {
1274
+ if (!qr.modules[y][x]) {
1275
+ x++;
1276
+ continue;
1277
+ }
1278
+ let run = 1;
1279
+ while (x + run < size && qr.modules[y][x + run]) run++;
1280
+ parts.push(
1281
+ `<rect x="${(x + quiet) * scale}" y="${(y + quiet) * scale}" width="${run * scale}" height="${scale}"/>`
1282
+ );
1283
+ x += run;
1284
+ }
1285
+ }
1286
+ parts.push("</g>", "</svg>");
1287
+ return parts.join("");
1288
+ }
1289
+
1290
+ /**
1291
+ * 文本 → `data:image/svg+xml;base64,…`(面板 <img src> 直接用,无需第三方库)。
1292
+ * @returns {string}
1293
+ */
1294
+ export function qrSvgDataUrl(text, opts = {}) {
1295
+ const svg = qrToSvg(qrEncode(text, opts), opts);
1296
+ return `data:image/svg+xml;base64,${Buffer.from(svg, "utf-8").toString("base64")}`;
1297
+ }
1298
+
1299
+ // ===========================================================================
1300
+ // 7. 绑定状态机(§3 / §10)
1301
+ // ===========================================================================
1302
+
1303
+ /**
1304
+ * 扫码绑定会话。
1305
+ *
1306
+ * 设计要点(全部来自 §3 的 8 态表):
1307
+ * - `need_verifycode` **回调式**表面化(onNeedVerifyCode),不读 stdin ——
1308
+ * 面板弹输入框,`submitVerifyCode()` 带回轮询;
1309
+ * - `expired` 自动刷新二维码,**最多 3 次**,超限给出明确文案;
1310
+ * - `binded_redirect` = 成功(该 bot 以前绑过),不是失败;
1311
+ * - `scaned_but_redirect` 切 redirect_host 继续(且做 https 校验);
1312
+ * - `verify_code_blocked` 给明确文案,并按参考实现刷新二维码/封顶放弃;
1313
+ * - `confirmed` **必须带 ilink_bot_id**,没有就失败(绝不当作成功);
1314
+ * - 未知状态 / 形状不认识 → 记日志 + 按可重试处理,**不崩**。
1315
+ */
1316
+ export class BindSession {
1317
+ constructor(opts) {
1318
+ this.client = opts.client;
1319
+ this.logger = opts.logger;
1320
+ this.qr = opts.qr;
1321
+ this.pollIntervalMs = opts.pollIntervalMs ?? 1000;
1322
+ this.pollTimeoutMs = opts.pollTimeoutMs ?? QR_LONG_POLL_TIMEOUT_MS;
1323
+ this.onEvent = typeof opts.onEvent === "function" ? opts.onEvent : () => {};
1324
+ this.onNeedVerifyCode = typeof opts.onNeedVerifyCode === "function" ? opts.onNeedVerifyCode : null;
1325
+ this.deadline = opts.deadline ?? Number.POSITIVE_INFINITY;
1326
+ this.clock = opts.clock ?? Date.now;
1327
+ this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1328
+
1329
+ this.qrcodeUrl = opts.qr.qrcode_img_content;
1330
+ this.expiredRefreshes = 0;
1331
+ this.verifyAttempts = 0;
1332
+ this.pendingVerifyCode = "";
1333
+ this.verifyCodeRequested = false;
1334
+ this.unknownStatuses = [];
1335
+ this.pollBaseUrl = opts.baseUrl ?? null; // null = 用 client 自己的
1336
+ this.done = false;
1337
+ this.result = null;
1338
+ }
1339
+
1340
+ /** 供面板 `submitVerifyCode(code)` 调用:暂存,下次轮询带上。 */
1341
+ submitVerifyCode(code) {
1342
+ const c = String(code ?? "").trim();
1343
+ if (!c) return false;
1344
+ this.pendingVerifyCode = c;
1345
+ this.verifyCodeRequested = false;
1346
+ return true;
1347
+ }
1348
+
1349
+ emit(evt, payload = {}) {
1350
+ try {
1351
+ this.onEvent(evt, payload);
1352
+ } catch (e) {
1353
+ this.logger.warn(`bind: onEvent 回调抛错(忽略)${redact(e.message)}`);
1354
+ }
1355
+ }
1356
+
1357
+ /** 刷新二维码(expired / verify_code_blocked 用)。 */
1358
+ async refreshQr() {
1359
+ this.expiredRefreshes++;
1360
+ const qr = await this.client.getBotQrcode({});
1361
+ this.qr = qr;
1362
+ this.qrcodeUrl = qr.qrcode_img_content;
1363
+ this.pendingVerifyCode = "";
1364
+ this.verifyCodeRequested = false;
1365
+ this.emit("qr", { qrcodeUrl: this.qrcodeUrl, refreshCount: this.expiredRefreshes });
1366
+ return qr;
1367
+ }
1368
+
1369
+ /** 放弃时的统一出口。 */
1370
+ fail(code, message) {
1371
+ this.done = true;
1372
+ this.result = { ok: false, code, message, qrcodeUrl: this.qrcodeUrl };
1373
+ this.emit("fail", this.result);
1374
+ return this.result;
1375
+ }
1376
+
1377
+ /**
1378
+ * 推进一步。返回值:
1379
+ * {ok:false, pending:true, status} 继续轮询
1380
+ * {ok:false, verifyNeeded:true} 等面板给配对码
1381
+ * {ok:true, ...} 绑定成功(binded_redirect 也算成功)
1382
+ * {ok:false, code, message} 终态失败
1383
+ */
1384
+ async next() {
1385
+ if (this.done) return this.result;
1386
+ if (this.clock() > this.deadline) {
1387
+ return this.fail("verify_timeout", "绑定超时:二维码已失效,请重新发起绑定。");
1388
+ }
1389
+ const opts = { qrcode: this.qr.qrcode, timeoutMs: this.pollTimeoutMs };
1390
+ if (this.pendingVerifyCode) opts.verifyCode = this.pendingVerifyCode;
1391
+ if (this.pollBaseUrl) opts.baseUrl = this.pollBaseUrl;
1392
+
1393
+ let resp;
1394
+ try {
1395
+ resp = await this.client.pollQrcodeStatus(opts);
1396
+ } catch (err) {
1397
+ if (err instanceof WeChatError && err.code === "bad_response_shape") {
1398
+ // §3 警告:未公开接口,形状不认识是一等错误 —— 记下来并要求重试,不崩。
1399
+ this.logger.warn(`bind: 轮询响应形状不认识,按可重试处理(${redact(err.message)})`);
1400
+ this.unknownStatuses.push("bad_response_shape");
1401
+ this.emit("unknown", { reason: "bad_response_shape" });
1402
+ return { ok: false, pending: true, status: "unknown" };
1403
+ }
1404
+ const code = err instanceof WeChatError ? err.code : "unknown";
1405
+ return this.fail(code === "http_error" ? "http_error" : code, `轮询失败:${redact(err.message)}`);
1406
+ }
1407
+
1408
+ const status = resp.status;
1409
+ this.emit("status", { status });
1410
+
1411
+ switch (status) {
1412
+ case "wait":
1413
+ return { ok: false, pending: true, status };
1414
+
1415
+ case "scaned":
1416
+ if (this.pendingVerifyCode) {
1417
+ this.logger.info("bind: 配对码已被接受,继续轮询");
1418
+ this.pendingVerifyCode = "";
1419
+ }
1420
+ this.emit("scaned", {});
1421
+ return { ok: false, pending: true, status };
1422
+
1423
+ case "need_verifycode": {
1424
+ this.verifyAttempts++;
1425
+ if (this.verifyAttempts > MAX_VERIFY_ATTEMPTS) {
1426
+ return this.fail("verify_blocked", "配对码多次不正确,连接流程已停止,请稍后再试。");
1427
+ }
1428
+ this.pendingVerifyCode = "";
1429
+ const payload = {
1430
+ attempt: this.verifyAttempts,
1431
+ maxAttempts: MAX_VERIFY_ATTEMPTS,
1432
+ message:
1433
+ this.verifyAttempts === 1
1434
+ ? "请输入手机微信上显示的数字配对码。"
1435
+ : "配对码不正确,请重新输入。"
1436
+ };
1437
+ this.emit("need_verifycode", payload);
1438
+ // 回调式表面化:面板弹输入框。没有回调也**不读 stdin**,直接返回等待态。
1439
+ if (this.onNeedVerifyCode) {
1440
+ try {
1441
+ const code = await this.onNeedVerifyCode(payload);
1442
+ if (code) this.submitVerifyCode(code);
1443
+ } catch (e) {
1444
+ this.logger.warn(`bind: onNeedVerifyCode 回调抛错 ${redact(e.message)}`);
1445
+ }
1446
+ }
1447
+ return { ok: false, verifyNeeded: true, status, attempt: this.verifyAttempts };
1448
+ }
1449
+
1450
+ case "expired": {
1451
+ if (this.expiredRefreshes >= MAX_QR_REFRESH_COUNT) {
1452
+ return this.fail(
1453
+ "bind_expired",
1454
+ `二维码已失效 ${MAX_QR_REFRESH_COUNT} 次,连接流程已停止,请稍后重新发起绑定。`
1455
+ );
1456
+ }
1457
+ this.emit("expired", { refreshCount: this.expiredRefreshes + 1, max: MAX_QR_REFRESH_COUNT });
1458
+ try {
1459
+ await this.refreshQr();
1460
+ } catch (e) {
1461
+ return this.fail("http_error", `刷新二维码失败:${redact(e.message)}`);
1462
+ }
1463
+ return { ok: false, pending: true, status, refreshed: true };
1464
+ }
1465
+
1466
+ case "scaned_but_redirect": {
1467
+ const host = normalizeRedirectHost(resp.redirect_host);
1468
+ if (host) {
1469
+ this.pollBaseUrl = `https://${host}`;
1470
+ this.logger.info(`bind: IDC 重定向,轮询主机切换为 ${host}`);
1471
+ this.emit("redirect", { host });
1472
+ } else {
1473
+ this.logger.warn("bind: scaned_but_redirect 缺 redirect_host 或非法,沿用当前主机");
1474
+ this.emit("redirect", { host: null });
1475
+ }
1476
+ return { ok: false, pending: true, status };
1477
+ }
1478
+
1479
+ case "verify_code_blocked":
1480
+ this.pendingVerifyCode = "";
1481
+ this.emit("verify_code_blocked", { message: "多次输入错误,请稍后再试。" });
1482
+ if (this.expiredRefreshes >= MAX_QR_REFRESH_COUNT) {
1483
+ return this.fail("verify_blocked", "多次输入错误,连接流程已停止。请稍后再试。");
1484
+ }
1485
+ try {
1486
+ await this.refreshQr();
1487
+ } catch (e) {
1488
+ return this.fail("http_error", `刷新二维码失败:${redact(e.message)}`);
1489
+ }
1490
+ return { ok: false, pending: true, status };
1491
+
1492
+ case "binded_redirect":
1493
+ // 该 bot 之前绑过 —— 按参考实现语义视为**成功**(alreadyBound),不是失败。
1494
+ this.logger.info("bind: binded_redirect(该 bot 已绑定过),视为成功");
1495
+ this.done = true;
1496
+ this.result = {
1497
+ ok: true,
1498
+ alreadyBound: true,
1499
+ qrcodeUrl: this.qrcodeUrl,
1500
+ message: "该微信机器人已绑定过,无需重复绑定。"
1501
+ };
1502
+ this.emit("binded_redirect", this.result);
1503
+ return this.result;
1504
+
1505
+ case "confirmed": {
1506
+ if (!resp.ilink_bot_id) {
1507
+ return this.fail(
1508
+ "bind_missing_bot_id",
1509
+ "绑定失败:服务器返回 confirmed 但没有 ilink_bot_id,无法确认绑定结果。"
1510
+ );
1511
+ }
1512
+ if (!resp.bot_token) {
1513
+ // token 缺失同样是"形状不认识"的一种,不能当成绑定成功(§8:没有 token 什么都做不了)。
1514
+ return this.fail("bad_response_shape", "绑定失败:服务器未返回 bot_token。");
1515
+ }
1516
+ this.done = true;
1517
+ this.result = {
1518
+ ok: true,
1519
+ alreadyBound: false,
1520
+ token: resp.bot_token,
1521
+ accountId: resp.ilink_bot_id,
1522
+ baseUrl: resp.baseurl || this.pollBaseUrl || this.client.baseUrl,
1523
+ userId: resp.ilink_user_id || "",
1524
+ qrcodeUrl: this.qrcodeUrl,
1525
+ message: "已将此电脑连接到微信机器人。"
1526
+ };
1527
+ this.logger.info(
1528
+ `bind: confirmed bot_id=${resp.ilink_bot_id} user=${redactToken(resp.ilink_user_id)} token=${redactToken(resp.bot_token)}`
1529
+ );
1530
+ this.emit("confirmed", { accountId: resp.ilink_bot_id, baseUrl: this.result.baseUrl });
1531
+ return this.result;
1532
+ }
1533
+
1534
+ default: {
1535
+ // 未知状态 = 未公开协议演进的正常可能。记下来、继续轮询,绝不崩、也不当成成功。
1536
+ this.logger.warn(`bind: 未知状态 "${String(status).slice(0, 40)}",按可重试处理`);
1537
+ this.unknownStatuses.push(String(status).slice(0, 40));
1538
+ this.emit("unknown", { status });
1539
+ return { ok: false, pending: true, status: "unknown" };
1540
+ }
1541
+ }
1542
+ }
1543
+
1544
+ /** 走到终态或超时(便捷封装;面板不需要它,测试和 CLI 用)。 */
1545
+ async run() {
1546
+ for (;;) {
1547
+ const step = await this.next();
1548
+ if (step.done !== undefined || step.ok || (!step.pending && !step.verifyNeeded)) return step;
1549
+ if (step.verifyNeeded) {
1550
+ // 没有配对码来源 → 明确失败,而不是死循环(§10 第 5 步:面板必须弹框)。
1551
+ if (!this.pendingVerifyCode) {
1552
+ return this.fail("verify_blocked", "需要数字配对码,但没有可用的输入来源。");
1553
+ }
1554
+ continue;
1555
+ }
1556
+ await this.sleep(this.pollIntervalMs);
1557
+ }
1558
+ }
1559
+ }
1560
+
1561
+ /**
1562
+ * 发起绑定(§10 第 2-3 步):取二维码 → 返回会话句柄 + 二维码 URL。
1563
+ * 面板拿到 qrcodeUrl 后本地渲染(可用本模块的 qrSvgDataUrl)。
1564
+ */
1565
+ export async function startBind(init = {}) {
1566
+ const client = init.client;
1567
+ if (!client) throw new WeChatError("bad_options", "startBind: 缺少 client");
1568
+ const logger = init.logger || client.logger;
1569
+ const localTokenList = Array.isArray(init.localTokenList) ? init.localTokenList : [];
1570
+ const qr = await client.getBotQrcode({ localTokenList });
1571
+ logger.info(`startBind: 二维码已就绪(img_len=${qr.qrcode_img_content.length})`);
1572
+ const session = new BindSession({
1573
+ client,
1574
+ logger,
1575
+ qr,
1576
+ baseUrl: init.baseUrl ?? null,
1577
+ pollIntervalMs: init.pollIntervalMs,
1578
+ pollTimeoutMs: init.pollTimeoutMs,
1579
+ onEvent: init.onEvent,
1580
+ onNeedVerifyCode: init.onNeedVerifyCode,
1581
+ // ⚠️ 必须用**注入的 clock** 算截止时间,不能写 Date.now():
1582
+ // Session 内部判超时用的是 `this.clock() > this.deadline`。若这里用真实 Date.now()
1583
+ // 而测试/调用方注入假时钟,两边时间轴不同源 → 超时**永远不会触发**,next() 一直
1584
+ // 返回 pending,面板就卡在"进行中"转圈,用户也不会看到"二维码已过期"。
1585
+ deadline:
1586
+ init.deadline ??
1587
+ (init.timeoutMs ? (init.clock ? init.clock() : Date.now()) + init.timeoutMs : Number.POSITIVE_INFINITY),
1588
+ clock: init.clock,
1589
+ sleep: init.sleep
1590
+ });
1591
+ return {
1592
+ session,
1593
+ qrcode: qr.qrcode,
1594
+ qrcodeUrl: qr.qrcode_img_content,
1595
+ /** 面板直接可用的 data URL。 */
1596
+ qrcodeSvg: qr.qrcode_img_content ? qrSvgDataUrl(qr.qrcode_img_content) : "",
1597
+ message: "请用手机微信扫描二维码完成绑定。",
1598
+ submitVerifyCode: (code) => session.submitVerifyCode(code),
1599
+ next: () => session.next(),
1600
+ run: () => session.run()
1601
+ };
1602
+ }
1603
+
1604
+ // ===========================================================================
1605
+ // 8. 凭据 / 状态文件(§8 / §9)
1606
+ // ===========================================================================
1607
+
1608
+ export const accountPath = (relayDir) => path.join(relayDir, ACCOUNT_FILE);
1609
+ export const statePath = (relayDir) => path.join(relayDir, STATE_FILE);
1610
+
1611
+ /**
1612
+ * 读凭据。返回 {token, accountId, baseUrl, userId, boundAt} 或 null。
1613
+ * ⚠️ **只有 bridge 内部能用它** —— 面板接口一律走 sanitizeAccount()(§8:永不回显 token)。
1614
+ */
1615
+ export function loadAccount(relayDir) {
1616
+ try {
1617
+ const raw = fs.readFileSync(accountPath(relayDir), "utf8");
1618
+ const data = JSON.parse(raw);
1619
+ if (!data || typeof data !== "object" || typeof data.token !== "string" || !data.token) return null;
1620
+ return {
1621
+ token: data.token,
1622
+ accountId: typeof data.accountId === "string" ? data.accountId : "",
1623
+ baseUrl: typeof data.baseUrl === "string" && data.baseUrl ? data.baseUrl : DEFAULT_ILINK_BASE_URL,
1624
+ userId: typeof data.userId === "string" ? data.userId : "",
1625
+ boundAt: Number.isFinite(data.boundAt) ? data.boundAt : 0
1626
+ };
1627
+ } catch {
1628
+ return null;
1629
+ }
1630
+ }
1631
+
1632
+ /** 保存凭据(0600 + Windows ACL 收紧)。 */
1633
+ export function saveAccount(relayDir, account, onWarn) {
1634
+ const data = {
1635
+ token: String(account.token),
1636
+ accountId: String(account.accountId ?? ""),
1637
+ baseUrl: String(account.baseUrl || DEFAULT_ILINK_BASE_URL),
1638
+ userId: String(account.userId ?? ""),
1639
+ boundAt: Number.isFinite(account.boundAt) ? account.boundAt : Date.now()
1640
+ };
1641
+ writePrivateJson(accountPath(relayDir), data, onWarn);
1642
+ return data;
1643
+ }
1644
+
1645
+ /** 解绑第一步:删凭据文件(§8 解绑顺序:停轮询 → notifystop → 删凭据)。 */
1646
+ export function clearAccount(relayDir) {
1647
+ try {
1648
+ fs.rmSync(accountPath(relayDir), { force: true });
1649
+ return true;
1650
+ } catch {
1651
+ return false;
1652
+ }
1653
+ }
1654
+
1655
+ /**
1656
+ * 面板可见的账号形态(§8:面板 API **永不回显 token**)。
1657
+ * 只有三个字段,且注释里再强调一次:任何新增字段都要先过一遍「会不会泄 token」。
1658
+ */
1659
+ export function sanitizeAccount(account) {
1660
+ if (!account) return { bound: false, botId: "", boundAt: 0 };
1661
+ return {
1662
+ bound: true,
1663
+ botId: String(account.accountId ?? ""),
1664
+ boundAt: Number.isFinite(account.boundAt) ? account.boundAt : 0
1665
+ };
1666
+ }
1667
+
1668
+ /** 空状态(§9:只有 bound / unbound 两态)。 */
1669
+ export function emptyState() {
1670
+ return {
1671
+ bound: false,
1672
+ bot_id: "",
1673
+ bound_at: 0,
1674
+ last_error: "",
1675
+ last_push_ok_at: 0,
1676
+ connected_at: 0
1677
+ };
1678
+ }
1679
+
1680
+ /**
1681
+ * 写 <relayDir>/.wechat-state.json(0600)。
1682
+ * §9:**只有两态** —— bound / unbound;`connected_at` / `last_error` / `last_push_ok_at`
1683
+ * 只是健康提示,**不参与、也不改变绑定状态**(面板只用来显示「连接正常 / 最近一次推送失败」)。
1684
+ * 沿用 persistBridgeState / writeE2eeStateFile 的文件约定。
1685
+ */
1686
+ export function saveState(relayDir, patch, onWarn) {
1687
+ let base = emptyState();
1688
+ try {
1689
+ const cur = JSON.parse(fs.readFileSync(statePath(relayDir), "utf8"));
1690
+ if (cur && typeof cur === "object") base = { ...base, ...cur };
1691
+ } catch {
1692
+ /* 首次写 / 文件损坏 → 从空状态开始 */
1693
+ }
1694
+ const next = { ...base, ...patch };
1695
+ next.bound = !!next.bound; // 归一为布尔,杜绝"三态"
1696
+ next.bot_id = next.bound ? String(next.bot_id ?? "") : "";
1697
+ next.bound_at = next.bound ? Number(next.bound_at) || 0 : 0;
1698
+ writePrivateJson(statePath(relayDir), next, onWarn);
1699
+ return next;
1700
+ }
1701
+
1702
+ /** 读状态文件;不存在 / 损坏 → 空状态(未绑定)。 */
1703
+ export function loadState(relayDir) {
1704
+ try {
1705
+ const cur = JSON.parse(fs.readFileSync(statePath(relayDir), "utf8"));
1706
+ if (!cur || typeof cur !== "object") return emptyState();
1707
+ return { ...emptyState(), ...cur, bound: !!cur.bound };
1708
+ } catch {
1709
+ return emptyState();
1710
+ }
1711
+ }
1712
+
1713
+ /** 绑定成功后的状态写入。 */
1714
+ export function markBound(relayDir, account, onWarn) {
1715
+ return saveState(
1716
+ relayDir,
1717
+ {
1718
+ bound: true,
1719
+ bot_id: account.accountId ?? "",
1720
+ bound_at: Number(account.boundAt) || Date.now(),
1721
+ last_error: "",
1722
+ connected_at: Date.now()
1723
+ },
1724
+ onWarn
1725
+ );
1726
+ }
1727
+
1728
+ /** 解绑:清凭据 + 状态回到未绑定。 */
1729
+ export function markUnbound(relayDir, reason = "", onWarn) {
1730
+ clearAccount(relayDir);
1731
+ return saveState(relayDir, { bound: false, bot_id: "", bound_at: 0, last_error: reason, connected_at: 0 }, onWarn);
1732
+ }
1733
+
1734
+ // ===========================================================================
1735
+ // 9. 通知格式化(§5)+ 回执编号注册表
1736
+ // ===========================================================================
1737
+
1738
+ /** 回执有效期文案里的分钟数(§6 ①:审批有寿命,过期静默丢失,文案必须说出来)。 */
1739
+ export const DEFAULT_REPLY_TTL_MINUTES = 5;
1740
+
1741
+ /**
1742
+ * 完成通知里「结论」的展示上限(字)。业主口径(2026-09-22):
1743
+ * 「任务完成通知里有些关键结论被大量截断了,加长一些,更完整地展示最终结论 ——
1744
+ * 这可能是用户关注的内容,所以都给他们展示出来」。
1745
+ * 原值 400 对真实结论太短(实测一条正常的修复总结就有 190+ 字,稍详细就超)。
1746
+ */
1747
+ export const COMPLETION_SUMMARY_MAX = 1500;
1748
+ /**
1749
+ * 单条微信消息的总长度上限(字)。超出仍会**显式标注**截断(不静默丢)。
1750
+ * ⚠️ 这个值必须 ≥ COMPLETION_SUMMARY_MAX,否则结论会在拼接后被二次砍掉。
1751
+ */
1752
+ export const COMPLETION_TEXT_MAX = 3000;
1753
+ /**
1754
+ * 从会话历史里取「结论」时的上限(字)。
1755
+ * ⚠️ 历史陷阱:`wechat-runtime.mjs` 的 #sessionSummary 曾写死 `slice(0, 700)`,
1756
+ * 而展示侧是 400 —— 两边各砍一刀,且**取值侧更小**时会在更早的地方就被砍掉,
1757
+ * 排查时只盯着 formatter 会找不到真正的截断点。两个值必须一起看(有测试锁这条关系)。
1758
+ */
1759
+ export const SESSION_SUMMARY_MAX = 3000;
1760
+
1761
+ const NODE_LABELS = Object.freeze({
1762
+ approval: "需要你拍板",
1763
+ question: "在等你回答",
1764
+ plan: "计划待你批准",
1765
+ error: "任务报错",
1766
+ stopped: "任务已停止",
1767
+ daily: "每日简报",
1768
+ quota: "额度提醒",
1769
+ membership: "会员提醒"
1770
+ });
1771
+
1772
+ /**
1773
+ * 文案纪律(§5):邀请/奖励相关**只有邀请人得奖励**,绝无「双方都得」
1774
+ * (这是已核实的商品事实:没有接受邀请方的奖励)。
1775
+ * 任何从这里长出来的文案都要过 test 的措辞断言。
1776
+ */
1777
+ export const INVITE_COPY = Object.freeze({
1778
+ inviteOnlyRewarded: "只有邀请人得奖励(被邀请方没有奖励)",
1779
+ quotaBtn: "回复 1 邀请好友(你可得奖励)",
1780
+ membershipBtn: "回复 1 邀请好友换时长(只有你可得奖励)"
1781
+ });
1782
+
1783
+ /**
1784
+ * 给每个节点留一格「可回执」的选项定义。
1785
+ * plan-review(intent.kind === 'plan-review')与普通提问文案必须区分(§5 节点 2)。
1786
+ */
1787
+ function optionsFor(kind, node) {
1788
+ if (kind === "approval") {
1789
+ return [
1790
+ { label: "同意一次", value: "allowed-once" },
1791
+ { label: "拒绝", value: "rejected" }
1792
+ ];
1793
+ }
1794
+ if (kind === "question" || kind === "plan") {
1795
+ const raw = Array.isArray(node.options) ? node.options : [];
1796
+ const opts = raw
1797
+ .map((o, i) => ({
1798
+ label: typeof o === "string" ? o : String((o && (o.label ?? o.title ?? o.value)) ?? `选项${i + 1}`),
1799
+ value: o && typeof o === "object" && o.value !== undefined ? o.value : i
1800
+ }))
1801
+ .slice(0, 9);
1802
+ return opts.length ? opts : null;
1803
+ }
1804
+ if (kind === "quota" || kind === "membership") {
1805
+ return [
1806
+ { label: "邀请好友", value: "invite" },
1807
+ { label: "知道了", value: "dismiss" }
1808
+ ];
1809
+ }
1810
+ return null;
1811
+ }
1812
+
1813
+ /**
1814
+ * 把 P0/P1 节点渲染成**纯文本**微信消息。
1815
+ *
1816
+ * @param {object} node
1817
+ * kind: 'approval'|'question'|'plan'|'error'|'stopped'|'daily'|'quota'|'membership'
1818
+ * tool / reason / prompt / detail / sessionId / options / lines(简报用)…
1819
+ * ttlMinutes(可回执节点的有效期,默认 5)
1820
+ * @returns {{kind,title,text,options,replyable,ttlMinutes,expiresAt}}
1821
+ */
1822
+ export function formatNotification(node = {}, opts = {}) {
1823
+ const kind = String(node.kind || "error");
1824
+ const title = NODE_LABELS[kind] || "DSH 通知";
1825
+ const now = opts.now ?? Date.now();
1826
+ const ttlMinutes = Number.isFinite(node.ttlMinutes) ? node.ttlMinutes : DEFAULT_REPLY_TTL_MINUTES;
1827
+ const lines = [];
1828
+
1829
+ switch (kind) {
1830
+ case "approval": {
1831
+ lines.push(`【${title}】`);
1832
+ lines.push(`工具: ${node.tool || "(未提供)"}`);
1833
+ if (node.reason) lines.push(`原因: ${node.reason}`);
1834
+ if (node.detail) lines.push(`详情: ${shorten(node.detail, 200)}`);
1835
+ break;
1836
+ }
1837
+ case "question":
1838
+ case "plan": {
1839
+ if (kind === "plan") {
1840
+ lines.push(`【${title}】`);
1841
+ lines.push("DSH 已写好计划,等你批准后开始执行。");
1842
+ } else {
1843
+ lines.push(`【${title}】`);
1844
+ lines.push("DSH 在等你回答:");
1845
+ }
1846
+ if (node.prompt) lines.push(shorten(node.prompt, 400));
1847
+ break;
1848
+ }
1849
+ case "error": {
1850
+ lines.push(`【${title}】`);
1851
+ if (node.sessionTitle) lines.push(`任务: ${node.sessionTitle}`);
1852
+ lines.push(shorten(node.detail || node.message || "任务执行出错。", 400));
1853
+ break;
1854
+ }
1855
+ case "stopped": {
1856
+ lines.push(`【${title}】`);
1857
+ if (node.sessionTitle) lines.push(`任务: ${node.sessionTitle}`);
1858
+ lines.push(`停止原因: ${stopReasonText(node.reason)}`);
1859
+ break;
1860
+ }
1861
+ case "daily": {
1862
+ lines.push(`【${title}】`);
1863
+ for (const l of Array.isArray(node.lines) ? node.lines : []) lines.push(String(l));
1864
+ // 措辞对齐"回顾今天做了什么" —— 日报不是待办清单
1865
+ if (!Array.isArray(node.lines) || !node.lines.length) lines.push("今天这台电脑上没有跑任务。");
1866
+ // 品牌轻露出(业主:让用户知道我们在给他提供服务)。**克制一行**、放在最底,不喧宾夺主。
1867
+ lines.push("", "—— DSH 远程控制 · 微信机器人通道");
1868
+ // ⚠️ 这句是**功能**不是客套:微信 24h 推送窗口靠用户回消息续期,
1869
+ // 简报的产品作用正是每天制造一次互动(§7)。必须出现「回复」字样,否则用户不会回。
1870
+ lines.push("(回复任意一句话即可保持推送窗口有效)");
1871
+ break;
1872
+ }
1873
+ case "quota": {
1874
+ lines.push(`【${title}】`);
1875
+ lines.push(shorten(node.message || "你的额度快用完了 / 已被限流。", 300));
1876
+ lines.push(INVITE_COPY.inviteOnlyRewarded);
1877
+ break;
1878
+ }
1879
+ case "membership": {
1880
+ lines.push(`【${title}】`);
1881
+ lines.push(shorten(node.message || "你的会员即将过期 / 已过期。", 300));
1882
+ lines.push(INVITE_COPY.inviteOnlyRewarded);
1883
+ break;
1884
+ }
1885
+ default: {
1886
+ lines.push(`【${title}】`);
1887
+ lines.push(shorten(node.message || node.detail || `未知节点 ${kind}`, 300));
1888
+ break;
1889
+ }
1890
+ }
1891
+
1892
+ const options = optionsFor(kind, node);
1893
+ const replyable = Array.isArray(options) && options.length > 0;
1894
+ if (replyable) {
1895
+ lines.push("");
1896
+ options.forEach((o, i) => lines.push(`回复 ${i + 1} ${o.label}`));
1897
+ if (kind === "question" || kind === "plan") {
1898
+ lines.push(`(本条 ${ttlMinutes} 分钟内有效)`);
1899
+ } else {
1900
+ lines.push(`(本条 ${ttlMinutes} 分钟内有效)`);
1901
+ }
1902
+ }
1903
+
1904
+ // 纯文本 + 短:微信不是富客户端(§5 交付要求)。超长整条截断并显式标注。
1905
+ let text = lines.join("\n");
1906
+ const maxLen = opts.maxLength ?? 900;
1907
+ if (text.length > maxLen) text = `${text.slice(0, maxLen - 20)}\n…(内容过长已截断)`;
1908
+
1909
+ return {
1910
+ kind,
1911
+ title,
1912
+ text,
1913
+ options: replyable ? options : [],
1914
+ replyable,
1915
+ ttlMinutes,
1916
+ expiresAt: replyable ? now + ttlMinutes * 60_000 : 0
1917
+ };
1918
+ }
1919
+
1920
+ /**
1921
+ * 单行摘要 + **显式标注截断**。
1922
+ * ⚠️ 截断必须写明「已截断」:否则用户以为自己看到的是全文(尤其错误详情被截时,
1923
+ * 会照着半句话去排查)。测试锁死了这一点。
1924
+ */
1925
+ function shorten(s, n) {
1926
+ const t = String(s ?? "").replace(/\s*\n\s*/g, " ").trim();
1927
+ return t.length > n ? `${t.slice(0, n - 1)}…(已截断)` : t;
1928
+ }
1929
+
1930
+ /** 按长度裁剪但**保留换行**(结论要按原样分多行展示 —— 压成一行正是"密密麻麻"的来源)。 */
1931
+ function clipText(s, n) {
1932
+ const t = String(s ?? "").trim();
1933
+ return t.length > n ? `${t.slice(0, n - 1)}…(已截断)` : t;
1934
+ }
1935
+
1936
+ /**
1937
+ * 把模型产出的 **Markdown 转成微信能看的纯文本**。
1938
+ *
1939
+ * 为什么必须转:微信**不渲染 Markdown**(最强证据是腾讯自己的插件在出站路径跑
1940
+ * `StreamingMarkdownFilter` 主动剥离"不支持的 markdown 语法")。所以模型写的表格会以
1941
+ * `| a | b |` 的**字面竖线**出现在气泡里、`**加粗**` 会显示成星号。
1942
+ * 业主原话:「现在的表格形式看起来很奇怪,整个格式在微信上没有做过任何兼容,体验很差很差」
1943
+ * —— 根因就在这里,不是"排版没调好"。
1944
+ *
1945
+ * 规则(只改"怎么显示",不改语义):
1946
+ * · 表格 → 逐行「· 列1:值1 | 列2:值2」(气泡是**比例字体**,任何用空格对齐的尝试都不可靠)
1947
+ * · 标题 `## x` → `【x】` 独占一行
1948
+ * · `**粗**` / `__粗__` / `*斜*` → 去掉标记(留着就是星号噪声)
1949
+ * · `[文字](链接)` → `文字:链接` —— **必须留下裸 URL**,微信才会自动识别成可点链接
1950
+ * · 行内 `` `代码` `` → 去掉反引号;``` 围栏 → 去掉围栏行,内容原样保留
1951
+ * · 无序列表 `-` `*` `+` → 统一成 `·`;有序列表不动
1952
+ * · 引用 `>` → 去掉标记;分隔线 `---`/`***` → `———————`
1953
+ * · 3 个以上连续空行 → 压成 1 个
1954
+ *
1955
+ * ⚠️ 单字符 `*` / `_` 只在"看起来像强调"(前后不贴空格)时才剥 —— 否则 `2 * 3 * 4` 会被吃成 `2 3 4`。
1956
+ */
1957
+ export function markdownToWechatText(md) {
1958
+ const src = String(md ?? "").replace(/\r\n?/g, "\n");
1959
+ if (!src.trim()) return "";
1960
+ const lines = src.split("\n");
1961
+ const out = [];
1962
+ let inFence = false;
1963
+
1964
+ for (let i = 0; i < lines.length; i += 1) {
1965
+ let line = lines[i];
1966
+
1967
+ // 围栏行整行丢掉(纯文本里代码本来就是纯文本,围栏反而碍眼)
1968
+ if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; continue; }
1969
+ if (inFence) { out.push(line); continue; }
1970
+
1971
+ // 表格:本行是 |…| 且下一行是分隔行 |---|---|
1972
+ if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
1973
+ const cells = (l) => l.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
1974
+ const head = cells(line);
1975
+ i += 2;
1976
+ while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) {
1977
+ const vals = cells(lines[i]);
1978
+ const parts = vals
1979
+ .map((v, k) => (v ? `${head[k] || `列${k + 1}`}:${v}` : ""))
1980
+ .filter(Boolean);
1981
+ if (parts.length) out.push(`· ${parts.join(" | ")}`);
1982
+ i += 1;
1983
+ }
1984
+ i -= 1; // 外层 for 还要 +1
1985
+ continue;
1986
+ }
1987
+
1988
+ const h = /^\s{0,3}(#{1,6})\s+(.*)$/.exec(line);
1989
+ if (h) { out.push(`【${h[2].trim()}】`); continue; }
1990
+
1991
+ if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { out.push("———————"); continue; }
1992
+
1993
+ line = line.replace(/^\s{0,3}>\s?/, ""); // 引用
1994
+ line = line.replace(/^(\s*)[-*+]\s+/, "$1· "); // 无序列表统一
1995
+
1996
+ line = line
1997
+ .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "$1:$2") // 链接 → 文字:裸URL
1998
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
1999
+ .replace(/__([^_]+)__/g, "$1")
2000
+ .replace(/\*(?!\s)([^*\n]+?)(?<!\s)\*/g, "$1")
2001
+ .replace(/(?<![\w_])_(?!\s)([^_\n]+?)(?<!\s)_(?![\w_])/g, "$1")
2002
+ .replace(/`([^`]+)`/g, "$1");
2003
+
2004
+ out.push(line);
2005
+ }
2006
+ return out.join("\n").replace(/\n{3,}/g, "\n\n").trim();
2007
+ }
2008
+
2009
+ const STOP_REASONS = Object.freeze({
2010
+ completed: "正常完成",
2011
+ aborted: "被中止",
2012
+ blocked: "被阻塞(等你处理)",
2013
+ error: "出错结束",
2014
+ "max-tokens": "上下文/输出达上限",
2015
+ interrupted: "被中断"
2016
+ });
2017
+
2018
+ export function stopReasonText(reason) {
2019
+ const k = String(reason ?? "").trim();
2020
+ return STOP_REASONS[k] || (k ? `未知原因(${k})` : "未知原因");
2021
+ }
2022
+
2023
+ /**
2024
+ * 只有这几种收尾需要额外说明"它不是正常跑完的"。
2025
+ * `completed` 故意不在表里 —— 正常完成就是正常完成,多一句废话反而像出事。
2026
+ */
2027
+ const COMPLETION_REASON_NOTES = Object.freeze({
2028
+ "max-tokens": "(到上限就停了,不是正常完成,结论可能不完整)",
2029
+ aborted: "(被中止,后面的活没做完,结论可能不完整)",
2030
+ interrupted: "(被中断,后面的活没做完,结论可能不完整)",
2031
+ blocked: "(它正等你处理,这一轮还没真正结束)",
2032
+ error: "(出错结束,结论可能不完整)"
2033
+ });
2034
+
2035
+ /**
2036
+ * **任务收尾消息**模板 —— 整个产品里用户看得最多的一条输出。
2037
+ *
2038
+ * 它要回答三个问题,顺序就是用户看消息的顺序:
2039
+ * ① 怎么结束的(完成 / 中断 / 中止 / 超 token 必须**能分辨**;
2040
+ * 以前只推「任务已停止」= 什么都没说);
2041
+ * ② 是哪个任务(会话名);
2042
+ * ③ 干出了什么(结论);最后告诉用户**直接回复就能接着做**
2043
+ * —— 这是本产品的全部卖点:不用回电脑、不用重新交代背景。
2044
+ *
2045
+ * ⚠️ 措辞诚实:`completed` 的头不许带失败味(用户会以为白干了);
2046
+ * `max-tokens` **不是**"跑完了",必须写明到上限截断。
2047
+ * ⚠️ 拿不到结论时(hanging)说的是"没取到",而不是"没有结论"——
2048
+ * 后者是替 agent 下结论,用户会据此以为任务什么都没产出。
2049
+ *
2050
+ * @param {object} args { title(会话名), reason(completed|aborted|blocked|error|max-tokens|interrupted),
2051
+ * summary(结论), sessionId, hanging(结论取不到时 true) }
2052
+ * @param {object} [opts] { summaryMaxLength=400, maxLength=900, ttlMinutes, continuationHint }
2053
+ * `continuationHint` 非空 = 用**调用方给的**收尾话术替换默认的「回复就能接着做」,
2054
+ * 且不再在结论缺失时邀请用户回复(免费档做不到,不能对他下这种指令)。
2055
+ * @returns {{kind:'completed', title:string, text:string, replyable:false, ttlMinutes:number}}
2056
+ * title 是**展示标题**(和 formatNotification 的 title 同义,【】里那一行);会话名在 text 里。
2057
+ */
2058
+ export function formatCompletion({ title, reason, summary, sessionId, hanging } = {}, opts = {}) {
2059
+ const reasonKey = String(reason ?? "").trim();
2060
+ const reasonText = stopReasonText(reasonKey);
2061
+ const name = String(title ?? "").trim();
2062
+ const sid = String(sessionId ?? "").trim();
2063
+ // 结论是**模型产出**,通常是 Markdown —— 先转成微信能看的纯文本(表格/加粗/链接见转换器注释)
2064
+ const bodyText = markdownToWechatText(summary);
2065
+ const summaryMax = Number.isFinite(opts.summaryMaxLength) ? opts.summaryMaxLength : COMPLETION_SUMMARY_MAX;
2066
+ const ttlMinutes = Number.isFinite(opts.ttlMinutes) ? opts.ttlMinutes : DEFAULT_REPLY_TTL_MINUTES;
2067
+
2068
+ // ① 头部:结束方式 + 是哪个任务(用户第一眼要找的两件事)
2069
+ const head = [`【任务${reasonText}】`];
2070
+ const note = COMPLETION_REASON_NOTES[reasonKey];
2071
+ if (note) head.push(note);
2072
+ if (name) {
2073
+ head.push(`会话:${name}`);
2074
+ } else if (sid) {
2075
+ // 没名字但知道 id → 给短号,用户能在 /ls 里对上号(总比"未命名"强)
2076
+ head.push(`会话:未命名(${sid.replace(/^session-/, "").slice(0, 8)})`);
2077
+ } else {
2078
+ head.push("会话:未命名(这次没取到会话名)");
2079
+ }
2080
+
2081
+ // ② 结论(**独立成段**,保留原有换行 —— 压成一行正是"密密麻麻"的来源)。
2082
+ // 只有「能回话」的档位才邀请用户回复 —— 否则那是对一个做不到的人下指令
2083
+ // (免费用户回复纯文本只会拿到付费引导,见 wechat-runtime 的 #upsellText)。
2084
+ const canContinue = opts.continuationHint === undefined;
2085
+ let conclusion;
2086
+ if (bodyText) {
2087
+ conclusion = clipText(bodyText, summaryMax);
2088
+ } else if (hanging) {
2089
+ conclusion = canContinue
2090
+ ? "暂时没取到(任务可能还在收尾)。回复 /summary 可以再要一次。"
2091
+ : "暂时没取到(任务可能还在收尾)。";
2092
+ } else {
2093
+ conclusion = canContinue
2094
+ ? "这次没有产出结论。回复一句话就能追问。"
2095
+ : "这次没有产出结论。";
2096
+ }
2097
+
2098
+ // ③ 下一步:系统注入的内容**单独分区**(不再和正文拖在一起)。
2099
+ // 付费档 = "回复即可续接"(产品核心);免费档由调用方换成"会员可用 + App 链接"。
2100
+ const continuation = opts.continuationHint
2101
+ || "· 直接回复一句话,就能接着这个会话往下做(不用重新交代背景)";
2102
+
2103
+ const headSection = head.join("\n");
2104
+ const tailSection = ["—— 下一步 ——", continuation].join("\n");
2105
+ let text = `${headSection}\n\n—— 结论 ——\n${conclusion}\n\n${tailSection}`;
2106
+
2107
+ // ④ 长度安全网:**只压正文**,头部与「下一步」永不截断。
2108
+ // ⚠️ 旧实现把整条 join 后盲切尾部 —— 而"下一步/回复 N"恰好拼在最后,
2109
+ // 于是一条超长消息会把**用户唯一能照做的那句话**切掉(真机投诉过的形态)。
2110
+ const maxLen = Number.isFinite(opts.maxLength) ? opts.maxLength : COMPLETION_TEXT_MAX;
2111
+ if (text.length > maxLen) {
2112
+ const room = Math.max(80, maxLen - headSection.length - tailSection.length - 30);
2113
+ text = `${headSection}\n\n—— 结论 ——\n${clipText(bodyText || conclusion, room)}\n\n${tailSection}`;
2114
+ }
2115
+
2116
+ return { kind: "completed", title: `任务${reasonText}`, text, replyable: false, ttlMinutes };
2117
+ }
2118
+
2119
+ /**
2120
+ * 回执编号注册表:每条发出去的通知拿一个不透明 eventId,
2121
+ * 入站回复用「编号」映射回它(§1 一步回执)。
2122
+ */
2123
+ export class EventRegistry {
2124
+ constructor({ max = 200, ttlMs = DEFAULT_REPLY_TTL_MINUTES * 60_000, clock = Date.now } = {}) {
2125
+ this.max = max;
2126
+ this.ttlMs = ttlMs;
2127
+ this.clock = clock;
2128
+ this.entries = new Map(); // eventId -> {eventId, options, kind, createdAt, expiresAt, meta}
2129
+ }
2130
+
2131
+ /** 登记一条可回执通知;返回其 eventId(调用方负责把它带进消息文案)。 */
2132
+ register({ eventId, options = [], kind = "", ttlMs = this.ttlMs, meta = {} } = {}) {
2133
+ const id = eventId || crypto.randomUUID();
2134
+ const now = this.clock();
2135
+ this.entries.set(id, {
2136
+ eventId: id,
2137
+ options,
2138
+ kind,
2139
+ meta,
2140
+ createdAt: now,
2141
+ expiresAt: now + ttlMs
2142
+ });
2143
+ while (this.entries.size > this.max) {
2144
+ const oldest = this.entries.keys().next().value;
2145
+ this.entries.delete(oldest);
2146
+ }
2147
+ return id;
2148
+ }
2149
+
2150
+ /** 取一条;**已过期 / 不存在**都返回 null,调用方据此回「这条已过期」(§6 ①)。 */
2151
+ get(eventId) {
2152
+ const e = this.entries.get(eventId);
2153
+ if (!e) return null;
2154
+ if (this.clock() > e.expiresAt) {
2155
+ this.entries.delete(eventId);
2156
+ return null;
2157
+ }
2158
+ return e;
2159
+ }
2160
+
2161
+ /** 用掉的立刻清掉,避免同一个编号被回复两次。 */
2162
+ consume(eventId) {
2163
+ const e = this.get(eventId);
2164
+ if (e) this.entries.delete(eventId);
2165
+ return e;
2166
+ }
2167
+
2168
+ expire(eventId) {
2169
+ return this.entries.delete(eventId);
2170
+ }
2171
+
2172
+ get size() {
2173
+ return this.entries.size;
2174
+ }
2175
+
2176
+ clear() {
2177
+ this.entries.clear();
2178
+ }
2179
+ }
2180
+
2181
+ /**
2182
+ * 把一条通知格式化成「带编号选项 + eventId」的完整出站规格。
2183
+ * 返回值里的 eventId 就是入站解析要映射回去的那个不透明 id。
2184
+ * ⚠️ 微信消息里**不出现** eventId(用户只回数字);eventId 只留在调用方/注册表。
2185
+ */
2186
+ export function buildOutboundNotification(node, registry, opts = {}) {
2187
+ const formatted = formatNotification(node, opts);
2188
+ if (!formatted.replyable || !registry) return { ...formatted, eventId: "" };
2189
+ const eventId = registry.register({
2190
+ eventId: node.eventId,
2191
+ options: formatted.options,
2192
+ kind: formatted.kind,
2193
+ ttlMs: formatted.ttlMinutes * 60_000,
2194
+ meta: node.meta || {}
2195
+ });
2196
+ return { ...formatted, eventId };
2197
+ }
2198
+
2199
+ // ===========================================================================
2200
+ // 9b. 破坏性工具识别(v2 安全约束:危险操作**不给一步回执**)
2201
+ // ===========================================================================
2202
+
2203
+ /**
2204
+ * 判定哲学:**假阳性便宜,假阴性昂贵**。
2205
+ * 假阳性 = 用户多走两步、回到电脑上当面点一次(烦,但没有任何损失);
2206
+ * 假阴性 = 手机上一下点掉 `rm -rf`,数据没了(不可逆,且**没有任何补救**)。
2207
+ * 所以**拿不准就判破坏性(true)**。
2208
+ *
2209
+ * 唯一的例外是"把什么都判成破坏性":那等于没有判定 —— 用户会对提示脱敏
2210
+ * (反正每次都要去电脑),安全约束反而废掉。所以普通的读/查/测试/构建
2211
+ * (`ls`、`cat`、`grep`、`npm test`、`git status`)以及 agent 的日常文件
2212
+ * 写入/编辑必须判 **false**。
2213
+ *
2214
+ * ⚠️ 真实生产者给的信息很薄:`dsh-events.mjs` 的审批节点只有
2215
+ * `toolName / callId / reason`(见该文件 1676-1685),**没有命令原文**。
2216
+ * 所以本函数按三层判:
2217
+ * ① 工具名本身带破坏语义(delete_file / rmdir / drop_table …);
2218
+ * ② detail / reason 文本里能认出破坏性意图(rm -rf、强推、DROP TABLE…);
2219
+ * ③ 能跑命令的工具**连一点可看的信息都没有**时,一律判破坏性
2220
+ * (看不见要跑什么 = 拿不准;见 SHELL_TOOL_NAME_RE)。
2221
+ */
2222
+
2223
+ /** 工具名本身就带破坏语义(detail 为空也要拦)。 */
2224
+ const DESTRUCTIVE_TOOL_NAME_RE =
2225
+ /(^|[^a-z0-9])(rm|rmdir|unlink|del|delete|remove|destroy|drop|truncate|purge|wipe|erase|shred|mkfs|revoke|force[_-]?push)([^a-z0-9]|$)/i;
2226
+
2227
+ /** 会执行命令的工具(第三层的判据:看不见命令原文就不给一步回执)。 */
2228
+ const SHELL_TOOL_NAME_RE =
2229
+ /(^|[^a-z0-9])(bash|sh|shell|zsh|fish|powershell|pwsh|cmd|terminal|exec|execute|run[_-]?command|subprocess|spawn|command)([^a-z0-9]|$)/i;
2230
+
2231
+ /** 搬运类工具:单个改名不算破坏性,但**带通配/强制**就是批量覆盖(见下面"批量搬运")。 */
2232
+ const MOVE_TOOL_NAME_RE = /(^|[^a-z0-9])(mv|move|rename|cp|copy)([^a-z0-9]|$)/i;
2233
+
2234
+ /**
2235
+ * detail/reason 文本里的破坏性意图。`why` 只用于排查/报告(函数只返回布尔)。
2236
+ * 每条都写得**具体**(要看得见的破坏动作),避免"什么命令都算破坏性"。
2237
+ */
2238
+ const DESTRUCTIVE_INTENT_PATTERNS = Object.freeze([
2239
+ // ── 删除(递归/强制/批量) ───────────────────────────────────────────────
2240
+ { re: /\brm\s+(-\S+\s+)*\S/i, why: "rm 删除" },
2241
+ { re: /\brmdir\b|\brd\s+\/s\b|\bremove-item\b/i, why: "删目录" },
2242
+ { re: /\b(del|delete|remove|unlink|erase|purge)\s+\S/i, why: "删除文件/对象" },
2243
+ { re: /\bfind\b[^\n]*(-delete\b|-exec\s+rm\b)/i, why: "find 批量删除" },
2244
+ { re: /\bgit\s+clean\s+-[a-z]*[fdx]/i, why: "git clean 丢弃未跟踪文件" },
2245
+ { re: /\bdocker\s+(system|volume|image|container)\s+prune\b|\bdocker\s+(rm|rmi)\b/i, why: "清理容器/镜像" },
2246
+ { re: /\bdocker\s+compose\s+down\b[^\n]*\s-v\b/i, why: "连数据卷一起拆" },
2247
+ { re: /\bkubectl\s+delete\b|\bterraform\s+(destroy|apply\s+-destroy)\b|\bhelm\s+uninstall\b/i, why: "删基础设施" },
2248
+ { re: /\baws\s+s3\s+(rm|rb)\b|\bgcloud\b[^\n]*\bdelete\b/i, why: "删云端资源" },
2249
+ { re: /\bnpm\s+unpublish\b/i, why: "撤回已发布的包" },
2250
+
2251
+ // ── 覆盖磁盘/分区 ─────────────────────────────────────────────────────
2252
+ { re: /\bmkfs(\.\w+)?\b|\bwipefs\b|\bshred\b/i, why: "格式化/擦除设备" },
2253
+ { re: /\bdiskutil\s+(erase|reformat|zeroDisk|secureErase)\w*/i, why: "抹掉磁盘" },
2254
+ { re: /\bdd\b[^\n]*\bof=/i, why: "dd 直接写设备/文件" },
2255
+ { re: /\bfdisk\b|\bparted\b|\bformat\s+[a-z]:/i, why: "改分区/格式化" },
2256
+ { re: /\bformat-volume\b/i, why: "格式化卷" },
2257
+
2258
+ // ── 重写历史/强推/丢弃改动 ────────────────────────────────────────────
2259
+ { re: /\bgit\s+push\b[^\n]*(--force\b|--force-with-lease\b|--delete\b|--mirror\b|\s-f\b)/i, why: "强推/删远端分支" },
2260
+ { re: /\bgit\s+(reset\s+--hard|filter-branch|filter-repo|rebase|update-ref\s+-d)\b/i, why: "重写历史" },
2261
+ { re: /\bgit\s+commit\b[^\n]*--amend\b/i, why: "改写已有提交" },
2262
+ { re: /\bgit\s+(checkout\s+--\s+\.|restore\s+\.|branch\s+-D|tag\s+-d|stash\s+(drop|clear))\b/i, why: "丢弃本地改动" },
2263
+
2264
+ // ── 数据库 ────────────────────────────────────────────────────────────
2265
+ { re: /\bdrop\s+(table|database|schema|collection|index|user|view)\b/i, why: "DROP" },
2266
+ { re: /\btruncate\b/i, why: "TRUNCATE" },
2267
+ { re: /\bdelete\s+from\b|\bdeleteMany\b|\bdropDatabase\b|\bdb\.\w+\.drop\b/i, why: "删数据" },
2268
+ { re: /--drop\b/i, why: "带 --drop 的导入/迁移" },
2269
+
2270
+ // ── 批量搬运/覆盖/递归改权限 ──────────────────────────────────────────
2271
+ { re: /\bmv\s+-[a-z]*f|\bmv\s+[^\n]*\*/i, why: "强制/批量移动" },
2272
+ { re: /\bmove\s+\/[yY]\b/i, why: "覆盖式移动" },
2273
+ { re: /\brsync\b[^\n]*--delete\b/i, why: "rsync --delete" },
2274
+ { re: /\bchmod\s+-[a-z]*R\b|\bchown\s+-[a-z]*R\b|\bchmod\s+777\b/i, why: "递归改权限" },
2275
+
2276
+ // ── 凭据/密钥(外泄面:手机上点一下就把密钥读了) ──────────────────────
2277
+ {
2278
+ re: /\.ssh\b|\bid_(rsa|dsa|ecdsa|ed25519)\b|\.aws\/credentials|\.netrc\b|\.npmrc\b|\.env\b|\.pem\b|\bkeychain\b|find-generic-password|\bsecretsmanager\b|get-secret-value|\bprivate[_-]?key\b|\bapi[_-]?key\b|\bpassword\b|\bpasswd\b|\bcredential/i,
2279
+ why: "读凭据/密钥"
2280
+ },
2281
+ { re: /\bgpg\b[^\n]*--export-secret/i, why: "导出私钥" },
2282
+
2283
+ // ── 停服务/杀进程/关机器 ─────────────────────────────────────────────
2284
+ { re: /\bsystemctl\s+(stop|disable|mask)\b|\blaunchctl\s+(unload|bootout|remove|stop)\b/i, why: "停系统服务" },
2285
+ { re: /\b(kill|killall|pkill)\s+-9\b|\bkillall\b|\bpkill\b/i, why: "强杀进程" },
2286
+ { re: /\b(shutdown|reboot|halt|poweroff)\b/i, why: "关机/重启" },
2287
+
2288
+ // ── 远端脚本直接喂给 shell(供应链) ──────────────────────────────────
2289
+ { re: /\b(curl|wget)\b[^\n]*\|\s*(sudo\s+)?(ba|z|k|d)?sh\b/i, why: "远端脚本直接执行" },
2290
+
2291
+ // ── 中文描述里写明的破坏性(事件侧 reason 常是中文) ──────────────────
2292
+ { re: /删库|删除所有|全部删除|彻底删除|格式化|强制推送|清空数据|数据丢失|不可恢复|销毁|擦除/, why: "描述里有明确破坏性措辞" }
2293
+ ]);
2294
+
2295
+ /** 把 detail(字符串/对象/数组)压成一段可扫的文本;循环引用/函数/超深结构都不抛。 */
2296
+ function flattenToolDetail(detail, depth = 0, seen = new Set()) {
2297
+ if (detail == null) return "";
2298
+ const t = typeof detail;
2299
+ if (t === "string") return detail;
2300
+ if (t === "symbol" || t === "function") return t === "symbol" ? String(detail) : "";
2301
+ if (t !== "object") return String(detail);
2302
+ if (depth > 4) return "";
2303
+ if (seen.has(detail)) return ""; // 循环引用:扫不出结论,但**绝不抛**
2304
+ seen.add(detail);
2305
+ try {
2306
+ if (Array.isArray(detail)) {
2307
+ return detail.map((v) => flattenToolDetail(v, depth + 1, seen)).join("\n");
2308
+ }
2309
+ const parts = [];
2310
+ for (const [k, v] of Object.entries(detail)) {
2311
+ parts.push(k);
2312
+ parts.push(flattenToolDetail(v, depth + 1, seen));
2313
+ }
2314
+ return parts.join("\n");
2315
+ } catch {
2316
+ return "";
2317
+ }
2318
+ }
2319
+
2320
+ /**
2321
+ * 这个工具/这次调用**是不是破坏性**?
2322
+ * 编排层据此**收回一步回执按钮**,让用户回电脑上当面确认。
2323
+ *
2324
+ * @param {string} toolName 工具名(如 Bash / Write / delete_file / mcp__fs__remove)
2325
+ * @param {string|object} [detail] 命令原文、参数对象,或事件侧给的 reason 文本
2326
+ * @returns {boolean} 拿不准 → true(理由见文件顶部注释)
2327
+ */
2328
+ export function isDestructiveTool(toolName, detail) {
2329
+ const name = String(toolName ?? "");
2330
+ if (DESTRUCTIVE_TOOL_NAME_RE.test(name)) return true;
2331
+
2332
+ const text = flattenToolDetail(detail);
2333
+ // ⚠️ 2026-09-22 按业主决定**删掉**了原来的规则③:「能跑命令的工具 + 完全看不到内容 → 算破坏性」。
2334
+ // 原意是防"盲批",但 DSH 的审批节点本来就只下发 toolName、常常没有命令原文,
2335
+ // 那条规则会把**绝大多数正常 Bash 审批**都降级成"只能回电脑确认",过严且伤体验。
2336
+ // 业主明确:安全边界松一点没问题,**DSH 自身有权限控制**。
2337
+ // 所以现在只看**内容**:看得出是 rm -rf / 强推 / DROP TABLE / 批量覆盖这类才拦;
2338
+ // 看不到内容时按普通审批处理(保留一步回执)。SHELL_TOOL_NAME_RE 仍保留,
2339
+ // 供将来若要恢复"盲批保护"时使用,并由测试固化其语义。
2340
+ if (!text.trim()) return false;
2341
+ for (const { re } of DESTRUCTIVE_INTENT_PATTERNS) {
2342
+ if (re.test(text)) return true;
2343
+ }
2344
+ // 搬运类工具 + 通配/强制 = 批量覆盖(单个改名不算,见 brief:"mass file moves")
2345
+ if (MOVE_TOOL_NAME_RE.test(name) && /[*?]|--force\b|\/force\b|\s-f\b/.test(text)) return true;
2346
+ return false;
2347
+ }
2348
+
2349
+ // ===========================================================================
2350
+ // 10. 入站解析(§11:真实形状未验证 → 防御式)
2351
+ // ===========================================================================
2352
+
2353
+ /** 全角数字/空白归一(NFKC 把 123 变 123,全角空格变普通空格)。 */
2354
+ export function normalizeInput(text) {
2355
+ return String(text ?? "")
2356
+ .normalize("NFKC")
2357
+ .replace(/[\u200b-\u200d\ufeff]/g, "")
2358
+ .trim();
2359
+ }
2360
+
2361
+ /**
2362
+ * 从 getupdates 的一条消息里取出**用户文本**(§11:真实形状未验证,全部防御式)。
2363
+ * 兼容:item_list[].text_item.text、单个 item、纯字符串 message、content 字段。
2364
+ */
2365
+ export function extractInboundText(msg) {
2366
+ if (msg == null) return "";
2367
+ if (typeof msg === "string") return msg;
2368
+ if (typeof msg !== "object") return "";
2369
+ const items = Array.isArray(msg.item_list)
2370
+ ? msg.item_list
2371
+ : msg.item
2372
+ ? [msg.item]
2373
+ : Array.isArray(msg.items)
2374
+ ? msg.items
2375
+ : [];
2376
+ for (const it of items) {
2377
+ if (!it || typeof it !== "object") continue;
2378
+ const type = it.type;
2379
+ // type 缺失时也认 text_item(形状未验证,宁可多认一次)
2380
+ if (type !== undefined && type !== MessageItemType.TEXT) continue;
2381
+ const t = it.text_item && typeof it.text_item === "object" ? it.text_item.text : undefined;
2382
+ if (typeof t === "string" && t.trim()) return t;
2383
+ }
2384
+ if (typeof msg.text === "string" && msg.text.trim()) return msg.text;
2385
+ if (typeof msg.content === "string" && msg.content.trim()) return msg.content;
2386
+ return "";
2387
+ }
2388
+
2389
+ /** 取出用户 id(from_user_id;兼容 fromUser、from)。 */
2390
+ export function extractFromUserId(msg) {
2391
+ if (!msg || typeof msg !== "object") return "";
2392
+ for (const k of ["from_user_id", "from_userId", "fromUser", "from"]) {
2393
+ const v = msg[k];
2394
+ if (typeof v === "string" && v) return v;
2395
+ }
2396
+ return "";
2397
+ }
2398
+
2399
+ /**
2400
+ * 别名 → 规范指令名。
2401
+ * ⚠️ 编排层是按**规范名**分派的(`cmd === "/ls"`),所以别名必须在 `classifyInbound()`
2402
+ * 里收敛一次;否则 `/list` 会掉进"未知指令",用户看到一句"不认识"。
2403
+ */
2404
+ export const COMMAND_ALIASES = Object.freeze({
2405
+ "/list": "/ls",
2406
+ "/sessions": "/ls"
2407
+ });
2408
+
2409
+ /**
2410
+ * 支持的指令集合。
2411
+ * ⚠️ 别名也要在册:`parseInboundMessage()` 用 COMMANDS 判断"这是不是一条指令",
2412
+ * 漏掉别名 → 明明是合法指令却被回成"不认识这条指令"。
2413
+ */
2414
+ export const COMMANDS = Object.freeze([
2415
+ "/new",
2416
+ "/ls",
2417
+ "/list",
2418
+ "/sessions",
2419
+ "/use",
2420
+ "/stop",
2421
+ "/status",
2422
+ "/summary",
2423
+ "/quiet",
2424
+ "/unbind",
2425
+ "/help"
2426
+ ]);
2427
+
2428
+ /**
2429
+ * 帮助文案(用户在微信里能看到的唯一说明书 → 短句、动词开头、说清"回什么会发生什么")。
2430
+ * ⚠️ COMMANDS 里每一条都必须在这里出现一次,否则用户根本不知道它存在。测试锁死这一点。
2431
+ */
2432
+ export const HELP_TEXT = [
2433
+ "【DSH 微信通道】",
2434
+ "回数字(如 1)可回执最近一条需要你拍板的消息。",
2435
+ "直接发一句话 = 说给当前任务(还没有任务时会新开一个)。",
2436
+ "/new <任务> 开新任务并把任务发下去(先不写任务也行)",
2437
+ "/ls 列出会话,用 /use <编号> 切换(/list、/sessions 同义)",
2438
+ "/use <编号> 切换到某个会话,之后你发的话都进它",
2439
+ "/stop 中断当前会话正在跑的回合",
2440
+ "/summary 重发当前会话的最近结论",
2441
+ "/status 查看绑定与推送状态",
2442
+ "/quiet 暂停推送(回复任意消息恢复)",
2443
+ "/unbind 解除微信绑定",
2444
+ "/help 显示本帮助"
2445
+ ].join("\n");
2446
+
2447
+ /**
2448
+ * 解析一条入站消息。
2449
+ *
2450
+ * @param {object} msg getupdates 里的一条消息(形状未验证 → 防御式)
2451
+ * @param {object} [opts] { registry, eventId }
2452
+ * @returns {{
2453
+ * ok:boolean, kind:'reply'|'command'|'unknown'|'empty', raw:string, from:string,
2454
+ * choice:number|null, digits:string, eventId:string, expired:boolean,
2455
+ * command:string, args:string, replyText:string
2456
+ * }}
2457
+ * - 数字(含全角、含首尾空白)→ kind:'reply', choice 为 1 基序号;
2458
+ * - 有 registry 时用 eventId 解析出对应事件;解析不到(过期/不存在)→ expired:true;
2459
+ * - `/xxx` → kind:'command';未知指令 → kind:'unknown'(调用方回帮助,绝不静默)。
2460
+ */
2461
+ export function parseInboundMessage(msg, opts = {}) {
2462
+ const raw = extractInboundText(msg);
2463
+ const from = extractFromUserId(msg);
2464
+ const base = {
2465
+ ok: false,
2466
+ kind: "empty",
2467
+ raw,
2468
+ from,
2469
+ choice: null,
2470
+ digits: "",
2471
+ eventId: opts.eventId || "",
2472
+ expired: false,
2473
+ command: "",
2474
+ args: "",
2475
+ replyText: ""
2476
+ };
2477
+ const text = normalizeInput(raw);
2478
+ if (!text) return base;
2479
+
2480
+ // 指令
2481
+ if (text.startsWith("/")) {
2482
+ const [cmdRaw, ...rest] = text.split(/\s+/);
2483
+ const cmd = cmdRaw.toLowerCase();
2484
+ if (COMMANDS.includes(cmd)) {
2485
+ return { ...base, ok: true, kind: "command", command: cmd, args: rest.join(" ") };
2486
+ }
2487
+ return {
2488
+ ...base,
2489
+ ok: false,
2490
+ kind: "unknown",
2491
+ command: cmd,
2492
+ args: rest.join(" "),
2493
+ replyText: `不认识这条指令:${cmd}\n\n${HELP_TEXT}`
2494
+ };
2495
+ }
2496
+
2497
+ // 纯数字回执(NFKC 之后全角数字已是半角;这里再兜一层 Unicode 数字判断)
2498
+ const digits = extractDigits(text);
2499
+ if (digits !== null) {
2500
+ const choice = Number(digits);
2501
+ const registry = opts.registry;
2502
+ const eventId = opts.eventId || "";
2503
+ if (registry && eventId) {
2504
+ const entry = registry.consume(eventId);
2505
+ if (!entry) {
2506
+ return {
2507
+ ...base,
2508
+ ok: true,
2509
+ kind: "reply",
2510
+ choice,
2511
+ digits,
2512
+ expired: true,
2513
+ replyText: "这条通知已过期或已被处理,请重新发起。"
2514
+ };
2515
+ }
2516
+ const opt = (entry.options || [])[choice - 1];
2517
+ if (!opt) {
2518
+ return {
2519
+ ...base,
2520
+ ok: false,
2521
+ kind: "unknown",
2522
+ choice,
2523
+ digits,
2524
+ replyText: `没有第 ${choice} 个选项。\n\n${HELP_TEXT}`
2525
+ };
2526
+ }
2527
+ return {
2528
+ ...base,
2529
+ ok: true,
2530
+ kind: "reply",
2531
+ choice,
2532
+ digits,
2533
+ eventId,
2534
+ expired: false,
2535
+ replyText: `已选择:${opt.label}`
2536
+ };
2537
+ }
2538
+ // 没有注册表(或没有 eventId)→ 仍然是合法回执,交由调用方按"最近一条"处理。
2539
+ return { ...base, ok: true, kind: "reply", choice, digits };
2540
+ }
2541
+
2542
+ // 其它自由文本:v1 不做对话(§1)→ 给帮助,绝不静默。
2543
+ return { ...base, ok: false, kind: "unknown", replyText: HELP_TEXT };
2544
+ }
2545
+
2546
+ /**
2547
+ * 提取纯数字(1..2 位)。返回字符串或 null。
2548
+ * 接受:裸数字 / 首尾空白 / 全角数字 / 全角空白 / 末尾句点。
2549
+ */
2550
+ export function extractDigits(text) {
2551
+ const t = normalizeInput(text);
2552
+ if (!t) return null;
2553
+ const m = /^([0-9]{1,2})[.。、]?$/.exec(t);
2554
+ if (m) return m[1];
2555
+ // 中文数字兜底("一"/"二")—— 老人机/语音输入常见
2556
+ const cn = { 一: "1", 二: "2", 三: "3", 四: "4", 五: "5", 六: "6", 七: "7", 八: "8", 九: "9" };
2557
+ if (t.length === 1 && cn[t]) return cn[t];
2558
+ return null;
2559
+ }
2560
+
2561
+ /**
2562
+ * 入站文本**分类**(纯函数、全量,不抛):v2 里同一句话有三种去向,必须先分清。
2563
+ *
2564
+ * ⚠️ 优先级不能换(编排层就按这个顺序分派):
2565
+ * ① `command` —— 否则「/new 修个 bug」会被当成发给会话的一句普通消息;
2566
+ * ② `choice` —— 裸数字是**回答上一条待办**;否则用户回「1」做审批时,
2567
+ * 这个「1」会被当成发给任务的文本;
2568
+ * ③ `message` —— 其余纯文本 = 说给当前会话的话(产品核心:回复即续接)。
2569
+ *
2570
+ * @param {string} text 原始入站文本(内部做 NFKC + 去零宽 + trim,调用方不必先处理)
2571
+ * @returns {{kind:'command'|'choice'|'message'|'empty', command?:string, args?:string,
2572
+ * choice?:number, text?:string}}
2573
+ * - command: `command` 已**收敛别名并小写**(`/list` → `/ls`),`args` 是其后原文;
2574
+ * - choice:`choice` 是 1 基序号(全角/中文数字已在 normalizeInput/extractDigits 里归一);
2575
+ * - message:`text` 是归一后的原文;
2576
+ * - empty:空/纯空白/纯零宽 → 什么都不做(不打扰用户,也绝不猜)。
2577
+ */
2578
+ export function classifyInbound(text) {
2579
+ const t = normalizeInput(text);
2580
+ if (!t) return { kind: "empty" };
2581
+ if (t.startsWith("/")) {
2582
+ const [cmdRaw, ...rest] = t.split(/\s+/);
2583
+ const typed = cmdRaw.toLowerCase();
2584
+ return { kind: "command", command: COMMAND_ALIASES[typed] || typed, args: rest.join(" ") };
2585
+ }
2586
+ const digits = extractDigits(t);
2587
+ if (digits !== null) return { kind: "choice", choice: Number(digits) };
2588
+ return { kind: "message", text: t };
2589
+ }
2590
+
2591
+ /** `/use <n>` 的编号:1 基正整数;非法(非纯数字 / <1 / 过大)返回 null。 */
2592
+ function useIndexFrom(args) {
2593
+ const t = normalizeInput(args);
2594
+ if (!t) return null;
2595
+ const m = /^([0-9]{1,9})$/.exec(t); // 全角已在 normalizeInput 里变半角
2596
+ if (!m) return null;
2597
+ const n = Number(m[1]);
2598
+ return Number.isSafeInteger(n) && n >= 1 ? n : null;
2599
+ }
2600
+
2601
+ /**
2602
+ * 指令 → 动作(纯函数,便于测试;真正的副作用由 bridge/编排层执行)。
2603
+ *
2604
+ * ⚠️ 字段名是 **`replyText`**(不是 text)。编排层拿到 replyText 才算"这条指令答复完了"。
2605
+ * ⚠️ 需要真实数据的动作(list/summary/status)replyText 只是**一句占位答复**:
2606
+ * 编排层应当用真实内容替换它(status 由 renderStatusText() 填,这是既有约定)。
2607
+ *
2608
+ * @returns {{action:string, params?:object, replyText:string}}
2609
+ * `/new <任务>` → `{action:'new', task, replyText}`(`task` 可能是空串)
2610
+ * `/ls`(及 `/list` `/sessions`) → `{action:'list', replyText}`
2611
+ * `/use <n>` → `{action:'use', index:{number|null}, replyText}`(非法输入 index 为 null)
2612
+ *
2613
+ * 两种调用形状都支持:`handleCommand("/new", "写周报")`、`handleCommand("/new 写周报")`。
2614
+ */
2615
+ export function handleCommand(command, args = "") {
2616
+ // 兼容两种调用形状:`handleCommand("/new", "写周报")` 与 `handleCommand("/new 写周报")`
2617
+ const raw = String(command ?? "").trim();
2618
+ const [head, ...inline] = raw.split(/\s+/);
2619
+ const cmd = (head || "").toLowerCase();
2620
+ const rawArgs = String(args ?? "").trim() ? String(args) : inline.join(" ");
2621
+ switch (cmd) {
2622
+ case "/new": {
2623
+ const task = rawArgs.trim();
2624
+ return {
2625
+ action: "new",
2626
+ task,
2627
+ replyText: task
2628
+ ? `已收到任务:${task}\n正在开一个新会话,开好就下发。`
2629
+ : "想让我做什么?把任务写在 /new 后面就行,例如:/new 帮我写一份周报。\n(也可以先开好,再直接把要求发给我。)"
2630
+ };
2631
+ }
2632
+ case "/ls":
2633
+ case "/list":
2634
+ case "/sessions":
2635
+ return { action: "list", replyText: "正在读取会话列表…" };
2636
+ case "/use": {
2637
+ const index = useIndexFrom(rawArgs);
2638
+ if (index === null) {
2639
+ return {
2640
+ action: "use",
2641
+ index: null,
2642
+ replyText: `请用 /use <编号> 指定要切到哪个会话,例如 /use 2(编号见 /ls 列表)。这一条没看懂:${rawArgs.trim() || "(空)"}`
2643
+ };
2644
+ }
2645
+ return { action: "use", index, replyText: `正在切换到会话 ${index}…` };
2646
+ }
2647
+ case "/stop":
2648
+ return { action: "stop", replyText: "已发出停止请求,任务停下来后我会把状态推给你。" };
2649
+ case "/summary":
2650
+ return { action: "summary", replyText: "正在整理当前会话的结论…" };
2651
+ case "/unbind":
2652
+ return {
2653
+ action: "unbind",
2654
+ replyText: "已解除微信绑定,不会再向你推送消息。如需重新绑定,请在面板里点「连接微信机器人」。"
2655
+ };
2656
+ case "/quiet":
2657
+ return { action: "quiet", replyText: "已暂停微信推送。回复任意消息即可恢复。" };
2658
+ case "/status":
2659
+ return { action: "status", replyText: "" }; // bridge 用 renderStatusText() 填
2660
+ case "/help":
2661
+ return { action: "help", replyText: HELP_TEXT };
2662
+ default:
2663
+ return { action: "unknown", replyText: `不认识这条指令。\n\n${HELP_TEXT}` };
2664
+ }
2665
+ }
2666
+
2667
+ /** /status 的回执文案(只含面板级别信息,绝不含 token —— §8)。 */
2668
+ export function renderStatusText(account, state, opts = {}) {
2669
+ const sanitized = sanitizeAccount(account);
2670
+ const st = state || emptyState();
2671
+ if (!sanitized.bound) return "当前未绑定微信机器人。请在面板里点「连接微信机器人」。";
2672
+ const lines = ["【DSH 微信通道状态】", `绑定: 已绑定(bot ${sanitized.botId || "未知"})`];
2673
+ if (st.connected_at) lines.push(`连接: ${new Date(st.connected_at).toLocaleString("zh-CN")}`);
2674
+ if (st.last_push_ok_at) lines.push(`最近一次推送: 成功(${new Date(st.last_push_ok_at).toLocaleString("zh-CN")})`);
2675
+ if (st.last_error) lines.push(`最近一次错误: ${shorten(st.last_error, 120)}`);
2676
+ if (opts.quiet) lines.push("推送: 已暂停(/quiet,回复任意消息恢复)");
2677
+ lines.push("在线离线只是提示,不影响绑定状态。");
2678
+ return lines.join("\n");
2679
+ }
2680
+
2681
+ // ===========================================================================
2682
+ // 11. 高层外壳(可选):把客户端的 venv 常量、凭据与状态串起来
2683
+ // ===========================================================================
2684
+
2685
+ /**
2686
+ * 便于 bridge 接线的薄封装(不主动起任何循环 —— 轮询由 bridge 决定何时跑)。
2687
+ *
2688
+ * @param {object} opts { relayDir, baseUrl, logger, clock }
2689
+ */
2690
+ export class WeChatChannel {
2691
+ constructor(opts = {}) {
2692
+ if (!opts.relayDir) throw new WeChatError("bad_options", "WeChatChannel: 缺少 relayDir");
2693
+ this.relayDir = opts.relayDir;
2694
+ this.logger = opts.logger || createLogger();
2695
+ this.clock = opts.clock || Date.now;
2696
+ this.account = loadAccount(this.relayDir);
2697
+ if (this.account) this.logger.addSecret(this.account.token);
2698
+ this.client = new IlinkClient({
2699
+ baseUrl: (this.account && this.account.baseUrl) || opts.baseUrl || DEFAULT_ILINK_BASE_URL,
2700
+ token: this.account ? this.account.token : "",
2701
+ clientVersion: opts.clientVersion,
2702
+ logger: this.logger,
2703
+ fetch: opts.fetch
2704
+ });
2705
+ this.cooldown = new SessionCooldown({ cooldownMs: opts.cooldownMs, clock: this.clock });
2706
+ this.registry = new EventRegistry({ clock: this.clock });
2707
+ this.updatesBuf = "";
2708
+ }
2709
+
2710
+ /** 面板接口用:永不回显 token(§8)。 */
2711
+ uiAccount() {
2712
+ return sanitizeAccount(this.account);
2713
+ }
2714
+
2715
+ /** 落盘状态(§9:bound/unbound 两态)。 */
2716
+ readState() {
2717
+ return loadState(this.relayDir);
2718
+ }
2719
+
2720
+ writeState(patch) {
2721
+ return saveState(this.relayDir, patch, (m) => this.logger.warn(m));
2722
+ }
2723
+
2724
+ /** 绑定成功后:存凭据 → 建客户端 → 状态标已绑定。 */
2725
+ adoptConfirmed(result) {
2726
+ const account = saveAccount(
2727
+ this.relayDir,
2728
+ {
2729
+ token: result.token,
2730
+ accountId: result.accountId,
2731
+ baseUrl: result.baseUrl || this.client.baseUrl,
2732
+ userId: result.userId || "",
2733
+ boundAt: this.clock()
2734
+ },
2735
+ (m) => this.logger.warn(m)
2736
+ );
2737
+ this.account = account;
2738
+ this.logger.addSecret(account.token); // 之后任何日志行都会自动脱敏
2739
+ this.client = new IlinkClient({
2740
+ baseUrl: account.baseUrl,
2741
+ token: account.token,
2742
+ clientVersion: this.client.clientVersion,
2743
+ logger: this.logger,
2744
+ fetch: this.client.fetchImpl
2745
+ });
2746
+ this.writeState({
2747
+ bound: true,
2748
+ bot_id: account.accountId,
2749
+ bound_at: account.boundAt,
2750
+ last_error: "",
2751
+ connected_at: this.clock()
2752
+ });
2753
+ // ★ 绑定成功必须**清掉冷却**(2026-09-22 实测 bug)。
2754
+ // 冷却原本只按"撞到 -14"计时,但**换绑会拿到全新的 bot_token** ——
2755
+ // 旧 token 的会话超时对新 token 毫无意义。不清的后果:用户解绑→重绑拿到新 token,
2756
+ // 却仍被旧 token 的 1 小时冷却挡着,表现成"刚绑上就一小时内不能聊天",
2757
+ // 而且面板只说"通知会延迟",用户完全不知道为什么。(业主实测撞到)
2758
+ this.cooldown.clear();
2759
+ return this.uiAccount();
2760
+ }
2761
+
2762
+ /**
2763
+ * 解绑(§8 顺序:停轮询 → notifystop → 删凭据)。
2764
+ * 停止长轮询是调用方的事(它持有循环);这里先 notifystop,再删凭据与状态。
2765
+ */
2766
+ async unbind() {
2767
+ let notifyError = "";
2768
+ if (this.account) {
2769
+ try {
2770
+ this.client.setToken(this.account.token);
2771
+ await this.client.notifyStop({});
2772
+ } catch (e) {
2773
+ notifyError = redact(e.message, [this.account.token]);
2774
+ this.logger.warn(`unbind: notifystop 失败(继续删凭据)${notifyError}`);
2775
+ }
2776
+ }
2777
+ const cleared = clearAccount(this.relayDir);
2778
+ this.account = null;
2779
+ this.client.setToken("");
2780
+ this.writeState({ bound: false, bot_id: "", bound_at: 0, connected_at: 0, last_error: notifyError });
2781
+ // ★ 解绑同样清冷却:凭据都删了,再"退避"没有任何意义 ——
2782
+ // 留着只会让用户重新绑定时继续被挡(见 adoptConfirmed 的注释)。
2783
+ this.cooldown.clear();
2784
+ return { ok: cleared, notifyError };
2785
+ }
2786
+
2787
+ /**
2788
+ * 标记推送失败/成功(状态文件里的健康提示;不影响绑定状态)。
2789
+ *
2790
+ * ⚠️ errText **必须在这里脱敏**,不能指望调用方先脱好 —— 这个状态文件是**面板可读**的,
2791
+ * 而错误原文里经常拼着 token/URL。实测:`markPush(false, "发送失败 … <token>")`
2792
+ * 会把明文 token 写进 `.wechat-state.json`(违反 §8「凭据永不落进面板可读的地方」)。
2793
+ */
2794
+ markPush(ok, errText = "") {
2795
+ if (ok) return this.writeState({ last_push_ok_at: this.clock(), last_error: "" });
2796
+ const known = this.account && this.account.token ? [this.account.token] : [];
2797
+ const safe = maskFieldsInText(String(redact(shorten(errText, 200), known)));
2798
+ return this.writeState({ last_error: safe });
2799
+ }
2800
+
2801
+ /**
2802
+ * 收到 -14 / session timeout 时统一入口:arm 冷却 + 记录错误状态。
2803
+ * bridge 在 getupdates / sendmessage 返回上调用它,然后按 cooldown.remainingMinutes() 退避。
2804
+ */
2805
+ noteSessionExpired(where = "api") {
2806
+ this.cooldown.arm(`${where}: session timeout(errcode -14)`);
2807
+ this.writeState({ last_error: `${where}: token 失效或会话过期(冷却 ${Math.round(SESSION_COOLDOWN_MS / 60000)} 分钟)` });
2808
+ this.logger.warn(`cooldown 已启动(${where}),${this.cooldown.remainingMinutes()} 分钟后重试`);
2809
+ return this.cooldown.until;
2810
+ }
2811
+ }
2812
+
2813
+ // ===========================================================================
2814
+ // 12. 路径辅助(bridge 接线用)
2815
+ // ===========================================================================
2816
+
2817
+ /** 默认 relayDir 解析:显式参数 > 环境变量 > DSH_HOME/profiles/web。 */
2818
+ export function resolveRelayDir(explicit) {
2819
+ if (explicit) return explicit;
2820
+ if (process.env.DSH_RELAY_DIR) return process.env.DSH_RELAY_DIR;
2821
+ const home = process.env.DSH_HOME || path.join(process.env.HOME || process.env.USERPROFILE || ".", ".dsh");
2822
+ return path.join(home, "profiles", "web");
2823
+ }
2824
+
2825
+ export const MODULE_PATH = fileURLToPath(import.meta.url);