@mrrisega/dsh-remote 0.6.9 → 0.6.10-beta.2

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 (29) hide show
  1. package/README.md +2 -1
  2. package/clients/dsh-remote/dsh-bridge.mjs +148 -9
  3. package/clients/dsh-remote/dsh-events.mjs +1981 -0
  4. package/clients/dsh-remote/src/lifecycle.mjs +42 -0
  5. package/clients/dsh-remote/test/dsh-events.test.mjs +1998 -0
  6. package/clients/dsh-remote/test/lifecycle.test.mjs +116 -1
  7. package/clients/dsh-remote/test/machine-fingerprint.test.mjs +148 -0
  8. package/clients/dsh-remote/test/wechat-channel.test.mjs +2033 -0
  9. package/clients/dsh-remote/test/wechat-e2e.test.mjs +481 -0
  10. package/clients/dsh-remote/test/wechat-runtime.test.mjs +626 -0
  11. package/clients/dsh-remote/wechat-channel.mjs +2705 -0
  12. package/clients/dsh-remote/wechat-runtime.mjs +1260 -0
  13. package/docs/telemetry.md +31 -2
  14. package/dsh-setup.mjs +134 -40
  15. package/package.json +6 -5
  16. package/packages/dsh-remote-web/lib/client.js +799 -65
  17. package/packages/dsh-remote-web/lib/index.js +460 -10
  18. package/packages/dsh-remote-web/package.json +1 -1
  19. package/packages/dsh-remote-web/test/activation-single-point.test.mjs +104 -0
  20. package/packages/dsh-remote-web/test/patch-activation.test.mjs +25 -12
  21. package/packages/dsh-remote-web/test/quota-absent.test.mjs +19 -4
  22. package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +51 -1
  23. package/packages/dsh-remote-web/test/settings-entry.test.mjs +17 -0
  24. package/packages/dsh-remote-web/test/telemetry.test.mjs +190 -3
  25. package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +5 -0
  26. package/packages/dsh-remote-web/test/wechat-bind-telemetry.test.mjs +357 -0
  27. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +866 -0
  28. package/packages/dsh-remote-web/test/wechat-proxy.test.mjs +369 -0
  29. package/packages/dsh-remote-web/test/windows-compat.test.mjs +117 -0
@@ -0,0 +1,1260 @@
1
+ /**
2
+ * 微信机器人通道 —— **运行时编排层**(bridge 侧)。
3
+ *
4
+ * 分工(见 docs/wechat-bot-channel.md):
5
+ * · `wechat-channel.mjs` 协议原语:ilink 客户端 / 扫码状态机 / 凭据 / 消息格式化 / 入站解析
6
+ * · `dsh-events.mjs` DSH 事件订阅:/api/remote.mux 的 $events 与 session/follow
7
+ * · **本文件** 把两者拼成一条能跑的生命周期:绑定控制面 + 出站通知路由 + 入站长轮询
8
+ * · `dsh-bridge.mjs` 只负责 import 本模块、start/stop(见其 runTunnel)
9
+ *
10
+ * 为什么要单独一层:上游两个模块刻意**不做循环**(wechat-channel.mjs 的 unbind 注释写着
11
+ * 「停止长轮询是调用方的事(它持有循环)」),而"谁持有循环、收到事件发给谁、用户回数字对应哪条
12
+ * 通知"是本产品的编排决策,不属于协议层。放在这里也让 bridge 主文件保持精简。
13
+ *
14
+ * 对外两个入口:
15
+ * · `createWeChatRuntime({...})` → 运行时对象(供 bridge 与测试使用)
16
+ * · 控制面 HTTP(仅回环 + bridge_secret):供插件宿主半边代理面板请求
17
+ *
18
+ * ⚠️ 安全边界
19
+ * · 控制面只 bind 127.0.0.1,**绝不** 0.0.0.0
20
+ * · 端口用 0(临时端口),写进 `.wechat-control.json` 供宿主半边发现
21
+ * · 每个请求校验 `x-dsh-bridge-secret`(来自 .dsh-config.json,与 bridge 既有密钥同源)
22
+ * · 面板侧响应一律走 sanitizeAccount() —— **永不回显 bot_token**
23
+ */
24
+
25
+ import http from "node:http";
26
+ import fs from "node:fs";
27
+ import path from "node:path";
28
+
29
+ import {
30
+ WeChatChannel,
31
+ startBind,
32
+ createLogger,
33
+ redact,
34
+ saveState,
35
+ loadState,
36
+ sanitizeAccount,
37
+ buildOutboundNotification,
38
+ normalizeInput,
39
+ extractDigits,
40
+ extractInboundText,
41
+ extractFromUserId,
42
+ handleCommand,
43
+ classifyInbound,
44
+ formatCompletion,
45
+ isDestructiveTool,
46
+ renderStatusText,
47
+ stopReasonText,
48
+ isSessionExpired,
49
+ writePrivateJson,
50
+ SessionCooldown,
51
+ DEFAULT_UPDATES_TIMEOUT_MS
52
+ } from "./wechat-channel.mjs";
53
+ import { createEventSubscriber, NODE_KINDS } from "./dsh-events.mjs";
54
+
55
+ /**
56
+ * v2:微信不再只是"通知器",而是**能创建会话、下发任务、追问续接**的遥控器。
57
+ *
58
+ * ⚠️ 这改变了安全面:一旦能 `session/prompt`,机器人就能在你电脑上驱动 agent 改文件、跑命令。
59
+ * 所以本文件有两条硬约束(别在重构时弄丢):
60
+ * ① 通道只在**已绑定**时启动(绑定又要求 bridge 有账号 → 等价于"必须注册登录");
61
+ * ② **破坏性操作不给一步回执** —— 见 `#notify` 里的 `isDestructiveTool` 分支。
62
+ */
63
+
64
+ /**
65
+ * v2 能力表(运营钩子)。
66
+ *
67
+ * 业主的运营规划:**当前全部免费开放**,后续留钩子让付费用户用更高阶的功能。
68
+ * 所以这里把「谁能用什么」做成**一张表 + 一处判断**,而不是散在代码里的 if:
69
+ * 将来加付费档 = 改这张表,不动任何业务逻辑(也不改架构)。
70
+ *
71
+ * ⚠️ 这张表现在是 **permissive** 的(免费档已含全部能力),所以它**不改变今天的可用性**;
72
+ * 但它在代码路径上是活的(每次派发都会查),不是死代码。
73
+ * ⚠️ `tier` 暂时恒为 "free" —— bridge 目前拿不到账号套餐。付费档上线时,
74
+ * 由 host 半边/账号 API 提供真实 tier 再塞进 `#tier()`,**只需改这一个函数**。
75
+ */
76
+ export const WECHAT_CAPABILITY_TABLE = Object.freeze({
77
+ free: Object.freeze(["notify", "approve", "assign", "sessions", "summary"]),
78
+ // 预留给付费档:中途纠偏(steer)、附件、多会话并行。今天不启用。
79
+ pro: Object.freeze(["notify", "approve", "assign", "sessions", "summary", "steer", "attach", "multi"])
80
+ });
81
+
82
+ /** 某档位具备哪些能力。未知档位按最低档(free)处理 —— 宁可少给,不可误放。 */
83
+ export function capabilitiesFor(tier, table = WECHAT_CAPABILITY_TABLE) {
84
+ const key = Object.prototype.hasOwnProperty.call(table, String(tier || "")) ? String(tier) : "free";
85
+ return table[key];
86
+ }
87
+
88
+ /**
89
+ * ★ 两个上游模块的**节点词表不一致**,这里是唯一的翻译点。
90
+ *
91
+ * 背景:`dsh-events.mjs` 与 `wechat-channel.mjs` 是并行开发的两个模块,各自按规格 §5 定了
92
+ * 自己的 kind 名 —— 事件侧用「事件语义」(`approval-request`),文案侧用「展示语义」(`approval`)。
93
+ * 两边单测各自全绿,拼在一起却不认识彼此。翻译放在编排层(本文件)而不是改动任一上游:
94
+ * 上游各自的名字都对,耦合点只有一处,放在这里能被一条测试完整覆盖。
95
+ *
96
+ * ⚠️ 新增节点时必须同时改这里,否则用户收到的是「未知节点」——`assertKindCoverage()` 会先报错。
97
+ */
98
+ const EVENT_KIND_TO_FORMATTER_KIND = Object.freeze({
99
+ [NODE_KINDS.APPROVAL_REQUEST]: "approval",
100
+ [NODE_KINDS.USER_QUESTION]: "question",
101
+ [NODE_KINDS.PLAN_REVIEW]: "plan",
102
+ [NODE_KINDS.SESSION_ERROR]: "error",
103
+ [NODE_KINDS.TURN_END]: "stopped",
104
+ [NODE_KINDS.DIGEST_DUE]: "daily",
105
+ [NODE_KINDS.QUOTA_LOW]: "quota",
106
+ [NODE_KINDS.MEMBERSHIP_EXPIRING]: "membership"
107
+ });
108
+
109
+ /** 没有文案模板的节点:不是"漏了",而是按设计另行处理(见 #notify)。 */
110
+ const SPECIAL_EVENT_KINDS = Object.freeze([
111
+ NODE_KINDS.EVENT_EXPIRED,
112
+ NODE_KINDS.GAP,
113
+ NODE_KINDS.FAULT
114
+ ]);
115
+
116
+ /**
117
+ * 把事件节点翻译成文案节点。
118
+ * @returns {{node: object|null, formatterKind: string}}
119
+ */
120
+ export function toFormatterNode(eventNode = {}) {
121
+ const formatterKind = EVENT_KIND_TO_FORMATTER_KIND[eventNode.kind] || "";
122
+ if (!formatterKind) return { node: null, formatterKind: "" };
123
+ const node = { ...eventNode, kind: formatterKind };
124
+
125
+ // 字段名对齐:事件侧叫 toolName,文案侧读 tool
126
+ if (node.tool == null && node.toolName != null) node.tool = node.toolName;
127
+
128
+ // 提问:事件侧是 questions[{id,question,options[]}],文案侧读 prompt + options[]
129
+ if (formatterKind === "question" || formatterKind === "plan") {
130
+ const q = Array.isArray(node.questions) ? node.questions[0] : null;
131
+ if (q) {
132
+ if (!node.prompt) node.prompt = q.question || q.detail || "";
133
+ if (!Array.isArray(node.options) || !node.options.length) {
134
+ node.options = Array.isArray(q.options) ? q.options : [];
135
+ }
136
+ }
137
+ }
138
+
139
+ // 简报:文案侧读 lines[]
140
+ if (formatterKind === "daily" && !Array.isArray(node.lines)) {
141
+ const parts = [];
142
+ if (node.notified != null) parts.push(`今天为你推送了 ${node.notified} 条提醒`);
143
+ if (node.answered != null) parts.push(`你回执了 ${node.answered} 条`);
144
+ if (!parts.length) parts.push("今天暂时没有要你处理的事。");
145
+ node.lines = parts;
146
+ }
147
+
148
+ // 停止:事件侧的 reason 是机器值(completed/aborted/…),文案侧自己会翻译,
149
+ // 但 detail 留一份人类可读的,便于排查
150
+ if (formatterKind === "stopped" && node.reason) {
151
+ node.detail = stopReasonText(node.reason);
152
+ }
153
+
154
+ return { node, formatterKind };
155
+ }
156
+
157
+ /**
158
+ * 自检:事件侧声明的每个 kind 都必须有归宿(翻译表 或 特殊列表)。
159
+ * 这样"上游新增了节点、编排层忘了接"会**当场**暴露,而不是等用户收到「未知节点」。
160
+ */
161
+ export function assertKindCoverage() {
162
+ const missing = Object.values(NODE_KINDS).filter(
163
+ (k) => !EVENT_KIND_TO_FORMATTER_KIND[k] && !SPECIAL_EVENT_KINDS.includes(k)
164
+ );
165
+ if (missing.length) {
166
+ throw new Error(`wechat-runtime: 事件侧新增了未接线的节点 kind: ${missing.join(", ")}(请补 EVENT_KIND_TO_FORMATTER_KIND)`);
167
+ }
168
+ return true;
169
+ }
170
+
171
+ /** 控制面发现文件(宿主半边读它拿端口)。不含密钥 —— 密钥在 .dsh-config.json。 */
172
+ export const CONTROL_FILE = ".wechat-control.json";
173
+ /** 控制面鉴权头。 */
174
+ export const CONTROL_HEADER = "x-dsh-bridge-secret";
175
+ /** 绑定会话的默认存活时长(面板上二维码可被扫的时间)。 */
176
+ export const DEFAULT_BIND_TTL_MS = 5 * 60_000;
177
+ /** 默认每日简报时刻(本地时区小时,0-23)。 */
178
+ export const DEFAULT_DIGEST_HOUR = 9;
179
+
180
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
181
+
182
+ /** 读一个 JSON **文件**(控制面发现文件等)。 */
183
+ function readJson(file) {
184
+ try {
185
+ return JSON.parse(fs.readFileSync(file, "utf8"));
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * 解析请求体里的 JSON **文本**。
193
+ *
194
+ * ⚠️ 必须与 readJson 分开:早先控制面把请求体交给了 readJson,而它是**读文件**的
195
+ * (`fs.readFileSync(<一段 JSON 文本>)` 必然抛错 → 返回 null),于是**所有 POST 体都是 null** ——
196
+ * 表现是「提交配对码」永远回「请输入手机微信上显示的数字」,而且没有任何报错线索。
197
+ * 端到端绑定流程测试抓到了它(wechat-e2e.test.mjs)。
198
+ */
199
+ function parseBodyText(text) {
200
+ try {
201
+ const v = JSON.parse(String(text || ""));
202
+ return v && typeof v === "object" ? v : null;
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+
208
+ /** 极简请求体读取(限制体积,防止回环上的畸形请求把内存吃满)。 */
209
+ function readBody(req, limit = 64 * 1024) {
210
+ return new Promise((resolve) => {
211
+ let size = 0;
212
+ const chunks = [];
213
+ req.on("data", (c) => {
214
+ size += c.length;
215
+ if (size > limit) {
216
+ req.destroy();
217
+ resolve("");
218
+ return;
219
+ }
220
+ chunks.push(c);
221
+ });
222
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
223
+ req.on("error", () => resolve(""));
224
+ });
225
+ }
226
+
227
+ function sendJson(res, status, body) {
228
+ const text = JSON.stringify(body);
229
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
230
+ res.end(text);
231
+ }
232
+
233
+ export class WeChatRuntime {
234
+ /**
235
+ * @param {{
236
+ * relayDir: string,
237
+ * upstream?: string, // dsh web 地址,默认 http://127.0.0.1:3080
238
+ * cookieOf?: () => string, // 读 .harness-cookie.json 的回调(bridge 已有该逻辑)
239
+ * secret?: string, // 控制面密钥(bridge_secret);空则控制面拒绝所有请求
240
+ * logger?: any,
241
+ * clock?: () => number,
242
+ * fetch?: typeof fetch,
243
+ * port?: number, // 控制面端口,0=临时端口(默认)
244
+ * bindTtlMs?: number,
245
+ * digestHour?: number,
246
+ * disabled?: boolean, // 总开关
247
+ * }} opts
248
+ */
249
+ constructor(opts = {}) {
250
+ if (!opts.relayDir) throw new Error("WeChatRuntime: 缺少 relayDir");
251
+ this.relayDir = opts.relayDir;
252
+ this.upstream = String(opts.upstream || "http://127.0.0.1:3080").replace(/\/+$/, "");
253
+ this.cookieOf = typeof opts.cookieOf === "function" ? opts.cookieOf : () => "";
254
+ this.secret = String(opts.secret || "");
255
+ this.clock = opts.clock || Date.now;
256
+ this.fetchImpl = opts.fetch;
257
+ this.logger = opts.logger || createLogger();
258
+ this.bindTtlMs = opts.bindTtlMs || DEFAULT_BIND_TTL_MS;
259
+ this.digestHour = Number.isInteger(opts.digestHour) ? opts.digestHour : DEFAULT_DIGEST_HOUR;
260
+ this.controlPort = Number.isInteger(opts.port) ? opts.port : 0;
261
+ this.disabled = !!opts.disabled;
262
+ // 冷却时长可注入:生产用 1 小时(与腾讯官方插件一致),测试用短值以便验证"不猛打接口"。
263
+ this.cooldownMs = Number.isFinite(opts.cooldownMs) && opts.cooldownMs > 0 ? opts.cooldownMs : undefined;
264
+
265
+ this.channel = new WeChatChannel({
266
+ relayDir: this.relayDir,
267
+ logger: this.logger,
268
+ clock: this.clock,
269
+ fetch: this.fetchImpl,
270
+ cooldownMs: this.cooldownMs,
271
+ // 未绑定时客户端用这个 baseUrl(生产恒为腾讯 ilink 默认值)。
272
+ // 可注入是为了让「绑定流程」能在本地假上游上被测 —— 否则绑定路径只能靠真机扫码验证。
273
+ baseUrl: opts.baseUrl
274
+ });
275
+ this.subscriber = null;
276
+ this.controlServer = null;
277
+ this.boundPort = 0;
278
+
279
+ this.stopping = false;
280
+ this.channelTask = null;
281
+ this.digestTask = null;
282
+
283
+ /** 绑定会话(同一时刻至多一个)。 */
284
+ this.bind = null;
285
+ /**
286
+ * 可回执通知的**待答队列**(最近的在末尾)。
287
+ * 微信里用户只回一个数字,消息里不带 eventId(见 buildOutboundNotification 注释),
288
+ * 所以数字必须映射到"最近一条待答通知" —— 这就是那张表存在的理由。
289
+ */
290
+ this.pendingReplies = [];
291
+ /** 当日简报计数(本地,不落盘)。 */
292
+ this.todayStats = { day: "", notified: 0, answered: 0 };
293
+
294
+ /**
295
+ * v2「当前会话」指针。**必须从状态文件恢复** —— 否则 bridge 一重启,
296
+ * 用户之前选好的任务就丢了,他再发一句话会静默开成一个**新任务**,
297
+ * 而不是继续原来那个(用户完全无从察觉)。
298
+ */
299
+ const persisted = loadState(this.relayDir);
300
+ this.currentSessionId = persisted.current_session_id || "";
301
+ this.currentSessionTitle = persisted.current_session_title || "";
302
+ /** `/ls` 的结果缓存(1-based 序号供 /use 使用)。 */
303
+ this.sessionIndex = [];
304
+ /**
305
+ * 运营钩子的两个可注入点(都只为可测与将来接真实套餐;生产不传即用默认)。
306
+ * `tierOverride` 空 = 按 free;`capabilityTable` 空 = 用内置表。
307
+ */
308
+ this.tierOverride = typeof opts.tier === "string" ? opts.tier : "";
309
+ this.capabilityTable = opts.capabilityTable || WECHAT_CAPABILITY_TABLE;
310
+ }
311
+
312
+ // ── 状态 ────────────────────────────────────────────────────────────────
313
+
314
+ /** 面板可见状态。**只返回脱敏字段**。 */
315
+ status() {
316
+ const state = loadState(this.relayDir);
317
+ const acct = sanitizeAccount(this.channel.account);
318
+ const cooldownMs = this.channel.cooldown.remainingMs ? this.channel.cooldown.remainingMs() : 0;
319
+ return {
320
+ ok: true,
321
+ disabled: this.disabled,
322
+ bound: !!acct.bound,
323
+ bot_id: acct.botId || "",
324
+ bound_at: acct.boundAt || 0,
325
+ connected_at: state.connected_at || 0,
326
+ last_push_ok_at: state.last_push_ok_at || 0,
327
+ last_error: state.last_error || "",
328
+ cooldown_ms: cooldownMs,
329
+ pending_replies: this.pendingReplies.length,
330
+ // ⚠️ 失败/成功后 bind.done=true 但仍留着对象 —— 必须一起判,否则面板在绑定失败后
331
+ // 会一直显示"正在绑定"(真机实测撞到:超时后 binding.active 还是 true)。
332
+ binding: this.bind && !this.bind.done
333
+ ? { active: true, need_verify_code: !!this.bind.needVerifyCode }
334
+ : { active: false, failed: !!(this.bind && this.bind.done && !this.bind.result) },
335
+ channel_running: !!this.channelTask,
336
+ events_running: !!this.subscriber
337
+ };
338
+ }
339
+
340
+ /** 通知文案里用的机器人自称与奖励口径(与产品口径同源)。 */
341
+ #notifyOpts() {
342
+ return { botName: "ClawBot" };
343
+ }
344
+
345
+ // ── 控制面 HTTP ─────────────────────────────────────────────────────────
346
+
347
+ /** 启动控制面(仅回环)。端口写进发现文件。 */
348
+ async startControl() {
349
+ if (this.controlServer) return this.boundPort;
350
+ this.controlServer = http.createServer((req, res) => {
351
+ this.#handleControl(req, res).catch((e) => {
352
+ try { sendJson(res, 500, { ok: false, error: redact(String(e && e.message ? e.message : e)) }); } catch { /* 已断开 */ }
353
+ });
354
+ });
355
+ // ★ 只 bind 回环 —— 控制面能改机器人绑定,绝不能暴露到局域网/公网。
356
+ await new Promise((resolve, reject) => {
357
+ this.controlServer.once("error", reject);
358
+ this.controlServer.listen(this.controlPort, "127.0.0.1", () => {
359
+ this.controlServer.removeListener("error", reject);
360
+ resolve();
361
+ });
362
+ });
363
+ this.boundPort = this.controlServer.address().port;
364
+ this.#publishControlFile();
365
+ this.logger.info(`[wechat] 控制面已就绪 127.0.0.1:${this.boundPort}(仅回环 + 密钥)`);
366
+ return this.boundPort;
367
+ }
368
+
369
+ #publishControlFile() {
370
+ try {
371
+ writePrivateJson(
372
+ path.join(this.relayDir, CONTROL_FILE),
373
+ { port: this.boundPort, pid: process.pid, started_at: this.clock(), header: CONTROL_HEADER },
374
+ (m) => this.logger.warn(m)
375
+ );
376
+ } catch (e) {
377
+ this.logger.warn(`[wechat] 写控制面发现文件失败:${redact(String(e.message || e))}`);
378
+ }
379
+ }
380
+
381
+ async #handleControl(req, res) {
382
+ if (!this.secret) {
383
+ // 没有密钥 = 拒绝一切(而不是放行)—— 避免配置缺失时控制面变成开放后门。
384
+ return sendJson(res, 403, { ok: false, error: "bridge_secret 未配置,控制面已禁用" });
385
+ }
386
+ const got = String(req.headers[CONTROL_HEADER] || "");
387
+ if (got !== this.secret) return sendJson(res, 401, { ok: false, error: "unauthorized" });
388
+
389
+ const url = new URL(req.url || "/", "http://127.0.0.1");
390
+ const route = `${req.method} ${url.pathname}`;
391
+ const body = req.method === "POST" ? parseBodyText(await readBody(req)) : null;
392
+
393
+ switch (route) {
394
+ case "GET /wechat/status":
395
+ return sendJson(res, 200, this.status());
396
+
397
+ case "POST /wechat/bind/start": {
398
+ const r = await this.beginBind();
399
+ return sendJson(res, r.ok ? 200 : 400, r);
400
+ }
401
+ case "GET /wechat/bind/poll": {
402
+ const r = await this.pollBind();
403
+ return sendJson(res, r.ok ? 200 : 400, r);
404
+ }
405
+ case "POST /wechat/bind/verify": {
406
+ const r = this.submitVerifyCode(body && body.code);
407
+ return sendJson(res, r.ok ? 200 : 400, r);
408
+ }
409
+ case "POST /wechat/bind/cancel":
410
+ return sendJson(res, 200, this.cancelBind());
411
+
412
+ case "POST /wechat/unbind":
413
+ return sendJson(res, 200, await this.unbind());
414
+
415
+ default:
416
+ return sendJson(res, 404, { ok: false, error: `no such route: ${route}` });
417
+ }
418
+ }
419
+
420
+ async stopControl() {
421
+ const srv = this.controlServer;
422
+ this.controlServer = null;
423
+ if (!srv) return;
424
+ await new Promise((resolve) => srv.close(() => resolve()));
425
+ try { fs.rmSync(path.join(this.relayDir, CONTROL_FILE), { force: true }); } catch { /* 忽略 */ }
426
+ }
427
+
428
+ // ── 生命周期 ────────────────────────────────────────────────────────────
429
+
430
+ async start() {
431
+ if (this.disabled) {
432
+ this.logger.info("[wechat] 已按配置禁用(DSH_WECHAT=0)");
433
+ return { ok: true, disabled: true };
434
+ }
435
+ await this.startControl();
436
+ if (this.channel.account) {
437
+ await this.startChannel();
438
+ } else {
439
+ this.logger.info("[wechat] 未绑定微信,等待面板发起绑定");
440
+ }
441
+ this.#startDigestTimer();
442
+ return { ok: true, port: this.boundPort, bound: !!this.channel.account };
443
+ }
444
+
445
+ async stop() {
446
+ this.stopping = true;
447
+ this.cancelBind();
448
+ if (this.digestTask) { clearInterval(this.digestTask); this.digestTask = null; }
449
+ await this.stopChannel();
450
+ await this.stopControl();
451
+ }
452
+
453
+ // ── 出站通道(绑定后才起) ──────────────────────────────────────────────
454
+
455
+ async startChannel() {
456
+ if (this.channelTask || !this.channel.account) return false;
457
+ this.stopping = false;
458
+ this.channel.writeState({ connected_at: this.clock(), last_error: "" });
459
+ this.#startSubscriber();
460
+ this.channelTask = this.#channelLoop().catch((e) => {
461
+ this.logger.warn(`[wechat] 长轮询退出:${redact(String(e && e.message ? e.message : e))}`);
462
+ this.channelTask = null;
463
+ });
464
+ return true;
465
+ }
466
+
467
+ async stopChannel() {
468
+ this.stopping = true;
469
+ if (this.subscriber) {
470
+ try { this.subscriber.close(); } catch { /* 忽略 */ }
471
+ this.subscriber = null;
472
+ }
473
+ const task = this.channelTask;
474
+ this.channelTask = null;
475
+ if (task) { try { await Promise.race([task, sleep(1500)]); } catch { /* 忽略 */ } }
476
+ // 通知腾讯侧"通道客户端下线"
477
+ try {
478
+ if (this.channel.account) {
479
+ this.channel.client.setToken(this.channel.account.token);
480
+ await this.channel.client.notifyStop({});
481
+ }
482
+ } catch (e) {
483
+ this.logger.warn(`[wechat] notifystop 失败(忽略):${redact(String(e && e.message ? e.message : e))}`);
484
+ }
485
+ }
486
+
487
+ /**
488
+ * 入站长轮询。user 的回复经此进来。
489
+ *
490
+ * 退避策略:命中 errcode -14(session timeout)时**不重试**,按腾讯官方插件的做法
491
+ * 冷却 1 小时(§3)——否则会把接口打爆,而且掩盖真正的失效原因。
492
+ */
493
+ async #channelLoop() {
494
+ // 上线信号:告诉腾讯侧"通道客户端起来了"(§3 notifystart)
495
+ try {
496
+ await this.channel.client.notifyStart({});
497
+ } catch (e) {
498
+ this.logger.warn(`[wechat] notifystart 失败(继续):${redact(String(e && e.message ? e.message : e))}`);
499
+ }
500
+ this.channel.writeState({ connected_at: this.clock(), last_error: "" });
501
+
502
+ while (!this.stopping) {
503
+ // ⚠️ SessionCooldown **没有** isActive() —— 它只有 remainingMs()/remainingMinutes()/arm()。
504
+ // 早先这里写成 `cooldown.isActive && cooldown.isActive()` 会静默短路成 false,
505
+ // 后果是 -14 冷却**完全不生效**、命中 session timeout 后仍继续猛打接口。
506
+ // 这正是本模块要防的事,所以只用 remainingMs() 判,并由测试锁死。
507
+ const cooldownMs = this.channel.cooldown.remainingMs();
508
+ if (cooldownMs > 0) {
509
+ await sleep(Math.min(60_000, Math.max(1000, cooldownMs)));
510
+ continue;
511
+ }
512
+ let resp;
513
+ try {
514
+ resp = await this.channel.client.getUpdates({
515
+ buf: this.channel.updatesBuf,
516
+ timeoutMs: DEFAULT_UPDATES_TIMEOUT_MS
517
+ });
518
+ } catch (e) {
519
+ this.#noteFailure(`getupdates 失败:${redact(String(e && e.message ? e.message : e))}`);
520
+ await sleep(3000);
521
+ continue;
522
+ }
523
+ if (isSessionExpired(resp)) {
524
+ this.channel.noteSessionExpired("getupdates");
525
+ continue;
526
+ }
527
+ if (typeof resp.get_updates_buf === "string") this.channel.updatesBuf = resp.get_updates_buf;
528
+ for (const msg of Array.isArray(resp.msgs) ? resp.msgs : []) {
529
+ try { await this.handleInbound(msg); } catch (e) {
530
+ this.logger.warn(`[wechat] 处理入站消息失败:${redact(String(e && e.message ? e.message : e))}`);
531
+ }
532
+ }
533
+ }
534
+ }
535
+
536
+ // ── 入站:回执与指令 ────────────────────────────────────────────────────
537
+
538
+ /** 取"最近一条待答通知"(用户回数字时映射到它)。 */
539
+ #latestPending() {
540
+ const now = this.clock();
541
+ while (this.pendingReplies.length) {
542
+ const head = this.pendingReplies[this.pendingReplies.length - 1];
543
+ const entry = this.channel.registry.get(head.eventId);
544
+ if (entry) return head;
545
+ this.pendingReplies.pop();
546
+ void now;
547
+ }
548
+ return null;
549
+ }
550
+
551
+ /** 处理一条入站消息(回执 / 指令 / 交代任务)。公开以便 bridge 与测试直接驱动。 */
552
+ async handleInbound(msg) {
553
+ const from = extractFromUserId(msg);
554
+ const text = normalizeInput(extractInboundText(msg));
555
+ if (!text) return;
556
+
557
+ // 内部统计:任何入站互动都算一次"窗口续期"事件
558
+ this.#bumpToday();
559
+
560
+ // ── v2 分派:命令 → 数字 → 普通消息 ────────────────────────────────────
561
+ // ⚠️ 顺序不能换:
562
+ // · 命令必须最先 —— 否则「/new 修个 bug」会被当成一条发给会话的普通消息;
563
+ // · 数字必须优先于普通消息 —— 否则用户回复「1」做审批时,会被当成给会话的文本发进去。
564
+ const cls = classifyInbound(text);
565
+
566
+ if (cls.kind === "command") {
567
+ await this.#runCommand(from, cls);
568
+ return;
569
+ }
570
+ if (cls.kind === "choice") {
571
+ await this.#answerChoice(from, cls.choice);
572
+ return;
573
+ }
574
+ if (cls.kind === "message") {
575
+ await this.#sendToSession(from, cls.text);
576
+ return;
577
+ }
578
+ }
579
+
580
+ /**
581
+ * 当前账号档位。今天恒为 free(bridge 拿不到套餐);付费档上线时**只改这里**。
582
+ * 可用 `opts.tier` 覆盖,供测试注入。
583
+ */
584
+ #tier() {
585
+ return this.tierOverride || "free";
586
+ }
587
+
588
+ /** 当前档位是否具备某能力(运营钩子的唯一判断入口)。 */
589
+ #can(cap) {
590
+ return capabilitiesFor(this.#tier(), this.capabilityTable).includes(cap);
591
+ }
592
+
593
+ /** 指令分派。 */
594
+ async #runCommand(from, cls) {
595
+ const cmd = cls.command;
596
+ const args = cls.args || "";
597
+
598
+ // ★ 运营钩子:能力表在**每次派发**时真的被查 —— 它是活代码,不是文档。
599
+ // 今天免费档已含 assign,所以行为不变;将来把 assign 从 free 拿掉即生效。
600
+ if (["/new", "/use", "/stop"].includes(cmd) && !this.#can("assign")) {
601
+ await this.reply(from, "当前档位还不能在微信里交代任务。升级后即可使用。");
602
+ return;
603
+ }
604
+
605
+ if (cmd === "/unbind") {
606
+ await this.reply(from, "正在为你解绑微信机器人…");
607
+ await this.unbind();
608
+ return;
609
+ }
610
+ if (cmd === "/quiet") {
611
+ this.channel.writeState({ quiet: true });
612
+ await this.reply(from, "已开启免打扰:之后只推需要你决定的与报错,不再推日常状态。回复 /status 查看,回复任意消息可恢复。");
613
+ return;
614
+ }
615
+ if (cmd === "/new") {
616
+ await this.#cmdNew(from, args);
617
+ return;
618
+ }
619
+ if (cmd === "/ls") {
620
+ await this.#cmdList(from);
621
+ return;
622
+ }
623
+ if (cmd === "/use") {
624
+ await this.#cmdUse(from, args);
625
+ return;
626
+ }
627
+ if (cmd === "/stop") {
628
+ await this.#cmdStop(from);
629
+ return;
630
+ }
631
+ if (cmd === "/summary") {
632
+ await this.#cmdSummary(from);
633
+ return;
634
+ }
635
+ if (cmd === "/status") {
636
+ await this.#cmdStatus(from);
637
+ return;
638
+ }
639
+ // 其余(含 /help)交给上游的通用处理 —— ⚠️ 字段是 `replyText`,不是 text。
640
+ const r = handleCommand(cmd, args);
641
+ await this.reply(from, (r && r.replyText) || "可用指令:/new /ls /use /stop /status /summary /help /quiet /unbind");
642
+ }
643
+
644
+ // ── v2:会话遥控 ────────────────────────────────────────────────────────
645
+
646
+ /** 当前会话指针落盘(重启后仍记得你在跟哪个任务)。 */
647
+ #rememberSession(sessionId, title) {
648
+ this.currentSessionId = sessionId || "";
649
+ this.currentSessionTitle = title || "";
650
+ this.channel.writeState({ current_session_id: this.currentSessionId, current_session_title: this.currentSessionTitle });
651
+ }
652
+
653
+ /** 拉一次会话列表(带标题),并记住 1-based 序号供 /use 使用。 */
654
+ async #sessions() {
655
+ if (!this.subscriber || typeof this.subscriber.listSessions !== "function") return null;
656
+ const r = await this.subscriber.listSessions();
657
+ if (!r || !r.ok) return null;
658
+ this.sessionIndex = r.sessions || [];
659
+ return this.sessionIndex;
660
+ }
661
+
662
+ /**
663
+ * `/new [任务]` —— 建一个 DSH 会话,可选地立刻把任务下发进去。
664
+ * 默认**沿用最近项目**(cwd 取最近一个会话的 cwd);要换项目就显式给路径,
665
+ * 避免把简单事做复杂(业主拍板)。
666
+ */
667
+ async #cmdNew(from, task) {
668
+ if (!this.subscriber || typeof this.subscriber.createSession !== "function") {
669
+ await this.reply(from, "暂不可用:DSH 会话服务未就绪。");
670
+ return;
671
+ }
672
+ const list = await this.#sessions();
673
+ let cwd = "";
674
+ if (list && list.length) {
675
+ // 优先沿用**当前会话**的项目,没有则用最近用过的那个
676
+ const cur = list.find((s) => s.sessionId === this.currentSessionId);
677
+ cwd = (cur && cur.cwd) || (list[0] && list[0].cwd) || "";
678
+ }
679
+
680
+ const created = await this.subscriber.createSession(cwd ? { cwd } : {});
681
+ if (!created || !created.ok) {
682
+ await this.reply(from, `开新任务失败:${(created && created.message) || "未知错误"}`);
683
+ return;
684
+ }
685
+ this.#rememberSession(created.sessionId, "");
686
+ const short = String(created.sessionId).replace(/^session-/, "").slice(0, 8);
687
+
688
+ const text = String(task || "").trim();
689
+ if (!text) {
690
+ await this.reply(from, `已开新任务 ${short}。把要做的直接发给我就行(回复任意内容即下发)。`);
691
+ return;
692
+ }
693
+ const sent = await this.subscriber.promptSession({ sessionId: created.sessionId, text });
694
+ if (!sent || !sent.ok) {
695
+ await this.reply(from, `新任务 ${short} 已建立,但下发失败:${(sent && sent.message) || "未知错误"}`);
696
+ return;
697
+ }
698
+ await this.reply(from, `已开新任务 ${short} 并下发。跑完我会推结论给你;中途想补充直接回话即可。`);
699
+ }
700
+
701
+ /** `/ls` —— 列出最近会话(带名称),供 /use 选择。 */
702
+ async #cmdList(from) {
703
+ const list = await this.#sessions();
704
+ if (!list) {
705
+ await this.reply(from, "暂时拿不到会话列表(DSH 会话服务未就绪)。");
706
+ return;
707
+ }
708
+ if (!list.length) {
709
+ await this.reply(from, "还没有任何会话。发一句话给我就能开一个新任务。");
710
+ return;
711
+ }
712
+ const top = list.slice(0, 9);
713
+ const lines = ["最近的会话(回 /use <编号> 切换):"];
714
+ top.forEach((s, i) => {
715
+ const name = s.title || "(未命名)";
716
+ const mark = s.sessionId === this.currentSessionId ? " ←当前" : "";
717
+ const run = s.running ? " ▶运行中" : "";
718
+ lines.push(`${i + 1}. ${name}${run}${mark}`);
719
+ });
720
+ lines.push("纯文本默认发给「当前」会话;想开新的用 /new。");
721
+ await this.reply(from, lines.join("\n"));
722
+ }
723
+
724
+ /** `/use <n>` —— 切换当前会话。 */
725
+ async #cmdUse(from, args) {
726
+ const list = (this.sessionIndex && this.sessionIndex.length) ? this.sessionIndex : await this.#sessions();
727
+ if (!list || !list.length) {
728
+ await this.reply(from, "还没有会话可选。先用 /ls 看看,或 /new 开一个。");
729
+ return;
730
+ }
731
+ const n = Number(String(args || "").trim());
732
+ if (!Number.isInteger(n) || n < 1 || n > list.length) {
733
+ await this.reply(from, `编号不对。请回 /use 1 到 /use ${list.length} 之间的数字(先 /ls 看列表)。`);
734
+ return;
735
+ }
736
+ const s = list[n - 1];
737
+ this.#rememberSession(s.sessionId, s.title || "");
738
+ await this.reply(from, `已切到:${s.title || "(未命名)"}${s.running ? "(运行中)" : ""}。之后你发的话都进这个任务。`);
739
+ }
740
+
741
+ /** `/stop` —— 中断当前会话正在跑的回合。 */
742
+ async #cmdStop(from) {
743
+ if (!this.currentSessionId) {
744
+ await this.reply(from, "现在没有选中的会话。/ls 看看要停哪个,或 /use <编号> 选中它。");
745
+ return;
746
+ }
747
+ if (!this.subscriber || typeof this.subscriber.cancelSession !== "function") {
748
+ await this.reply(from, "暂不可用:DSH 会话服务未就绪。");
749
+ return;
750
+ }
751
+ const r = await this.subscriber.cancelSession({ sessionId: this.currentSessionId });
752
+ await this.reply(from, r && r.ok ? "已发出中断。任务停下后我会把状态推给你。" : `中断失败:${(r && r.message) || "未知错误"}`);
753
+ }
754
+
755
+ /** `/status` —— 绑定状态 + 当前会话。 */
756
+ async #cmdStatus(from) {
757
+ const base = renderStatusText(this.channel.account, loadState(this.relayDir), { pending: this.pendingReplies.length });
758
+ const name = this.currentSessionTitle || "";
759
+ const short = this.currentSessionId ? String(this.currentSessionId).replace(/^session-/, "").slice(0, 8) : "";
760
+ const line = this.currentSessionId
761
+ ? `当前任务:${name || "(未命名)"} ${short}`
762
+ : "当前任务:未选中(发一句话即开新任务,/ls 可切换)";
763
+ await this.reply(from, `${base}\n${line}`);
764
+ }
765
+
766
+ /** `/summary` —— 重发当前会话的最近结论。 */
767
+ async #cmdSummary(from) {
768
+ if (!this.currentSessionId) {
769
+ await this.reply(from, "现在没有选中的会话。先 /ls + /use <编号> 选一个。");
770
+ return;
771
+ }
772
+ const r = await this.#sessionSummary(this.currentSessionId);
773
+ if (!r) {
774
+ await this.reply(from, "暂时取不到结论(会话历史读不到或还没有内容)。");
775
+ return;
776
+ }
777
+ await this.reply(from, r);
778
+ }
779
+
780
+ /** 取某个会话的"结论"= 最后一条助手消息(已截断到微信可读长度)。 */
781
+ async #sessionSummary(sessionId) {
782
+ if (!this.subscriber || typeof this.subscriber.lastAssistantText !== "function") return "";
783
+ try {
784
+ const r = await this.subscriber.lastAssistantText({ sessionId });
785
+ if (!r || !r.ok || !r.text) return "";
786
+ return String(r.text).trim().slice(0, 700);
787
+ } catch {
788
+ return "";
789
+ }
790
+ }
791
+
792
+ /** 普通文本 → 发给当前会话;没有当前会话就等同 /new。 */
793
+ async #sendToSession(from, text) {
794
+ const body = String(text || "").trim();
795
+ if (!body) return;
796
+ if (!this.currentSessionId) {
797
+ await this.#cmdNew(from, body);
798
+ return;
799
+ }
800
+ if (!this.subscriber || typeof this.subscriber.promptSession !== "function") {
801
+ await this.reply(from, "暂不可用:DSH 会话服务未就绪。");
802
+ return;
803
+ }
804
+ const r = await this.subscriber.promptSession({ sessionId: this.currentSessionId, text: body });
805
+ if (r && r.ok) {
806
+ await this.reply(from, `已补充给「${this.currentSessionTitle || "当前任务"}」,跑完推结论给你。`);
807
+ return;
808
+ }
809
+ await this.reply(from, `发送失败:${(r && r.message) || "未知错误"}。回 /ls 确认当前任务还在不在。`);
810
+ }
811
+
812
+ /** 数字回执(审批 / 提问)。 */
813
+ async #answerChoice(from, digits) {
814
+ if (digits === null || digits === undefined) return;
815
+
816
+ const pending = this.#latestPending();
817
+ if (!pending) {
818
+ // §6 ①:过期**绝不**当成同意,如实告诉用户。
819
+ await this.reply(from, "这条对应的待办已经过期或已被处理过了,没有代你做出任何选择。");
820
+ return;
821
+ }
822
+ const entry = this.channel.registry.get(pending.eventId);
823
+ if (!entry) {
824
+ this.pendingReplies = this.pendingReplies.filter((p) => p.eventId !== pending.eventId);
825
+ await this.reply(from, "这条对应的待办已经过期或已被处理过了,没有代你做出任何选择。");
826
+ return;
827
+ }
828
+ const option = entry.options[digits - 1];
829
+ if (!option) {
830
+ await this.reply(from, `编号 ${digits} 不在选项里。请回复 1-${entry.options.length} 之间的数字。`);
831
+ return;
832
+ }
833
+
834
+ // 用掉,避免同一条被回复两次
835
+ this.channel.registry.consume(pending.eventId);
836
+ this.pendingReplies = this.pendingReplies.filter((p) => p.eventId !== pending.eventId);
837
+
838
+ // ⚠️ 选项字段是 `value`(不是 outcome);且审批与提问的**回执编码不同**,
839
+ // 必须按事件节点自己的 answerShape 分派 —— 用错会把审批值塞进提问信封。
840
+ let ans;
841
+ try {
842
+ if (pending.answerShape === "approval") {
843
+ ans = await this.subscriber.answerApproval(pending.eventId, option.value);
844
+ } else {
845
+ const q = Array.isArray(pending.node && pending.node.questions) ? pending.node.questions[0] : null;
846
+ const answers = q
847
+ ? [{ id: q.id, selected: [String(option.label)] }]
848
+ : [{ id: "answer", selected: [String(option.label)] }];
849
+ ans = await this.subscriber.answerQuestion(pending.eventId, answers);
850
+ }
851
+ } catch (e) {
852
+ ans = { ok: false, error: redact(String(e && e.message ? e.message : e)) };
853
+ }
854
+ this.#bumpToday("answered");
855
+ if (ans && ans.ok === false) {
856
+ // 回晚了 / 已被别处处理 —— 如实告知,不假装成功(§6 ①)
857
+ await this.reply(from, `没能替你完成这个选择(${ans.error || "可能已经过期或被处理"})。任务那边已按"未批准"继续处理了,请到电脑上确认。`);
858
+ return;
859
+ }
860
+ await this.reply(from, `已按你的选择处理:${option.label || digits}。`);
861
+ }
862
+
863
+ async reply(to, text) {
864
+ if (!this.channel.account || !to) return false;
865
+ try {
866
+ await this.channel.client.sendMessage({ to, text });
867
+ this.channel.markPush(true);
868
+ return true;
869
+ } catch (e) {
870
+ this.#noteFailure(`sendmessage 失败:${redact(String(e && e.message ? e.message : e))}`);
871
+ return false;
872
+ }
873
+ }
874
+
875
+ #noteFailure(text) {
876
+ this.channel.writeState({ last_error: String(text).slice(0, 200) });
877
+ this.logger.warn(`[wechat] ${text}`);
878
+ }
879
+
880
+ // ── 出站:DSH 事件 → 微信通知 ───────────────────────────────────────────
881
+
882
+ #startSubscriber() {
883
+ // ⚠️ 建订阅器与挂事件处理器必须**分开**:早先把 on(...) 全写在 `if (subscriber) return;`
884
+ // 之后,于是任何**预先注入**的订阅器都拿不到任何 handler —— 表现为"事件来了却什么都不发生",
885
+ // 而且完全静默。现在无论订阅器是新建还是注入,都保证挂上处理器(WeakSet 去重,可重复调用)。
886
+ if (!this.subscriber) {
887
+ this.subscriber = createEventSubscriber({
888
+ upstream: this.upstream,
889
+ cookie: () => this.cookieOf(),
890
+ follow: true,
891
+ log: (level, message, meta) => {
892
+ try { this.logger[level === "warn" ? "warn" : level === "error" ? "warn" : "info"](`[wechat/events] ${message}`, meta); } catch { /* 忽略 */ }
893
+ }
894
+ });
895
+ }
896
+ this.#wireSubscriber(this.subscriber);
897
+ if (typeof this.subscriber.start === "function") this.subscriber.start();
898
+ }
899
+
900
+ #wireSubscriber(sub) {
901
+ this.wired = this.wired || new WeakSet();
902
+ if (!sub || this.wired.has(sub)) return;
903
+ this.wired.add(sub);
904
+
905
+ sub.on("event", (node) => {
906
+ this.notify(node).catch((e) => this.logger.warn(`[wechat] 通知发送失败:${redact(String(e && e.message ? e.message : e))}`));
907
+ });
908
+ sub.on("gap", (info) => {
909
+ // §6 ②:事件不重放 —— 断线窗口内的通知**漏了就是漏了**,必须如实说,不能装作没事。
910
+ this.logger.warn(`[wechat/events] 订阅断线窗口 ${info && info.from ? new Date(info.from).toISOString() : "?"} → ${info && info.to ? new Date(info.to).toISOString() : "?"},该窗口内的通知不可补发`);
911
+ this.#tellGap().catch(() => {});
912
+ });
913
+ sub.on("auth-error", (info) => {
914
+ this.logger.warn(`[wechat/events] 鉴权失败(${info && info.surface}):需要刷新 harness cookie`);
915
+ });
916
+ }
917
+
918
+ async #tellGap() {
919
+ if (!this.channel.account) return;
920
+ const to = this.channel.account.userId;
921
+ if (!to) return;
922
+ await this.reply(to, "⚠️ 通知通道刚才断过线。这段时间里的提醒可能没有发给你(微信侧不会补发),如果有正在跑的任务,建议到电脑上看一眼。");
923
+ }
924
+
925
+ /**
926
+ * 把一条**事件**节点发到微信。可回执的登记进待答队列。
927
+ * 公开方法:bridge 与测试都直接调用它(不叫 #notify 是因为它是本模块的主要出口之一)。
928
+ */
929
+ async notify(eventNode) {
930
+ if (!eventNode || !this.channel.account) return { ok: false, reason: "not_bound" };
931
+ const acct = this.channel.account;
932
+
933
+ // 免打扰:只放行"需要你决定"的与"报错"。用**事件侧**的 kind 判,不是文案侧的。
934
+ const state = loadState(this.relayDir);
935
+ if (state.quiet) {
936
+ const important = [
937
+ NODE_KINDS.APPROVAL_REQUEST,
938
+ NODE_KINDS.USER_QUESTION,
939
+ NODE_KINDS.PLAN_REVIEW,
940
+ NODE_KINDS.SESSION_ERROR
941
+ ].includes(eventNode.kind);
942
+ if (!important) return { ok: false, reason: "quiet" };
943
+ }
944
+
945
+ // 没有文案模板的节点:按设计另行处理,不是漏接线
946
+ if (SPECIAL_EVENT_KINDS.includes(eventNode.kind)) {
947
+ if (eventNode.kind === NODE_KINDS.EVENT_EXPIRED) {
948
+ // §6 ①:过期要**明确告诉用户**,不能沉默 —— 用户以为还没过期最危险。
949
+ await this.reply(acct.userId, "⌛ 刚才那条需要你决定的事已经过期了,DSH 已按「未批准」继续处理。如果还要做,请到电脑上重新发起。");
950
+ }
951
+ // gap 由 subscriber 的 'gap' 事件单独处理;fault 只记日志,不打扰用户。
952
+ return { ok: false, reason: `special:${eventNode.kind}` };
953
+ }
954
+
955
+ const { node: formatted, formatterKind } = toFormatterNode(eventNode);
956
+ if (!formatted) {
957
+ this.logger.warn(`[wechat] 未接线的节点 kind=${eventNode.kind}(消息未发出)`);
958
+ return { ok: false, reason: `unmapped:${eventNode.kind}` };
959
+ }
960
+
961
+ // ── v2 富化 ────────────────────────────────────────────────────────────
962
+ // 两条 v2 规则在这里落地,顺序有讲究:先判"完成推送要富化",再判"审批要不要降级为
963
+ // 只能回电脑确认"。两者都**不走**通用模板,所以放在 buildOutboundNotification 之前。
964
+ let built;
965
+
966
+ if (formatterKind === "stopped") {
967
+ // 完成任务不能只推「任务已停止」—— 要带**会话名称 + 结论**,让用户不用打开电脑就知道结果。
968
+ const sid = eventNode.sessionId || this.currentSessionId || "";
969
+ let title = this.currentSessionTitle || "";
970
+ if (sid) {
971
+ const list = await this.#sessions();
972
+ const hit = list ? list.find((s) => s.sessionId === sid) : null;
973
+ if (hit && hit.title) title = hit.title;
974
+ // 学到标题就记住,后面 /status 与 /ls 都能直接用
975
+ if (hit && sid === this.currentSessionId && title !== this.currentSessionTitle) {
976
+ this.#rememberSession(sid, title);
977
+ }
978
+ }
979
+ const summary = sid ? await this.#sessionSummary(sid) : "";
980
+ const c = formatCompletion({
981
+ title,
982
+ reason: eventNode.reason,
983
+ summary,
984
+ sessionId: sid,
985
+ hanging: !summary
986
+ });
987
+ built = { text: c.text, replyable: false, eventId: "" };
988
+ } else if (
989
+ formatterKind === "approval" &&
990
+ // ★ 安全边界:破坏性操作**不给一步回执**。加了 session/prompt 之后,这条通道能驱动
991
+ // agent 改文件/跑命令,手机上点一下太便宜;必须回电脑上确认。
992
+ isDestructiveTool(eventNode.toolName || formatted.tool, eventNode.reason || formatted.detail)
993
+ ) {
994
+ const tool = eventNode.toolName || formatted.tool || "(未提供)";
995
+ const detail = String(eventNode.reason || formatted.detail || "").trim();
996
+ // 现在被降级只有两种原因,文案要分别说清是**哪一种**(否则用户不知道该防什么):
997
+ // · 有内容且命中破坏性模式 → 是这一步的**内容**危险;
998
+ // · 内容为空但降级了 → 是**这个工具本身**属于删除/覆盖类(按工具名判的)。
999
+ // ⚠️ 「看不到内容」不再降级(业主拍板:DSH 自身有权限控制,不要过严),所以这里
1000
+ // 不会出现"因为看不见所以不给点"的说法 —— 那样说会与实现不一致。
1001
+ built = {
1002
+ text: [
1003
+ "【需要你到电脑上确认】",
1004
+ `工具: ${tool}`,
1005
+ detail ? `原因: ${detail}` : "",
1006
+ "",
1007
+ detail
1008
+ ? "这一步的**内容**被判定为破坏性操作(删除 / 强推 / 覆盖等),不能从微信里一键放行。"
1009
+ : "这个**工具本身**属于删除 / 覆盖类,不能从微信里一键放行。",
1010
+ "请到电脑上确认;不处理的话 DSH 会按「未批准」继续。"
1011
+ ].filter(Boolean).join("\n"),
1012
+ replyable: false,
1013
+ eventId: ""
1014
+ };
1015
+ } else {
1016
+ built = buildOutboundNotification(formatted, this.channel.registry, this.#notifyOpts());
1017
+ }
1018
+
1019
+ if (!built || !built.text) return { ok: false, reason: "no_text" };
1020
+
1021
+ const sent = await this.reply(acct.userId, built.text);
1022
+ if (sent && built.replyable && built.eventId) {
1023
+ this.pendingReplies.push({
1024
+ eventId: built.eventId,
1025
+ kind: formatterKind,
1026
+ answerShape: eventNode.answerShape || (formatterKind === "approval" ? "approval" : "question"),
1027
+ node: eventNode,
1028
+ at: this.clock()
1029
+ });
1030
+ // 队列上限:只保留最近若干条待答,避免无限增长
1031
+ while (this.pendingReplies.length > 20) this.pendingReplies.shift();
1032
+ this.#bumpToday("notified");
1033
+ }
1034
+ return { ok: sent, eventId: built.eventId || "" };
1035
+ }
1036
+
1037
+ /** 供 bridge / 账户轮询调用:P1 的额度提醒(挂双路钩子)。 */
1038
+ async notifyQuotaLow(detail = {}) {
1039
+ if (!this.subscriber) return { ok: false, reason: "not_running" };
1040
+ return this.notify(this.subscriber.quotaLow(detail));
1041
+ }
1042
+
1043
+ /** 供 bridge / 账户轮询调用:P1 的会员过期提醒(挂续费/带新用户双路钩子)。 */
1044
+ async notifyMembershipExpiring(detail = {}) {
1045
+ if (!this.subscriber) return { ok: false, reason: "not_running" };
1046
+ return this.notify(this.subscriber.membershipExpiring(detail));
1047
+ }
1048
+
1049
+ // ── 每日简报(兼作 24h 推送窗口心跳,§7) ────────────────────────────────
1050
+
1051
+ #startDigestTimer() {
1052
+ if (this.digestTask) return;
1053
+ // 每分钟检查一次"是否到了今天的简报时刻且今天还没发过"
1054
+ this.digestTask = setInterval(() => {
1055
+ this.#maybeDigest().catch(() => {});
1056
+ }, 60_000);
1057
+ if (this.digestTask.unref) this.digestTask.unref();
1058
+ }
1059
+
1060
+ #today(now = this.clock()) {
1061
+ const d = new Date(now);
1062
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
1063
+ }
1064
+
1065
+ #bumpToday(field = "notified") {
1066
+ const day = this.#today();
1067
+ if (this.todayStats.day !== day) this.todayStats = { day, notified: 0, answered: 0 };
1068
+ this.todayStats[field] = (this.todayStats[field] || 0) + 1;
1069
+ }
1070
+
1071
+ async #maybeDigest() {
1072
+ if (this.stopping || !this.channel.account || !this.subscriber) return;
1073
+ const now = this.clock();
1074
+ const hour = new Date(now).getHours();
1075
+ if (hour !== this.digestHour) return;
1076
+ const state = loadState(this.relayDir);
1077
+ if (state.last_digest_day === this.#today(now)) return; // 今天已发
1078
+ this.channel.writeState({ last_digest_day: this.#today(now) });
1079
+ const node = this.subscriber.digestDue({
1080
+ notified: this.todayStats.notified,
1081
+ answered: this.todayStats.answered
1082
+ });
1083
+ await this.notify(node);
1084
+ }
1085
+
1086
+ /**
1087
+ * 供测试/运维手动触发一次简报(绕过时刻判断)。
1088
+ * 生产路径走 #maybeDigest 的定时器;单测不能等一天,所以单独开一个口子。
1089
+ */
1090
+ async runDigestNow(extra = {}) {
1091
+ if (!this.subscriber) return { ok: false, reason: "not_running" };
1092
+ const node = this.subscriber.digestDue({ notified: this.todayStats.notified, answered: this.todayStats.answered, ...extra });
1093
+ return this.notify(node);
1094
+ }
1095
+
1096
+ // ── 绑定控制面 ──────────────────────────────────────────────────────────
1097
+
1098
+ /** 开始绑定:取二维码 → 返回面板可直接 <img src> 的 data URL。 */
1099
+ /**
1100
+ * 本机是否已登录账号。
1101
+ * 判据:`.dsh-config.json` 里有 `phone` —— 该字段由面板登录成功后写入(安装器的 setup
1102
+ * 也会写)。读不到/损坏一律当**未登录**,宁可多要一次登录,也不能在未登录时放开遥控能力。
1103
+ */
1104
+ #hasAccount() {
1105
+ try {
1106
+ const cfg = readJson(path.join(this.relayDir, ".dsh-config.json"));
1107
+ return !!(cfg && typeof cfg.phone === "string" && cfg.phone.trim());
1108
+ } catch {
1109
+ return false;
1110
+ }
1111
+ }
1112
+
1113
+ async beginBind() {
1114
+ if (this.disabled) return { ok: false, error: "微信通道已禁用" };
1115
+ if (this.channel.account) return { ok: false, error: "已经绑定过了;如需更换请先解绑" };
1116
+ // ★ 权限门槛(业主拍板:必须注册登录后才能用微信机器人)。
1117
+ // 判据是**本机配置里有账号**(登录后才会写入)。放在最前面:没登录就连二维码都不给,
1118
+ // 而不是"给二维码但绑上用不了"——后者会让用户白扫一次,体验更差。
1119
+ // bridge 侧这道闸与面板侧"未登录不显示 tab"是双保险(bridge 可能被别的客户端调)。
1120
+ if (!this.#hasAccount()) {
1121
+ return { ok: false, error: "请先在「远程访问」面板登录账号,再连接微信机器人。", code: "login_required" };
1122
+ }
1123
+ this.cancelBind();
1124
+ try {
1125
+ const started = await startBind({
1126
+ client: this.channel.client,
1127
+ logger: this.logger,
1128
+ timeoutMs: this.bindTtlMs
1129
+ });
1130
+ this.bind = {
1131
+ started,
1132
+ createdAt: this.clock(),
1133
+ needVerifyCode: false,
1134
+ done: false,
1135
+ result: null,
1136
+ error: ""
1137
+ };
1138
+ // 进入 need_verifycode 时置位,让面板弹输入框(§3 状态机)
1139
+ started.session.onNeedVerifyCode = () => { if (this.bind) this.bind.needVerifyCode = true; };
1140
+ return {
1141
+ ok: true,
1142
+ qrcode_svg: started.qrcodeSvg || "",
1143
+ qrcode_url: started.qrcodeUrl || "",
1144
+ message: started.message
1145
+ };
1146
+ } catch (e) {
1147
+ const error = redact(String(e && e.message ? e.message : e));
1148
+ this.logger.warn(`[wechat] 取二维码失败:${error}`);
1149
+ return { ok: false, error };
1150
+ }
1151
+ }
1152
+
1153
+ /**
1154
+ * 推进绑定状态机一步。面板轮询调用。
1155
+ *
1156
+ * ⚠️ 必须严格按 `BindSession.next()` 的**真实返回契约**判分支 —— 这里踩过一次大坑:
1157
+ * 早先我按 `{state:"confirmed", account:{…}}` 读,而实际上 `next()` 的返回是
1158
+ * · 成功 `{ok:true, alreadyBound:false, token, accountId, baseUrl, userId, message}`(**没有 state/account**)
1159
+ * · 待续 `{ok:false, pending:true, status:"wait"|"scaned"|"unknown"}`
1160
+ * · 要码 `{ok:false, verifyNeeded:true, status:"need_verifycode", attempt}`
1161
+ * · 失败 `{ok:false, code, message}`(来自 fail())
1162
+ * 于是 `state` 恒为 "wait"、成功分支永不命中 —— **用户永远绑不上**,而且没有任何报错线索。
1163
+ * 是端到端绑定流程测试抓到的(wechat-e2e.test.mjs)。
1164
+ */
1165
+ async pollBind() {
1166
+ if (!this.bind) return { ok: true, state: "idle", bound: !!this.channel.account };
1167
+ if (this.bind.done) {
1168
+ return { ok: true, state: this.bind.result ? "confirmed" : "failed", bound: !!this.channel.account, error: this.bind.error };
1169
+ }
1170
+ let step;
1171
+ try {
1172
+ step = await this.bind.started.next();
1173
+ } catch (e) {
1174
+ this.bind.error = redact(String(e && e.message ? e.message : e));
1175
+ this.bind.done = true;
1176
+ return { ok: false, state: "failed", error: this.bind.error };
1177
+ }
1178
+ if (!step || typeof step !== "object") {
1179
+ return { ok: true, state: "wait", need_verify_code: !!this.bind.needVerifyCode, bound: false };
1180
+ }
1181
+
1182
+ // 1) 成功:token 是最硬的判据(没有 token 什么都做不了)
1183
+ if (step.ok === true && typeof step.token === "string" && step.token) {
1184
+ this.channel.adoptConfirmed({
1185
+ token: step.token,
1186
+ accountId: step.accountId,
1187
+ baseUrl: step.baseUrl,
1188
+ userId: step.userId
1189
+ });
1190
+ this.bind.done = true;
1191
+ this.bind.result = true;
1192
+ await this.startChannel(); // 绑定成功即上线,面板随后就能看到"已绑定"
1193
+ return { ok: true, state: "confirmed", bound: true };
1194
+ }
1195
+ // 2) 该 bot 之前已绑过 → 服务端不再下发凭据,但语义上是成功
1196
+ if (step.ok === true && step.alreadyBound === true) {
1197
+ this.bind.done = true;
1198
+ this.bind.result = true;
1199
+ return { ok: true, state: "already_bound", bound: !!this.channel.account };
1200
+ }
1201
+ // 3) 要配对码(必须每次表面化,否则用户第二次不会再看到输入框)
1202
+ if (step.verifyNeeded === true) {
1203
+ this.bind.needVerifyCode = true;
1204
+ return { ok: true, state: "need_verifycode", need_verify_code: true, bound: false };
1205
+ }
1206
+ // 4) 终态失败:带 code 且不再 pending
1207
+ if (step.ok === false && step.pending !== true && typeof step.code === "string" && step.code) {
1208
+ this.bind.done = true;
1209
+ this.bind.error = step.message || step.code;
1210
+ return { ok: false, state: "failed", code: step.code, error: this.bind.error };
1211
+ }
1212
+ // 5) 仍在推进(wait / scaned / unknown)
1213
+ this.bind.needVerifyCode = false;
1214
+ return {
1215
+ ok: true,
1216
+ state: typeof step.status === "string" ? step.status : "wait",
1217
+ need_verify_code: false,
1218
+ bound: false
1219
+ };
1220
+ }
1221
+
1222
+ /** 提交手机微信上显示的数字配对码。 */
1223
+ submitVerifyCode(code) {
1224
+ const c = String(code == null ? "" : code).trim();
1225
+ if (!/^[0-9]{1,8}$/.test(c)) return { ok: false, error: "请输入手机微信上显示的数字" };
1226
+ if (!this.bind) return { ok: false, error: "当前没有进行中的绑定" };
1227
+ const ok = this.bind.started.submitVerifyCode(c);
1228
+ if (ok) this.bind.needVerifyCode = false;
1229
+ return ok ? { ok: true } : { ok: false, error: "配对码未被接受,请重试" };
1230
+ }
1231
+
1232
+ cancelBind() {
1233
+ if (!this.bind) return { ok: true, cancelled: false };
1234
+ try { if (this.bind.started.session && this.bind.started.session.cancel) this.bind.started.session.cancel(); } catch { /* 忽略 */ }
1235
+ this.bind = null;
1236
+ return { ok: true, cancelled: true };
1237
+ }
1238
+
1239
+ /** 解绑:停轮询 → notifystop → 删凭据 → 状态置未绑定。 */
1240
+ async unbind() {
1241
+ await this.stopChannel();
1242
+ const r = await this.channel.unbind();
1243
+ this.pendingReplies = [];
1244
+ this.subscriber = null;
1245
+ // 允许重新绑定
1246
+ this.stopping = false;
1247
+ return { ok: true, notify_error: r.notifyError || "" };
1248
+ }
1249
+ }
1250
+
1251
+ export function createWeChatRuntime(opts) {
1252
+ return new WeChatRuntime(opts);
1253
+ }
1254
+
1255
+ /** 读控制面发现文件(插件宿主半边用)。 */
1256
+ export function readControlFile(relayDir) {
1257
+ return readJson(path.join(relayDir, CONTROL_FILE));
1258
+ }
1259
+
1260
+ export { saveState, loadState, SessionCooldown };