@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,1984 @@
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
+ SESSION_SUMMARY_MAX,
52
+ DEFAULT_UPDATES_TIMEOUT_MS
53
+ } from "./wechat-channel.mjs";
54
+ import { createEventSubscriber, NODE_KINDS } from "./dsh-events.mjs";
55
+
56
+ /**
57
+ * v2:微信不再只是"通知器",而是**能创建会话、下发任务、追问续接**的遥控器。
58
+ *
59
+ * ⚠️ 这改变了安全面:一旦能 `session/prompt`,机器人就能在你电脑上驱动 agent 改文件、跑命令。
60
+ * 所以本文件有两条硬约束(别在重构时弄丢):
61
+ * ① 通道只在**已绑定**时启动(绑定又要求 bridge 有账号 → 等价于"必须注册登录");
62
+ * ② **破坏性操作不给一步回执** —— 见 `#notify` 里的 `isDestructiveTool` 分支。
63
+ */
64
+
65
+ /**
66
+ * v2 能力表(运营钩子) —— 业主 2026-09-22 确认的分层:
67
+ *
68
+ * · **免费档 = 只读 + 审批**:收得到任务通知(含完成小结、报错、额度、会员到期),
69
+ * 点得了审批回执(选择下一步执行)。但**遥控不了**。
70
+ * · **付费档 = 免费档 + 遥控**:在微信里直接交代任务、切换会话(以及预留的中途纠偏/附件/多会话)。
71
+ *
72
+ * 免费用户尝试付费能力时,回复里带上 App 链接 —— 这既是解释,也是转化入口
73
+ * (见 `#upsellText()`:业主口径是"如果免费用户要去做其他的东西,就在消息后面跟一个访问 App 的链接")。
74
+ *
75
+ * ⚠️ `pro_max` **必须存在**:服务端的生效套餐取值就是 free / pro / pro_max(见企业端 auth.js 的
76
+ * PLAN_PRIORITY),而 `capabilitiesFor` 对**未知档位回退 free** —— 漏了 pro_max 就会把
77
+ * 最高的付费档用户当成免费用户挡在门外。
78
+ * ⚠️ 改这张表 = 改产品权限,别顺手加能力;`free` 那行是"用户没付钱时他能做什么"的唯一定义。
79
+ */
80
+ const FREE_CAPABILITIES = Object.freeze([
81
+ "notify", "approve", "status", "stop",
82
+ // ★ 业主 2026-09-22 拍板:「免费只能继续已有会话(回复即续接),不能开新会话、不能选用别的会话」。
83
+ // 所以 assign 被**拆成两半**:continue 免费、new 付费 —— 既让免费用户"能接着聊",
84
+ // 又保住"开新任务 / 管会话"是会员能力(否则付费理由就没了)。
85
+ "assign.continue"
86
+ ]);
87
+ const PAID_CAPABILITIES = Object.freeze([
88
+ "notify", "approve", "status", "stop",
89
+ "assign", // 粗粒度授权:按前缀规则天然覆盖 assign.new 与 assign.continue
90
+ "sessions", "summary",
91
+ "steer", "attach", "multi" // 预留:中途纠偏 / 附件 / 多会话并行
92
+ ]);
93
+ export const WECHAT_CAPABILITY_TABLE = Object.freeze({
94
+ free: FREE_CAPABILITIES,
95
+ pro: PAID_CAPABILITIES,
96
+ pro_max: PAID_CAPABILITIES
97
+ });
98
+
99
+ /**
100
+ * 能力授权(点号**前缀规则**)。
101
+ *
102
+ * 配置里写粗粒度 `"assign"` 即授权 `assign` 与 `assign.*`;写细粒度 `"assign.continue"` 只授权它自己。
103
+ * 这样运营既能一键给整块能力,也能只给其中一半。
104
+ * ⚠️ **未知 need 一律不授权**(fail-closed):代码新加了个能力而配置没跟上时,宁可不给。
105
+ */
106
+ export function grantsCap(caps, need) {
107
+ if (!Array.isArray(caps) || !need) return false;
108
+ return caps.some((c) => {
109
+ const s = String(c || "");
110
+ return s === need || need.startsWith(`${s}.`);
111
+ });
112
+ }
113
+
114
+ /**
115
+ * 各档位的**额外限制**(与能力表并列,同属"权益包")。
116
+ *
117
+ * `messages_per_month`:该档用户每月最多能给 agent 发多少条消息;**0 或缺省 = 不限**。
118
+ * 业主 2026-09-22:「免费给每月 N 条消息额度,超出再引导升级(可配置,挂在权益包里)」。
119
+ * ⚠️ 只有**派活**(发消息/开任务)计数;审批回执与指令**不计数** ——
120
+ * 否则免费用户会为了省额度而不敢拍板,那等于把安全阀关掉。
121
+ */
122
+ export const WECHAT_TIER_LIMITS = Object.freeze({
123
+ free: Object.freeze({ messages_per_month: 20 }),
124
+ pro: Object.freeze({ messages_per_month: 0 }),
125
+ pro_max: Object.freeze({ messages_per_month: 0 })
126
+ });
127
+
128
+ /** 某档位的限制。未知档位按最低档(free)处理 —— 与能力表同一口径。 */
129
+ export function limitsFor(tier, table = WECHAT_TIER_LIMITS) {
130
+ const key = Object.prototype.hasOwnProperty.call(table, String(tier || "")) ? String(tier) : "free";
131
+ return table[key] || {};
132
+ }
133
+
134
+ /** 某档位具备哪些能力。未知档位按最低档(free)处理 —— 宁可少给,不可误放。 */
135
+ export function capabilitiesFor(tier, table = WECHAT_CAPABILITY_TABLE) {
136
+ const key = Object.prototype.hasOwnProperty.call(table, String(tier || "")) ? String(tier) : "free";
137
+ return table[key];
138
+ }
139
+
140
+ /**
141
+ * 指令 → 所需能力。**没列出的指令不设门槛**(`/help`、`/status`、`/unbind`、`/quiet` 人人可用)。
142
+ *
143
+ * 为什么 `/unbind` 绝不设门槛:免费用户也必须能解绑 —— 否则他被绑上了却退不掉,
144
+ * 这既是骚扰也是合规问题。
145
+ * 为什么 `/stop` 放进免费档:免费用户已经能通过审批「选择下一步执行」,再给一个急停阀是同一件事;
146
+ * 一个只会发通知、却连"停下"都做不到的通道,用户会觉得被挟持。
147
+ */
148
+ const COMMAND_CAPABILITY = Object.freeze({
149
+ "/new": "assign.new", // 开新会话 = 付费(「继续已有会话」走 assign.continue,见纯文本分支)
150
+ "/ls": "sessions",
151
+ "/use": "sessions",
152
+ "/summary": "summary",
153
+ "/stop": "stop"
154
+ });
155
+
156
+ /**
157
+ * ★ 两个上游模块的**节点词表不一致**,这里是唯一的翻译点。
158
+ *
159
+ * 背景:`dsh-events.mjs` 与 `wechat-channel.mjs` 是并行开发的两个模块,各自按规格 §5 定了
160
+ * 自己的 kind 名 —— 事件侧用「事件语义」(`approval-request`),文案侧用「展示语义」(`approval`)。
161
+ * 两边单测各自全绿,拼在一起却不认识彼此。翻译放在编排层(本文件)而不是改动任一上游:
162
+ * 上游各自的名字都对,耦合点只有一处,放在这里能被一条测试完整覆盖。
163
+ *
164
+ * ⚠️ 新增节点时必须同时改这里,否则用户收到的是「未知节点」——`assertKindCoverage()` 会先报错。
165
+ */
166
+ const EVENT_KIND_TO_FORMATTER_KIND = Object.freeze({
167
+ [NODE_KINDS.APPROVAL_REQUEST]: "approval",
168
+ [NODE_KINDS.USER_QUESTION]: "question",
169
+ [NODE_KINDS.PLAN_REVIEW]: "plan",
170
+ [NODE_KINDS.SESSION_ERROR]: "error",
171
+ [NODE_KINDS.TURN_END]: "stopped",
172
+ [NODE_KINDS.DIGEST_DUE]: "daily",
173
+ [NODE_KINDS.QUOTA_LOW]: "quota",
174
+ [NODE_KINDS.MEMBERSHIP_EXPIRING]: "membership"
175
+ });
176
+
177
+ /** 没有文案模板的节点:不是"漏了",而是按设计另行处理(见 #notify)。 */
178
+ const SPECIAL_EVENT_KINDS = Object.freeze([
179
+ NODE_KINDS.EVENT_EXPIRED,
180
+ NODE_KINDS.GAP,
181
+ NODE_KINDS.FAULT
182
+ ]);
183
+
184
+ /**
185
+ * **提醒类**节点:它不是"等用户拍板",只是"告诉他一件事 + 顺带给个入口"。
186
+ *
187
+ * 为什么必须单独分一类(2026-09-23 定位到真机问题):
188
+ * 额度提醒与会员到期提醒在文案层是**可回执**的(带「邀请好友 / 知道了」选项,
189
+ * 见 wechat-channel.mjs 的 `optionsFor`),于是它们会被塞进审批队列 `pendingReplies`。
190
+ * 而数字回执按 **FIFO** 认领(微信里消息位置固定,用户从上往下读)——
191
+ * 真机后果:用户看到的是底部**最新那条审批**,回「1」,
192
+ * 回执却落到了**更早的那条额度提醒**上 → 审批没人答,得再回一次才轮到。
193
+ *
194
+ * 所以队列分两类:
195
+ * · **决策**(审批 / 提问 / 计划)= 需要用户拍板,数字回执优先给它们;
196
+ * · **提醒**(额度 / 会员到期)= 不占决策位;只有一条决策都没有时,数字才给它们。
197
+ */
198
+ const NOTICE_FORMATTER_KINDS = Object.freeze(["quota", "membership"]);
199
+
200
+ /**
201
+ * 把事件节点翻译成文案节点。
202
+ * @returns {{node: object|null, formatterKind: string}}
203
+ */
204
+ export function toFormatterNode(eventNode = {}) {
205
+ const formatterKind = EVENT_KIND_TO_FORMATTER_KIND[eventNode.kind] || "";
206
+ if (!formatterKind) return { node: null, formatterKind: "" };
207
+ const node = { ...eventNode, kind: formatterKind };
208
+
209
+ // 字段名对齐:事件侧叫 toolName,文案侧读 tool
210
+ if (node.tool == null && node.toolName != null) node.tool = node.toolName;
211
+
212
+ // 提问:事件侧是 questions[{id,question,options[]}],文案侧读 prompt + options[]
213
+ if (formatterKind === "question" || formatterKind === "plan") {
214
+ const q = Array.isArray(node.questions) ? node.questions[0] : null;
215
+ if (q) {
216
+ if (!node.prompt) node.prompt = q.question || q.detail || "";
217
+ if (!Array.isArray(node.options) || !node.options.length) {
218
+ node.options = Array.isArray(q.options) ? q.options : [];
219
+ }
220
+ }
221
+ }
222
+
223
+ // 简报:文案侧读 lines[]
224
+ if (formatterKind === "daily" && !Array.isArray(node.lines)) {
225
+ const parts = [];
226
+ if (node.notified != null) parts.push(`今天为你推送了 ${node.notified} 条提醒`);
227
+ if (node.answered != null) parts.push(`你回执了 ${node.answered} 条`);
228
+ if (!parts.length) parts.push("今天暂时没有要你处理的事。");
229
+ node.lines = parts;
230
+ }
231
+
232
+ // 停止:事件侧的 reason 是机器值(completed/aborted/…),文案侧自己会翻译,
233
+ // 但 detail 留一份人类可读的,便于排查
234
+ if (formatterKind === "stopped" && node.reason) {
235
+ node.detail = stopReasonText(node.reason);
236
+ }
237
+
238
+ return { node, formatterKind };
239
+ }
240
+
241
+ /**
242
+ * 自检:事件侧声明的每个 kind 都必须有归宿(翻译表 或 特殊列表)。
243
+ * 这样"上游新增了节点、编排层忘了接"会**当场**暴露,而不是等用户收到「未知节点」。
244
+ */
245
+ export function assertKindCoverage() {
246
+ const missing = Object.values(NODE_KINDS).filter(
247
+ (k) => !EVENT_KIND_TO_FORMATTER_KIND[k] && !SPECIAL_EVENT_KINDS.includes(k)
248
+ );
249
+ if (missing.length) {
250
+ throw new Error(`wechat-runtime: 事件侧新增了未接线的节点 kind: ${missing.join(", ")}(请补 EVENT_KIND_TO_FORMATTER_KIND)`);
251
+ }
252
+ return true;
253
+ }
254
+
255
+ /** 控制面发现文件(宿主半边读它拿端口)。不含密钥 —— 密钥在 .dsh-config.json。 */
256
+ export const CONTROL_FILE = ".wechat-control.json";
257
+ /** 控制面鉴权头。 */
258
+ export const CONTROL_HEADER = "x-dsh-bridge-secret";
259
+ /** 绑定会话的默认存活时长(面板上二维码可被扫的时间)。 */
260
+ export const DEFAULT_BIND_TTL_MS = 5 * 60_000;
261
+ /** 默认每日简报时刻(本地时区小时,0-23)。 */
262
+ /**
263
+ * 每日简报时刻(本地小时)。
264
+ *
265
+ * ⚠️ 必须是**晚上**而不是早上:简报的内容是"**今天**干了啥"(已完成/出错/你拍板了几次),
266
+ * 早上 9 点发的时候今天才刚开始,整篇都是空的 —— 业主原话「每天早上发『今日干了啥事儿』,
267
+ * 这肯定不太对劲。应该是晚上发,比如晚上 6 点,发『今天干了啥』」。
268
+ * 它同时兼作微信 24h 推送窗口的心跳,所以一天**只发一次**、不要早晚各一次。
269
+ */
270
+ export const DEFAULT_DIGEST_HOUR = 18;
271
+ /**
272
+ * 档位校准间隔。10 分钟足够跟上升级/到期(派发被拒时还会立刻再确认一次),
273
+ * 又不至于把账号 API 当心跳打。
274
+ */
275
+ export const WECHAT_TIER_REFRESH_MS = 10 * 60_000;
276
+
277
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
278
+
279
+ /** 读一个 JSON **文件**(控制面发现文件等)。 */
280
+ function readJson(file) {
281
+ try {
282
+ return JSON.parse(fs.readFileSync(file, "utf8"));
283
+ } catch {
284
+ return null;
285
+ }
286
+ }
287
+
288
+ /**
289
+ * 解析请求体里的 JSON **文本**。
290
+ *
291
+ * ⚠️ 必须与 readJson 分开:早先控制面把请求体交给了 readJson,而它是**读文件**的
292
+ * (`fs.readFileSync(<一段 JSON 文本>)` 必然抛错 → 返回 null),于是**所有 POST 体都是 null** ——
293
+ * 表现是「提交配对码」永远回「请输入手机微信上显示的数字」,而且没有任何报错线索。
294
+ * 端到端绑定流程测试抓到了它(wechat-e2e.test.mjs)。
295
+ */
296
+ function parseBodyText(text) {
297
+ try {
298
+ const v = JSON.parse(String(text || ""));
299
+ return v && typeof v === "object" ? v : null;
300
+ } catch {
301
+ return null;
302
+ }
303
+ }
304
+
305
+ /** 极简请求体读取(限制体积,防止回环上的畸形请求把内存吃满)。 */
306
+ function readBody(req, limit = 64 * 1024) {
307
+ return new Promise((resolve) => {
308
+ let size = 0;
309
+ const chunks = [];
310
+ req.on("data", (c) => {
311
+ size += c.length;
312
+ if (size > limit) {
313
+ req.destroy();
314
+ resolve("");
315
+ return;
316
+ }
317
+ chunks.push(c);
318
+ });
319
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
320
+ req.on("error", () => resolve(""));
321
+ });
322
+ }
323
+
324
+ function sendJson(res, status, body) {
325
+ const text = JSON.stringify(body);
326
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
327
+ res.end(text);
328
+ }
329
+
330
+ export class WeChatRuntime {
331
+ /**
332
+ * @param {{
333
+ * relayDir: string,
334
+ * upstream?: string, // dsh web 地址,默认 http://127.0.0.1:3080
335
+ * cookieOf?: () => string, // 读 .harness-cookie.json 的回调(bridge 已有该逻辑)
336
+ * secret?: string, // 控制面密钥(bridge_secret);空则控制面拒绝所有请求
337
+ * logger?: any,
338
+ * clock?: () => number,
339
+ * fetch?: typeof fetch,
340
+ * port?: number, // 控制面端口,0=临时端口(默认)
341
+ * bindTtlMs?: number,
342
+ * digestHour?: number,
343
+ * disabled?: boolean, // 总开关
344
+ * }} opts
345
+ */
346
+ constructor(opts = {}) {
347
+ if (!opts.relayDir) throw new Error("WeChatRuntime: 缺少 relayDir");
348
+ this.relayDir = opts.relayDir;
349
+ this.upstream = String(opts.upstream || "http://127.0.0.1:3080").replace(/\/+$/, "");
350
+ this.cookieOf = typeof opts.cookieOf === "function" ? opts.cookieOf : () => "";
351
+ this.secret = String(opts.secret || "");
352
+ this.clock = opts.clock || Date.now;
353
+ this.fetchImpl = opts.fetch;
354
+ this.logger = opts.logger || createLogger();
355
+ this.bindTtlMs = opts.bindTtlMs || DEFAULT_BIND_TTL_MS;
356
+ this.digestHour = Number.isInteger(opts.digestHour) ? opts.digestHour : DEFAULT_DIGEST_HOUR;
357
+ this.controlPort = Number.isInteger(opts.port) ? opts.port : 0;
358
+ this.disabled = !!opts.disabled;
359
+ // 冷却时长可注入:生产用 1 小时(与腾讯官方插件一致),测试用短值以便验证"不猛打接口"。
360
+ this.cooldownMs = Number.isFinite(opts.cooldownMs) && opts.cooldownMs > 0 ? opts.cooldownMs : undefined;
361
+
362
+ this.channel = new WeChatChannel({
363
+ relayDir: this.relayDir,
364
+ logger: this.logger,
365
+ clock: this.clock,
366
+ fetch: this.fetchImpl,
367
+ cooldownMs: this.cooldownMs,
368
+ // 未绑定时客户端用这个 baseUrl(生产恒为腾讯 ilink 默认值)。
369
+ // 可注入是为了让「绑定流程」能在本地假上游上被测 —— 否则绑定路径只能靠真机扫码验证。
370
+ baseUrl: opts.baseUrl
371
+ });
372
+ this.subscriber = null;
373
+ this.controlServer = null;
374
+ this.boundPort = 0;
375
+
376
+ this.stopping = false;
377
+ this.channelTask = null;
378
+ this.digestTask = null;
379
+
380
+ /** 绑定会话(同一时刻至多一个)。 */
381
+ this.bind = null;
382
+ /**
383
+ * 可回执通知的**待答队列**(最近的在末尾)。
384
+ * 微信里用户只回一个数字,消息里不带 eventId(见 buildOutboundNotification 注释),
385
+ * 所以数字必须映射到"最近一条待答通知" —— 这就是那张表存在的理由。
386
+ */
387
+ this.pendingReplies = [];
388
+ /** 当日简报计数(本地,不落盘)。 */
389
+ this.todayStats = { day: "", notified: 0, answered: 0 };
390
+
391
+ /**
392
+ * v2「当前会话」指针。**必须从状态文件恢复** —— 否则 bridge 一重启,
393
+ * 用户之前选好的任务就丢了,他再发一句话会静默开成一个**新任务**,
394
+ * 而不是继续原来那个(用户完全无从察觉)。
395
+ */
396
+ const persisted = loadState(this.relayDir);
397
+ this.currentSessionId = persisted.current_session_id || "";
398
+ this.currentSessionTitle = persisted.current_session_title || "";
399
+ /** `/ls` 的结果缓存(1-based 序号供 /use 使用)。 */
400
+ this.sessionIndex = [];
401
+ /**
402
+ * 运营钩子的可注入点(生产由 bridge 注入真实档位;测试可覆盖)。
403
+ * `tierOverride` 空 = 用缓存/默认;`capabilityTable` 空 = 用内置表。
404
+ */
405
+ this.tierOverride = typeof opts.tier === "string" ? opts.tier : "";
406
+ this.capabilityTable = opts.capabilityTable || WECHAT_CAPABILITY_TABLE;
407
+ this.limitTable = opts.limitTable || WECHAT_TIER_LIMITS;
408
+ /**
409
+ * **服务端下发的权益包**(可空):`{rev, plan, caps[], limits{}}`。
410
+ * 空 = 冷启动/还没取到 → 用内置表按档位解析(与今天行为完全一致)。
411
+ * ⚠️ 取自磁盘缓存:进程重启后不会因为一次网络失败就把付费用户降级。
412
+ */
413
+ this.entitlements = (opts.entitlements && typeof opts.entitlements === "object")
414
+ ? opts.entitlements
415
+ : (persisted.entitlements && typeof persisted.entitlements === "object" ? persisted.entitlements : null);
416
+ this.entitlementsRev = String((this.entitlements && this.entitlements.rev) || "");
417
+ /** 免费档的每月消息用量(跨月自动归零)。 */
418
+ this.msgUsage = (persisted.msg_usage && typeof persisted.msg_usage === "object") ? persisted.msg_usage : null;
419
+ /**
420
+ * 真实档位来源(async→ "free"|"pro"|"pro_max";空串 = 这次没取到)。
421
+ * bridge 用账号 API 的生效套餐实现它;不注入时档位恒为缓存/默认(测试与自建)。
422
+ */
423
+ this.tierProvider = typeof opts.tierProvider === "function" ? opts.tierProvider : null;
424
+ /**
425
+ * 账号快照来源(async→ `{plan, plan_ends_at, trial_expires_at}`)。
426
+ * 目前只服务于"会员临近到期提醒" —— bridge 注入时**复用它查档位那次 `/api/me`**,
427
+ * 不额外增加网络请求。不注入 = 不发这类提醒(宁可不发,也不拿猜的到期日骚扰用户)。
428
+ */
429
+ this.accountInfo = typeof opts.accountInfo === "function" ? opts.accountInfo : null;
430
+ /** App 入口(免费用户越界时给的转化链接)。空则不附链接,不编一个假地址。 */
431
+ this.appUrl = String(opts.appUrl || "").trim();
432
+ /**
433
+ * ★ 档位**持久化缓存**:进程重启后不会因为一次网络失败就把付费用户降级成免费。
434
+ * 首次运行且从未取到过 = 空 → 按 free(宁可少给,不可误放)。
435
+ */
436
+ this.cachedTier = String(persisted.tier || "").trim();
437
+ this.tierCheckedAt = 0;
438
+ this.tierTimer = null;
439
+ /**
440
+ * 被拒时重查档位的节流窗口。用户刚升级完重试要能进,但免费用户反复发消息
441
+ * 也不能变成"每条消息打一次账号 API",所以取 5 秒(最坏情况等 5 秒就能进)。
442
+ * 可注入,便于测试把这条路径跑成确定性的。
443
+ */
444
+ this.tierDenyThrottleMs = Number.isFinite(opts.tierDenyThrottleMs) ? opts.tierDenyThrottleMs : 5_000;
445
+ }
446
+
447
+ // ── 状态 ────────────────────────────────────────────────────────────────
448
+
449
+ /** 面板可见状态。**只返回脱敏字段**。 */
450
+ status() {
451
+ const state = loadState(this.relayDir);
452
+ const acct = sanitizeAccount(this.channel.account);
453
+ const cooldownMs = this.channel.cooldown.remainingMs ? this.channel.cooldown.remainingMs() : 0;
454
+ return {
455
+ ok: true,
456
+ disabled: this.disabled,
457
+ bound: !!acct.bound,
458
+ bot_id: acct.botId || "",
459
+ bound_at: acct.boundAt || 0,
460
+ connected_at: state.connected_at || 0,
461
+ last_push_ok_at: state.last_push_ok_at || 0,
462
+ last_error: state.last_error || "",
463
+ cooldown_ms: cooldownMs,
464
+ // 只数**待拍板**的(不含额度/会员到期这类提醒):面板上的数字要与用户真实要做的决定一致
465
+ pending_replies: this.#decisionCount(),
466
+ // ★ 面板要把「你现在能用什么」如实展示出来(业主:话术与权限必须一致,不多承诺)。
467
+ // 这里下发的是**通道实际在用**的那一份(服务端权益包优先,否则内置表按档位),
468
+ // 所以面板不需要自己猜档位 → 展示与判定不可能不一致。
469
+ plan: this.#tier(),
470
+ caps: this.#caps(),
471
+ limits: this.#limits(),
472
+ entitlements_source: this.entitlements && this.entitlements.caps ? "server" : "builtin",
473
+ messages_used_this_month: (this.msgUsage && this.msgUsage.month === this.#monthKey())
474
+ ? Number(this.msgUsage.count || 0)
475
+ : 0,
476
+ // ⚠️ 失败/成功后 bind.done=true 但仍留着对象 —— 必须一起判,否则面板在绑定失败后
477
+ // 会一直显示"正在绑定"(真机实测撞到:超时后 binding.active 还是 true)。
478
+ binding: this.bind && !this.bind.done
479
+ ? { active: true, need_verify_code: !!this.bind.needVerifyCode }
480
+ : { active: false, failed: !!(this.bind && this.bind.done && !this.bind.result) },
481
+ channel_running: !!this.channelTask,
482
+ events_running: !!this.subscriber
483
+ };
484
+ }
485
+
486
+ /** 通知文案里用的机器人自称与奖励口径(与产品口径同源)。 */
487
+ #notifyOpts() {
488
+ return { botName: "ClawBot" };
489
+ }
490
+
491
+ // ── 控制面 HTTP ─────────────────────────────────────────────────────────
492
+
493
+ /** 启动控制面(仅回环)。端口写进发现文件。 */
494
+ async startControl() {
495
+ if (this.controlServer) return this.boundPort;
496
+ this.controlServer = http.createServer((req, res) => {
497
+ this.#handleControl(req, res).catch((e) => {
498
+ try { sendJson(res, 500, { ok: false, error: redact(String(e && e.message ? e.message : e)) }); } catch { /* 已断开 */ }
499
+ });
500
+ });
501
+ // ★ 只 bind 回环 —— 控制面能改机器人绑定,绝不能暴露到局域网/公网。
502
+ await new Promise((resolve, reject) => {
503
+ this.controlServer.once("error", reject);
504
+ this.controlServer.listen(this.controlPort, "127.0.0.1", () => {
505
+ this.controlServer.removeListener("error", reject);
506
+ resolve();
507
+ });
508
+ });
509
+ this.boundPort = this.controlServer.address().port;
510
+ this.#publishControlFile();
511
+ this.logger.info(`[wechat] 控制面已就绪 127.0.0.1:${this.boundPort}(仅回环 + 密钥)`);
512
+ return this.boundPort;
513
+ }
514
+
515
+ #publishControlFile() {
516
+ try {
517
+ writePrivateJson(
518
+ path.join(this.relayDir, CONTROL_FILE),
519
+ { port: this.boundPort, pid: process.pid, started_at: this.clock(), header: CONTROL_HEADER },
520
+ (m) => this.logger.warn(m)
521
+ );
522
+ } catch (e) {
523
+ this.logger.warn(`[wechat] 写控制面发现文件失败:${redact(String(e.message || e))}`);
524
+ }
525
+ }
526
+
527
+ async #handleControl(req, res) {
528
+ if (!this.secret) {
529
+ // 没有密钥 = 拒绝一切(而不是放行)—— 避免配置缺失时控制面变成开放后门。
530
+ return sendJson(res, 403, { ok: false, error: "bridge_secret 未配置,控制面已禁用" });
531
+ }
532
+ const got = String(req.headers[CONTROL_HEADER] || "");
533
+ if (got !== this.secret) return sendJson(res, 401, { ok: false, error: "unauthorized" });
534
+
535
+ const url = new URL(req.url || "/", "http://127.0.0.1");
536
+ const route = `${req.method} ${url.pathname}`;
537
+ const body = req.method === "POST" ? parseBodyText(await readBody(req)) : null;
538
+
539
+ switch (route) {
540
+ case "GET /wechat/status":
541
+ return sendJson(res, 200, this.status());
542
+
543
+ case "POST /wechat/bind/start": {
544
+ const r = await this.beginBind();
545
+ return sendJson(res, r.ok ? 200 : 400, r);
546
+ }
547
+ case "GET /wechat/bind/poll": {
548
+ const r = await this.pollBind();
549
+ return sendJson(res, r.ok ? 200 : 400, r);
550
+ }
551
+ case "POST /wechat/bind/verify": {
552
+ const r = this.submitVerifyCode(body && body.code);
553
+ return sendJson(res, r.ok ? 200 : 400, r);
554
+ }
555
+ case "POST /wechat/bind/cancel":
556
+ return sendJson(res, 200, this.cancelBind());
557
+
558
+ case "POST /wechat/unbind":
559
+ return sendJson(res, 200, await this.unbind());
560
+
561
+ default:
562
+ return sendJson(res, 404, { ok: false, error: `no such route: ${route}` });
563
+ }
564
+ }
565
+
566
+ async stopControl() {
567
+ const srv = this.controlServer;
568
+ this.controlServer = null;
569
+ if (!srv) return;
570
+ await new Promise((resolve) => srv.close(() => resolve()));
571
+ try { fs.rmSync(path.join(this.relayDir, CONTROL_FILE), { force: true }); } catch { /* 忽略 */ }
572
+ }
573
+
574
+ // ── 生命周期 ────────────────────────────────────────────────────────────
575
+
576
+ async start() {
577
+ if (this.disabled) {
578
+ this.logger.info("[wechat] 已按配置禁用(DSH_WECHAT=0)");
579
+ return { ok: true, disabled: true };
580
+ }
581
+ await this.startControl();
582
+ if (this.channel.account) {
583
+ await this.startChannel();
584
+ } else {
585
+ this.logger.info("[wechat] 未绑定微信,等待面板发起绑定");
586
+ }
587
+ this.#startDigestTimer();
588
+ return { ok: true, port: this.boundPort, bound: !!this.channel.account };
589
+ }
590
+
591
+ async stop() {
592
+ this.stopping = true;
593
+ this.cancelBind();
594
+ if (this.digestTask) { clearInterval(this.digestTask); this.digestTask = null; }
595
+ await this.stopChannel();
596
+ await this.stopControl();
597
+ }
598
+
599
+ // ── 出站通道(绑定后才起) ──────────────────────────────────────────────
600
+
601
+ async startChannel() {
602
+ if (this.channelTask || !this.channel.account) return false;
603
+ this.stopping = false;
604
+ this.channel.writeState({ connected_at: this.clock(), last_error: "" });
605
+ // 档位:启动时取一次(不等它,别拖慢上线),之后每 10 分钟校准一次。
606
+ // 用户升级后不必重启 bridge —— 下一次派发时的 #canNow 还会立刻再确认一遍。
607
+ void this.#refreshTier(0);
608
+ if (!this.tierTimer) {
609
+ this.tierTimer = setInterval(() => { void this.#refreshTier(0); }, WECHAT_TIER_REFRESH_MS);
610
+ if (typeof this.tierTimer.unref === "function") this.tierTimer.unref();
611
+ }
612
+ this.#startSubscriber();
613
+ this.channelTask = this.#channelLoop().catch((e) => {
614
+ this.logger.warn(`[wechat] 长轮询退出:${redact(String(e && e.message ? e.message : e))}`);
615
+ this.channelTask = null;
616
+ });
617
+ return true;
618
+ }
619
+
620
+ async stopChannel() {
621
+ this.stopping = true;
622
+ if (this.tierTimer) {
623
+ clearInterval(this.tierTimer);
624
+ this.tierTimer = null;
625
+ }
626
+ if (this.subscriber) {
627
+ try { this.subscriber.close(); } catch { /* 忽略 */ }
628
+ this.subscriber = null;
629
+ }
630
+ const task = this.channelTask;
631
+ this.channelTask = null;
632
+ if (task) { try { await Promise.race([task, sleep(1500)]); } catch { /* 忽略 */ } }
633
+ // 通知腾讯侧"通道客户端下线"
634
+ try {
635
+ if (this.channel.account) {
636
+ this.channel.client.setToken(this.channel.account.token);
637
+ await this.channel.client.notifyStop({});
638
+ }
639
+ } catch (e) {
640
+ this.logger.warn(`[wechat] notifystop 失败(忽略):${redact(String(e && e.message ? e.message : e))}`);
641
+ }
642
+ }
643
+
644
+ /**
645
+ * 入站长轮询。user 的回复经此进来。
646
+ *
647
+ * 退避策略:命中 errcode -14(session timeout)时**不重试**,按腾讯官方插件的做法
648
+ * 冷却 1 小时(§3)——否则会把接口打爆,而且掩盖真正的失效原因。
649
+ */
650
+ async #channelLoop() {
651
+ // 上线信号:告诉腾讯侧"通道客户端起来了"(§3 notifystart)
652
+ try {
653
+ await this.channel.client.notifyStart({});
654
+ } catch (e) {
655
+ this.logger.warn(`[wechat] notifystart 失败(继续):${redact(String(e && e.message ? e.message : e))}`);
656
+ }
657
+ this.channel.writeState({ connected_at: this.clock(), last_error: "" });
658
+
659
+ while (!this.stopping) {
660
+ // ⚠️ SessionCooldown **没有** isActive() —— 它只有 remainingMs()/remainingMinutes()/arm()。
661
+ // 早先这里写成 `cooldown.isActive && cooldown.isActive()` 会静默短路成 false,
662
+ // 后果是 -14 冷却**完全不生效**、命中 session timeout 后仍继续猛打接口。
663
+ // 这正是本模块要防的事,所以只用 remainingMs() 判,并由测试锁死。
664
+ const cooldownMs = this.channel.cooldown.remainingMs();
665
+ if (cooldownMs > 0) {
666
+ await sleep(Math.min(60_000, Math.max(1000, cooldownMs)));
667
+ continue;
668
+ }
669
+ let resp;
670
+ try {
671
+ resp = await this.channel.client.getUpdates({
672
+ buf: this.channel.updatesBuf,
673
+ timeoutMs: DEFAULT_UPDATES_TIMEOUT_MS
674
+ });
675
+ } catch (e) {
676
+ this.#noteFailure(`getupdates 失败:${redact(String(e && e.message ? e.message : e))}`);
677
+ await sleep(3000);
678
+ continue;
679
+ }
680
+ if (isSessionExpired(resp)) {
681
+ this.channel.noteSessionExpired("getupdates");
682
+ continue;
683
+ }
684
+ if (typeof resp.get_updates_buf === "string") this.channel.updatesBuf = resp.get_updates_buf;
685
+ for (const msg of Array.isArray(resp.msgs) ? resp.msgs : []) {
686
+ try { await this.handleInbound(msg); } catch (e) {
687
+ this.logger.warn(`[wechat] 处理入站消息失败:${redact(String(e && e.message ? e.message : e))}`);
688
+ }
689
+ }
690
+ }
691
+ }
692
+
693
+ // ── 入站:回执与指令 ────────────────────────────────────────────────────
694
+
695
+ /** 取"最近一条待答通知"(用户回数字时映射到它)。 */
696
+ /**
697
+ * 取"**最早**一条待答通知"。
698
+ *
699
+ * ⚠️ 必须是**先进先出**,不能取最近一条。真机 bug(业主报的):
700
+ * 「如果需要用户回应多条消息,而用户一开始只看到第一条审批消息,回应一个数字之后,
701
+ * 流程就结束了,后面的几条消息没有让用户继续回应,直接提示为『未回应』」
702
+ * 成因:旧实现按栈顶(最新那条)匹配数字,而**微信是从上往下读的**、消息一发出去位置就固定 ——
703
+ * 用户看着第 1 条回「1」,系统却答了最后一条,第 1 条于是永远没人回应。
704
+ */
705
+ #oldestPending() {
706
+ // 先清掉已被消费/过期的,再看队列
707
+ this.pendingReplies = this.pendingReplies.filter((p) => this.channel.registry.get(p.eventId));
708
+ // ★ **决策优先**:数字回执是给"要用户拍板的事"用的。
709
+ // 提醒类(额度/会员到期)只是告知 + 顺带入个口,不能抢走审批的回执位 ——
710
+ // 否则用户看着底部那条审批回「1」,回执却落到更早的提醒上(真机复现过)。
711
+ const decisions = this.pendingReplies.filter((p) => !p.notice);
712
+ if (decisions.length) return decisions[0];
713
+ // 一条决策都没有 → 才轮到提醒(它的「邀请好友 / 知道了」这时才有意义)
714
+ return this.pendingReplies[0] || null;
715
+ }
716
+
717
+ /** 待**拍板**的条数(不含提醒类)。 */
718
+ #decisionCount() {
719
+ return this.pendingReplies.filter((p) => !p.notice).length;
720
+ }
721
+
722
+ /**
723
+ * 答完一条后,把**下一条**待拍板的重新发到底部。
724
+ *
725
+ * 这是"多条审批"体验的关键一步:微信消息位置固定,用户永远在**底部的那条**上回数字。
726
+ * 把下一条重发到底部 ⇒ "用户正在看的那条"就恒等于"系统会作答的那条"(FIFO),两种直觉对齐。
727
+ * 不重发的话,用户得往上翻去找哪条还没答 —— 翻错就又是"答错对象"。
728
+ *
729
+ * 只发**紧凑提醒**(完整内容在聊天记录里已有),不复用完整模板:避免重复长文、也避免再占一个回执编号。
730
+ */
731
+ async #resurfaceNext(from) {
732
+ const pending = this.#oldestPending();
733
+ if (!pending) return;
734
+ const entry = this.channel.registry.get(pending.eventId);
735
+ const opts = Array.isArray(entry && entry.options) ? entry.options : [];
736
+ if (!opts.length) return; // 不可回执的(如破坏性审批)不再提示
737
+ const task = String((pending.node && (pending.node.toolName || pending.node.title)) || "").trim();
738
+ const lines = [`还有 ${this.#decisionCount()} 条待你拍板:`];
739
+ if (task) lines.push(`· ${task}`);
740
+ lines.push("");
741
+ opts.forEach((o, i) => lines.push(`回复 ${i + 1} = ${o.label}`));
742
+ await this.reply(from, lines.join("\n"));
743
+ }
744
+
745
+ /** 处理一条入站消息(回执 / 指令 / 交代任务)。公开以便 bridge 与测试直接驱动。 */
746
+ async handleInbound(msg) {
747
+ const from = extractFromUserId(msg);
748
+ const text = normalizeInput(extractInboundText(msg));
749
+ if (!text) return;
750
+
751
+ // 内部统计:任何入站互动都算一次"窗口续期"事件
752
+ this.#bumpToday();
753
+
754
+ // ── v2 分派:命令 → 数字 → 普通消息 ────────────────────────────────────
755
+ // ⚠️ 顺序不能换:
756
+ // · 命令必须最先 —— 否则「/new 修个 bug」会被当成一条发给会话的普通消息;
757
+ // · 数字必须优先于普通消息 —— 否则用户回复「1」做审批时,会被当成给会话的文本发进去。
758
+ const cls = classifyInbound(text);
759
+
760
+ if (cls.kind === "command") {
761
+ await this.#runCommand(from, cls);
762
+ return;
763
+ }
764
+ if (cls.kind === "choice") {
765
+ await this.#answerChoice(from, cls.choice);
766
+ return;
767
+ }
768
+ if (cls.kind === "message") {
769
+ // 纯文本 = 派活。★ 分两种(业主 2026-09-22 拍板):
770
+ // · **有当前会话** → 「继续已有会话」= assign.continue —— **免费也有**(让免费用户能接着聊)
771
+ // · **没有当前会话** → 等同开新任务 = assign.new —— 付费才有
772
+ // 这样既满足"能继续聊",又保住"开新任务/管会话"是会员能力。
773
+ const need = this.currentSessionId ? "assign.continue" : "assign.new";
774
+ if (!(await this.#canNow(need))) {
775
+ // ⚠️ 陷阱:回执只认**纯数字**(见 classifyInbound)。免费档的核心价值恰恰是审批,
776
+ // 用户很可能打字「允许」而不是回「1」—— 若只回一句付费提示,他会以为免费版什么都干不了。
777
+ // 所以手上有待回执的消息时,先把"回数字就能拍板"说在前面。
778
+ const waiting = this.#decisionCount();
779
+ const head = waiting > 0
780
+ ? `你还有 ${waiting} 条待你拍板的消息 —— 直接回一个数字(如 1)就能完成决定,不用打字。\n\n`
781
+ : "";
782
+ await this.reply(from, head + this.#upsellText());
783
+ return;
784
+ }
785
+ await this.#sendToSession(from, cls.text);
786
+ return;
787
+ }
788
+ }
789
+
790
+ /**
791
+ * 当前账号档位。可用 `opts.tier` 覆盖(测试注入);否则用**上次成功取到的档位**。
792
+ * 从没取到过 = 空 → 按 free(宁可少给,不可误放)。
793
+ */
794
+ #tier() {
795
+ if (this.tierOverride) return this.tierOverride;
796
+ return this.cachedTier || "free";
797
+ }
798
+
799
+ /**
800
+ * 当前生效的**能力集合**。
801
+ * 优先用服务端权益包下发的 caps;没有才按档位查内置表(冷启动兜底)。
802
+ * 这样"后台改权限无需发版"就成立了 —— 而内置表保证即使服务端没给也不会瞎放权。
803
+ */
804
+ #caps() {
805
+ const server = this.entitlements && Array.isArray(this.entitlements.caps) ? this.entitlements.caps : null;
806
+ // ⚠️ 判据是「**是不是数组**」,不是「数组非空」(2026-09-23 修):
807
+ // 服务端下发 `caps: []` 是**权威的"这一档什么都不能做"**(后台明确清空,契约 §4.1)。
808
+ // 写成 `server.length` 会把空数组当成"没给",于是回退**内置表** ——
809
+ // 本该"什么都不能做"的档位拿回内置的一整套能力(付费档尤其严重),方向正好是 fail-open。
810
+ if (server) return server;
811
+ return capabilitiesFor(this.#tier(), this.capabilityTable);
812
+ }
813
+
814
+ /** 当前生效的限制(服务端权益包优先,否则内置表按档位)。 */
815
+ #limits() {
816
+ const server = this.entitlements && this.entitlements.limits;
817
+ if (server && typeof server === "object") return server;
818
+ return limitsFor(this.#tier(), this.limitTable);
819
+ }
820
+
821
+ /** 当前档位是否具备某能力(运营钩子的唯一判断入口)。 */
822
+ #can(cap) {
823
+ return grantsCap(this.#caps(), cap);
824
+ }
825
+
826
+ /** 月份键(用量按月归零)。 */
827
+ #monthKey(now = this.clock()) {
828
+ const d = new Date(now);
829
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
830
+ }
831
+
832
+ /**
833
+ * **每月消息额度闸**(业主 2026-09-22:免费给每月 N 条,超出引导升级)。
834
+ * `messages_per_month` 为 0/缺省 = 不限(付费档走这条)。
835
+ * ⚠️ 只在**派活**前拦;审批回执与指令不计数、也不拦 ——
836
+ * 否则免费用户会为了省额度不敢拍板,那等于把安全阀关掉。
837
+ */
838
+ #msgQuotaGate() {
839
+ const limit = Number((this.#limits() || {}).messages_per_month || 0);
840
+ if (!Number.isFinite(limit) || limit <= 0) return { ok: true };
841
+ const month = this.#monthKey();
842
+ const used = this.msgUsage && this.msgUsage.month === month ? Number(this.msgUsage.count || 0) : 0;
843
+ if (used < limit) return { ok: true };
844
+ return {
845
+ ok: false,
846
+ text: `本月的 ${limit} 条消息额度已经用完了(下个月 1 号自动恢复)。\n\n${this.#upsellText()}`
847
+ };
848
+ }
849
+
850
+ /** 记一条消息用量。**只在真的派出去之后调用**(发失败不该扣额度)。 */
851
+ #msgQuotaBump() {
852
+ const month = this.#monthKey();
853
+ const used = this.msgUsage && this.msgUsage.month === month ? Number(this.msgUsage.count || 0) : 0;
854
+ this.msgUsage = { month, count: used + 1 };
855
+ this.channel.writeState({ msg_usage: this.msgUsage });
856
+ }
857
+
858
+ /**
859
+ * 刷新档位(带节流)。**取不到时保留上次已知档位** —— 网络抖一下就把付费用户降级成免费,
860
+ * 他会看到"升级后即可使用"而自己明明付过钱,这比多给几分钟权限糟糕得多。
861
+ * @returns {Promise<string>} 刷新后的档位
862
+ */
863
+ async #refreshTier(minIntervalMs = 30_000) {
864
+ if (!this.tierProvider) return this.#tier();
865
+ const now = Date.now();
866
+ if (now - this.tierCheckedAt < minIntervalMs) return this.#tier();
867
+ this.tierCheckedAt = now;
868
+ let got = null;
869
+ try {
870
+ got = await this.tierProvider();
871
+ } catch (e) {
872
+ this.logger.warn(`[wechat] 读取账号档位失败(沿用上次):${redact(String(e && e.message ? e.message : e))}`);
873
+ return this.#tier();
874
+ }
875
+ // 兼容两种契约:字符串(旧:只给档位)与对象(权益包:档位 + 能力 + 限制 + 版本号)
876
+ const isObj = Boolean(got) && typeof got === "object";
877
+ const plan = String((isObj ? got.plan : got) || "").trim();
878
+ if (!plan) return this.#tier(); // 空 = 这次没取到 → 沿用缓存(绝不降级)
879
+ // ★ 判据是「caps 是**数组**」,**空数组也算权威**(2026-09-23 修):
880
+ // 空数组 = 服务端明确表达"这一档什么都不能做"(后台"清空",契约 §4.1)。
881
+ // 若把空数组当成"没给",会沿用上次那份包(或回退内置表)——
882
+ // 于是"清空 pro"变成"pro 拿回内置的一整付费套能力",比意图**更多**权限(fail-open)。
883
+ const caps = isObj && Array.isArray(got.caps) ? got.caps : null;
884
+ const limits = isObj && got.limits && typeof got.limits === "object" ? got.limits : null;
885
+ const rev = isObj ? String(got.rev || "") : "";
886
+ const planChanged = plan !== this.cachedTier;
887
+ const bundleIncoming = Boolean(caps || limits);
888
+ this.cachedTier = plan;
889
+ if (bundleIncoming) {
890
+ // 服务端给了权益包 → 以它为准;没给的字段保留上次(不因为缺字段就把能力清空)
891
+ this.entitlements = {
892
+ rev,
893
+ plan,
894
+ caps: caps || (this.entitlements && this.entitlements.caps) || null,
895
+ limits: limits || (this.entitlements && this.entitlements.limits) || null
896
+ };
897
+ this.entitlementsRev = rev;
898
+ }
899
+ if (planChanged || bundleIncoming) {
900
+ this.channel.writeState(bundleIncoming ? { tier: plan, entitlements: this.entitlements } : { tier: plan });
901
+ if (planChanged) this.logger.info(`[wechat] 账号档位:${plan}${bundleIncoming ? "(含权益包)" : ""}`);
902
+ }
903
+ return this.#tier();
904
+ }
905
+
906
+ /**
907
+ * 判能力时先**确保档位是新鲜的**:用户刚买完重试必须能进(最坏等 `tierDenyThrottleMs`,
908
+ * 默认 5 秒),而不是等到下次重启 bridge 才发现"我付了钱还是不能用"。
909
+ */
910
+ async #canNow(cap) {
911
+ if (this.#can(cap)) return true;
912
+ await this.#refreshTier(this.tierDenyThrottleMs);
913
+ return this.#can(cap);
914
+ }
915
+
916
+ /**
917
+ * 立刻校准一次档位(不走节流)。
918
+ *
919
+ * 存在的理由:这是唯一能**主动**触发校准的入口 —— 其余触发点都被"当前档位够用就不查"
920
+ * 拦在前面(`#canNow` 只在被拒时才查),于是一条关键不变量没法被测到:
921
+ * 「provider 返回空串时必须沿用上次档位,而不是降级」。付费用户的钱包就挂在这条上。
922
+ * 将来面板做"我刚买完,立刻校准"也走这里。
923
+ */
924
+ async refreshTierNow() {
925
+ return this.#refreshTier(0);
926
+ }
927
+
928
+ /**
929
+ * 帮助文案。**必须按档位如实分层**。
930
+ *
931
+ * 为什么不能直接用上游那个静态 HELP_TEXT:它把 `/new`、`/ls`、`/use` 与"直接发一句话"
932
+ * 都列成"你能用的",而 `#upsellText()` 正是让免费用户"回复 /help 看现在能做什么" ——
933
+ * 那等于把人骗到一个他做不到的清单上,然后每次尝试都吃一次拒绝。
934
+ */
935
+ #helpText() {
936
+ // ★ 逐条**按实际能力**生成,不再用"档位名"或"有没有粗粒度 assign"来猜。
937
+ //
938
+ // 为什么必须改(2026-09-23,后台可配权限之后才暴露):管理员可以把 caps 配得很细 ——
939
+ // 只勾细粒度 `assign.new` 而不勾粗粒度根 `assign` 时,旧的 `#can("assign")` 返回**假**,
940
+ // 于是**付费用户**拿到一份写着"你没有这些功能"的免费档帮助;
941
+ // 反过来(勾了粗根又关掉某个子能力)则会把做不到的指令列成"你能用的"。
942
+ // 两个方向都违反业主红线「话术与权限必须一致、不多承诺」。
943
+ // 现在每一行都挂一个判据,能不能用由 `#can(...)` **逐条**决定。
944
+ const canNew = this.#can("assign.new");
945
+ const canContinue = this.#can("assign.continue");
946
+ const rows = [
947
+ { ok: this.#can("approve"), text: "· 回数字(如 1)回执最近一条需要你拍板的消息" },
948
+ { ok: this.#can("stop"), text: "· /stop 中断当前会话正在跑的回合" },
949
+ { ok: this.#can("status"), text: "· /status 查看绑定与推送状态" },
950
+ { ok: true, text: "· /quiet 暂停推送(回复任意消息恢复)" }, // 本地开关,不需要任何能力
951
+ { ok: true, text: "· /unbind 解除微信绑定" },
952
+ // 「接着当前任务聊」与「开新任务」是**两个独立能力**(业主 2026-09-22 拍板把 assign 拆成两半),
953
+ // 所以这里也拆成两行、各自判各自的:免费用户会看到第一行可用、第二行落在会员区。
954
+ // ⚠️ 行内**不要**再写"会员功能"三个字 —— 那是下面分区标题的专属词,
955
+ // 混进可用段会让"会员区之前不得出现 /new"这类结构断言失准(读起来也乱)。
956
+ { ok: canContinue, text: "· 直接发一句话 = 接着当前任务聊" },
957
+ { ok: canNew, text: "· /new <任务> 开新任务(直接发一句话也行)" },
958
+ { ok: this.#can("sessions"), text: "· /ls 列出会话、/use <编号> 切换会话" },
959
+ { ok: this.#can("summary"), text: "· /summary 重发最近结论" }
960
+ ];
961
+ const usable = rows.filter((r) => r.ok);
962
+ const locked = rows.filter((r) => !r.ok);
963
+ // 「通知版」这个标签说的是**档位画像**,不是"有缺项" —— 判据必须是
964
+ // "他有没有能在微信里**干活**的能力"(开新任务 / 管会话 / 要小结)。
965
+ // ⚠️ 曾经写成 `locked.length ? 通知版 : ...`:后台把 caps 配细之后,
966
+ // 一个能开新任务的付费用户只要缺了 summary 就会被标成"通知版" —— 那是错的画像。
967
+ const workCaps = ["assign.new", "sessions", "summary"];
968
+ const isNoticeOnly = !workCaps.some((c) => this.#can(c));
969
+ const lines = [
970
+ `【DSH 微信通道${isNoticeOnly ? " · 通知版" : ""}】`,
971
+ "现在能用的:",
972
+ ...usable.map((r) => r.text)
973
+ ];
974
+ if (locked.length) {
975
+ lines.push("", "会员功能(开通后可用):", ...locked.map((r) => r.text));
976
+ if (this.appUrl) lines.push("", `👉 开通并查看设备列表:${this.appUrl}`);
977
+ }
978
+ return lines.join("\n");
979
+ }
980
+
981
+ /**
982
+ * 免费用户越界时的回复:说清「通知版能做什么」+ 给出可点击的 App 链接。
983
+ *
984
+ * 业主口径:免费用户要去做别的事情时,就在消息后面跟一个访问 App 的链接,
985
+ * 点一下就能到我们的 App 界面 —— 这既是解释,也是把用户引回远程控制主功能的入口。
986
+ * ⚠️ 微信不渲染 Markdown,所以这里用「」和纯文本,不要写 `**`。
987
+ */
988
+ #upsellText() {
989
+ const lines = [
990
+ "微信机器人当前是「通知版」:",
991
+ "· 能收到任务通知、完成小结,以及需要你审批的请求",
992
+ "· 在微信里直接交代任务、切换会话属于会员功能"
993
+ ];
994
+ if (this.appUrl) {
995
+ lines.push("", `👉 打开 App 查看设备列表、远程控制你的电脑:${this.appUrl}`);
996
+ }
997
+ lines.push("", "回复 /help 可以看到现在能做什么。");
998
+ return lines.join("\n");
999
+ }
1000
+
1001
+ /**
1002
+ * 免费档的任务收尾话术。
1003
+ * 默认那句「回复就能接着做」对免费用户是**空头承诺**(他回复只会拿到付费引导),
1004
+ * 所以换成"会员可用 + App 链接",把用户引到主功能上去。
1005
+ */
1006
+ #continuationHint() {
1007
+ const tail = this.appUrl ? `打开 App 继续:${this.appUrl}` : "打开 App 即可继续。";
1008
+ return `在微信里接着交代下一步属于会员功能。${tail}`;
1009
+ }
1010
+
1011
+ /** 指令分派。 */
1012
+ async #runCommand(from, cls) {
1013
+ const cmd = cls.command;
1014
+ const args = cls.args || "";
1015
+
1016
+ // ★ 运营钩子:能力表在**每次派发**时真的被查 —— 它是活代码,不是文档。
1017
+ // 先 `#canNow` 刷新档位再判:用户刚升级完立刻重试必须能进。
1018
+ const need = COMMAND_CAPABILITY[cmd];
1019
+ if (need && !(await this.#canNow(need))) {
1020
+ await this.reply(from, this.#upsellText());
1021
+ return;
1022
+ }
1023
+
1024
+ if (cmd === "/unbind") {
1025
+ await this.reply(from, "正在为你解绑微信机器人…");
1026
+ await this.unbind();
1027
+ return;
1028
+ }
1029
+ if (cmd === "/quiet") {
1030
+ this.channel.writeState({ quiet: true });
1031
+ await this.reply(from, "已开启免打扰:之后只推需要你决定的与报错,不再推日常状态。回复 /status 查看,回复任意消息可恢复。");
1032
+ return;
1033
+ }
1034
+ if (cmd === "/new") {
1035
+ await this.#cmdNew(from, args);
1036
+ return;
1037
+ }
1038
+ if (cmd === "/ls") {
1039
+ await this.#cmdList(from);
1040
+ return;
1041
+ }
1042
+ if (cmd === "/use") {
1043
+ await this.#cmdUse(from, args);
1044
+ return;
1045
+ }
1046
+ if (cmd === "/stop") {
1047
+ await this.#cmdStop(from);
1048
+ return;
1049
+ }
1050
+ if (cmd === "/summary") {
1051
+ await this.#cmdSummary(from);
1052
+ return;
1053
+ }
1054
+ if (cmd === "/status") {
1055
+ await this.#cmdStatus(from);
1056
+ return;
1057
+ }
1058
+ if (cmd === "/help") {
1059
+ await this.reply(from, this.#helpText());
1060
+ return;
1061
+ }
1062
+ // 未知指令:**绝不静默**,而且必须用**按档位分层**的帮助。
1063
+ //
1064
+ // ⚠️ 这里以前直接透传上游 `handleCommand()` 的 replyText,而它拼的是**静态** HELP_TEXT ——
1065
+ // 那份清单把 `/new`、`/ls`、`/use`、`/summary` 与"直接发一句话"一并列成"你能用的"。
1066
+ // 免费用户敲错一个字,拿到的就是这张会员清单:照着做 → 再吃一次拒绝,
1067
+ // 于是"打错命令"被理解成"这功能坏了"。业主红线是「话术与权限必须一致、不多承诺」,
1068
+ // 而 `#helpText()` 存在的唯一理由就是按档位如实分层(见它的注释)。
1069
+ // 另外把用户敲的那条**原样回显** —— 他才知道是哪个字打错了。
1070
+ if (cmd) {
1071
+ await this.reply(from, `不认识这条指令:${cmd}\n\n${this.#helpText()}`);
1072
+ return;
1073
+ }
1074
+ // 兜底:理论上到不了(入站非空文本必带 command),但绝不静默吞掉
1075
+ const r = handleCommand(cmd, args);
1076
+ await this.reply(from, (r && r.replyText) || "可用指令:/help");
1077
+ }
1078
+
1079
+ // ── v2:会话遥控 ────────────────────────────────────────────────────────
1080
+
1081
+ /** 当前会话指针落盘(重启后仍记得你在跟哪个任务)。 */
1082
+ #rememberSession(sessionId, title) {
1083
+ this.currentSessionId = sessionId || "";
1084
+ this.currentSessionTitle = title || "";
1085
+ this.channel.writeState({ current_session_id: this.currentSessionId, current_session_title: this.currentSessionTitle });
1086
+ }
1087
+
1088
+ /** 拉一次会话列表(带标题),并记住 1-based 序号供 /use 使用。 */
1089
+ async #sessions() {
1090
+ if (!this.subscriber || typeof this.subscriber.listSessions !== "function") return null;
1091
+ const r = await this.subscriber.listSessions();
1092
+ if (!r || !r.ok) return null;
1093
+ this.sessionIndex = r.sessions || [];
1094
+ return this.sessionIndex;
1095
+ }
1096
+
1097
+ /**
1098
+ * `/new [任务]` —— 建一个 DSH 会话,可选地立刻把任务下发进去。
1099
+ * 默认**沿用最近项目**(cwd 取最近一个会话的 cwd);要换项目就显式给路径,
1100
+ * 避免把简单事做复杂(业主拍板)。
1101
+ */
1102
+ async #cmdNew(from, task) {
1103
+ if (!this.subscriber || typeof this.subscriber.createSession !== "function") {
1104
+ await this.reply(from, "暂不可用:DSH 会话服务未就绪。");
1105
+ return;
1106
+ }
1107
+ // ⚠️ 额度闸必须在**建会话之前** —— 否则超额时会在 DSH 里留下一个空会话(白占一个任务的坑)
1108
+ const gate = this.#msgQuotaGate();
1109
+ if (!gate.ok) {
1110
+ await this.reply(from, gate.text);
1111
+ return;
1112
+ }
1113
+ const list = await this.#sessions();
1114
+ let cwd = "";
1115
+ if (list && list.length) {
1116
+ // 优先沿用**当前会话**的项目,没有则用最近用过的那个
1117
+ const cur = list.find((s) => s.sessionId === this.currentSessionId);
1118
+ cwd = (cur && cur.cwd) || (list[0] && list[0].cwd) || "";
1119
+ }
1120
+
1121
+ const created = await this.subscriber.createSession(cwd ? { cwd } : {});
1122
+ if (!created || !created.ok) {
1123
+ await this.reply(from, `开新任务失败:${(created && created.message) || "未知错误"}`);
1124
+ return;
1125
+ }
1126
+ this.#rememberSession(created.sessionId, "");
1127
+ const short = String(created.sessionId).replace(/^session-/, "").slice(0, 8);
1128
+
1129
+ const text = String(task || "").trim();
1130
+ if (!text) {
1131
+ await this.reply(from, `已开新任务 ${short}。把要做的直接发给我就行(回复任意内容即下发)。`);
1132
+ return;
1133
+ }
1134
+ const sent = await this.subscriber.promptSession({ sessionId: created.sessionId, text });
1135
+ if (!sent || !sent.ok) {
1136
+ await this.reply(from, `新任务 ${short} 已建立,但下发失败:${(sent && sent.message) || "未知错误"}`);
1137
+ return; // 没派出去 → 不扣额度
1138
+ }
1139
+ this.#msgQuotaBump();
1140
+ await this.reply(from, `已开新任务 ${short} 并下发。跑完我会推结论给你;中途想补充直接回话即可。`);
1141
+ // ★ 额度消耗**之后**才可能提醒(必须排在 bump 之后:提醒要看到刚扣掉的这一条)。
1142
+ // 放在回执之后:提醒落在"已下发"下面,读起来是补充说明而不是打断。
1143
+ // fire-and-forget:提醒是锦上添花,绝不能因为它失败/变慢而影响派活这条主流程。
1144
+ this.#maybeRemindQuota().catch(() => {});
1145
+ }
1146
+
1147
+ /** `/ls` —— 列出最近会话(带名称),供 /use 选择。 */
1148
+ async #cmdList(from) {
1149
+ const list = await this.#sessions();
1150
+ if (!list) {
1151
+ await this.reply(from, "暂时拿不到会话列表(DSH 会话服务未就绪)。");
1152
+ return;
1153
+ }
1154
+ if (!list.length) {
1155
+ await this.reply(from, "还没有任何会话。发一句话给我就能开一个新任务。");
1156
+ return;
1157
+ }
1158
+ const top = list.slice(0, 9);
1159
+ const lines = ["最近的会话(回 /use <编号> 切换):"];
1160
+ top.forEach((s, i) => {
1161
+ const name = s.title || "(未命名)";
1162
+ const mark = s.sessionId === this.currentSessionId ? " ←当前" : "";
1163
+ const run = s.running ? " ▶运行中" : "";
1164
+ lines.push(`${i + 1}. ${name}${run}${mark}`);
1165
+ });
1166
+ lines.push("纯文本默认发给「当前」会话;想开新的用 /new。");
1167
+ await this.reply(from, lines.join("\n"));
1168
+ }
1169
+
1170
+ /** `/use <n>` —— 切换当前会话。 */
1171
+ async #cmdUse(from, args) {
1172
+ const list = (this.sessionIndex && this.sessionIndex.length) ? this.sessionIndex : await this.#sessions();
1173
+ if (!list || !list.length) {
1174
+ await this.reply(from, "还没有会话可选。先用 /ls 看看,或 /new 开一个。");
1175
+ return;
1176
+ }
1177
+ const n = Number(String(args || "").trim());
1178
+ if (!Number.isInteger(n) || n < 1 || n > list.length) {
1179
+ await this.reply(from, `编号不对。请回 /use 1 到 /use ${list.length} 之间的数字(先 /ls 看列表)。`);
1180
+ return;
1181
+ }
1182
+ const s = list[n - 1];
1183
+ this.#rememberSession(s.sessionId, s.title || "");
1184
+ await this.reply(from, `已切到:${s.title || "(未命名)"}${s.running ? "(运行中)" : ""}。之后你发的话都进这个任务。`);
1185
+ }
1186
+
1187
+ /** `/stop` —— 中断当前会话正在跑的回合。 */
1188
+ async #cmdStop(from) {
1189
+ if (!this.currentSessionId) {
1190
+ await this.reply(from, "现在没有选中的会话。/ls 看看要停哪个,或 /use <编号> 选中它。");
1191
+ return;
1192
+ }
1193
+ if (!this.subscriber || typeof this.subscriber.cancelSession !== "function") {
1194
+ await this.reply(from, "暂不可用:DSH 会话服务未就绪。");
1195
+ return;
1196
+ }
1197
+ const r = await this.subscriber.cancelSession({ sessionId: this.currentSessionId });
1198
+ await this.reply(from, r && r.ok ? "已发出中断。任务停下后我会把状态推给你。" : `中断失败:${(r && r.message) || "未知错误"}`);
1199
+ }
1200
+
1201
+ /** `/status` —— 绑定状态 + 当前会话。 */
1202
+ async #cmdStatus(from) {
1203
+ const base = renderStatusText(this.channel.account, loadState(this.relayDir), { pending: this.#decisionCount() });
1204
+ const name = this.currentSessionTitle || "";
1205
+ const short = this.currentSessionId ? String(this.currentSessionId).replace(/^session-/, "").slice(0, 8) : "";
1206
+ const line = this.currentSessionId
1207
+ ? `当前任务:${name || "(未命名)"} ${short}`
1208
+ : "当前任务:未选中(发一句话即开新任务,/ls 可切换)";
1209
+ await this.reply(from, `${base}\n${line}`);
1210
+ }
1211
+
1212
+ /** `/summary` —— 重发当前会话的最近结论。 */
1213
+ async #cmdSummary(from) {
1214
+ if (!this.currentSessionId) {
1215
+ await this.reply(from, "现在没有选中的会话。先 /ls + /use <编号> 选一个。");
1216
+ return;
1217
+ }
1218
+ const r = await this.#sessionSummary(this.currentSessionId);
1219
+ if (!r) {
1220
+ await this.reply(from, "暂时取不到结论(会话历史读不到或还没有内容)。");
1221
+ return;
1222
+ }
1223
+ await this.reply(from, r);
1224
+ }
1225
+
1226
+ /** 取某个会话的"结论"= 最后一条助手消息(截到微信可读长度上限)。 */
1227
+ async #sessionSummary(sessionId) {
1228
+ if (!this.subscriber || typeof this.subscriber.lastAssistantText !== "function") return "";
1229
+ try {
1230
+ const r = await this.subscriber.lastAssistantText({ sessionId });
1231
+ if (!r || !r.ok || !r.text) return "";
1232
+ // ⚠️ 这里与展示侧的 COMPLETION_SUMMARY_MAX 是**两道闸**:取值侧若更小,
1233
+ // 结论会在拼接之前就被砍掉,排查时只盯 formatter 找不到真正截断点(历史踩过)。
1234
+ return String(r.text).trim().slice(0, SESSION_SUMMARY_MAX);
1235
+ } catch {
1236
+ return "";
1237
+ }
1238
+ }
1239
+
1240
+ /** 普通文本 → 发给当前会话;没有当前会话就等同 /new。 */
1241
+ async #sendToSession(from, text) {
1242
+ const body = String(text || "").trim();
1243
+ if (!body) return;
1244
+ if (!this.currentSessionId) {
1245
+ await this.#cmdNew(from, body);
1246
+ return;
1247
+ }
1248
+ if (!this.subscriber || typeof this.subscriber.promptSession !== "function") {
1249
+ await this.reply(from, "暂不可用:DSH 会话服务未就绪。");
1250
+ return;
1251
+ }
1252
+ // 每月消息额度闸(放在真正派活之前;审批/指令不计数也不拦)
1253
+ const gate = this.#msgQuotaGate();
1254
+ if (!gate.ok) {
1255
+ await this.reply(from, gate.text);
1256
+ return;
1257
+ }
1258
+ const r = await this.subscriber.promptSession({ sessionId: this.currentSessionId, text: body });
1259
+ if (r && r.ok) {
1260
+ this.#msgQuotaBump(); // 只在真的派出去之后扣额度(发失败不该扣)
1261
+ await this.reply(from, `已补充给「${this.currentSessionTitle || "当前任务"}」,跑完推结论给你。`);
1262
+ // ★ 额度消耗**之后**才可能提醒(顺序不能反:提醒要看的是扣完之后还剩几条);
1263
+ // fire-and-forget,提醒失败不影响"已下发"这条主流程的结果。
1264
+ this.#maybeRemindQuota().catch(() => {});
1265
+ return;
1266
+ }
1267
+ await this.reply(from, `发送失败:${(r && r.message) || "未知错误"}。回 /ls 确认当前任务还在不在。`);
1268
+ }
1269
+
1270
+ /** 数字回执(审批 / 提问)。 */
1271
+ async #answerChoice(from, digits) {
1272
+ if (digits === null || digits === undefined) return;
1273
+
1274
+ const pending = this.#oldestPending();
1275
+ if (!pending) {
1276
+ // §6 ①:过期**绝不**当成同意,如实告诉用户。
1277
+ await this.reply(from, "这条对应的待办已经过期或已被处理过了,没有代你做出任何选择。");
1278
+ return;
1279
+ }
1280
+ const entry = this.channel.registry.get(pending.eventId);
1281
+ if (!entry) {
1282
+ this.pendingReplies = this.pendingReplies.filter((p) => p.eventId !== pending.eventId);
1283
+ await this.reply(from, "这条对应的待办已经过期或已被处理过了,没有代你做出任何选择。");
1284
+ return;
1285
+ }
1286
+ const option = entry.options[digits - 1];
1287
+ if (!option) {
1288
+ await this.reply(from, `编号 ${digits} 不在选项里。请回复 1-${entry.options.length} 之间的数字。`);
1289
+ return;
1290
+ }
1291
+
1292
+ // ── 提醒类(额度 / 会员到期):它**没有**对应的 DSH 提问或审批,回执必须就地消化 ──
1293
+ // ⚠️ 这里顺手修掉一个「从来没生效过」的入口:此前提醒的「邀请好友 / 知道了」
1294
+ // 被当成提问派给 `subscriber.answerQuestion`,拿一个**不存在的 question id** 去答,
1295
+ // 必然失败 → 用户看到的是「没能替你完成这个选择」。选项文案承诺了、行为却做不到,
1296
+ // 正是业主红线里"话术与权限不一致"的那一类。
1297
+ if (pending.notice) {
1298
+ this.channel.registry.consume(pending.eventId);
1299
+ this.pendingReplies = this.pendingReplies.filter((p) => p.eventId !== pending.eventId);
1300
+ if (String(option.value) === "invite") {
1301
+ const lines = ["邀请好友:在 App 里的「邀请」入口取你的专属链接,发给他即可。"];
1302
+ if (this.appUrl) lines.push(this.appUrl);
1303
+ // 只说事实,不报具体奖励数字(那是 App/后台的口径,这里报错了就成了假承诺)
1304
+ lines.push("(只有邀请人得奖励,被邀请方没有奖励;奖励档位以 App 内展示为准。)");
1305
+ await this.reply(from, lines.join("\n"));
1306
+ } else {
1307
+ await this.reply(from, "好的。");
1308
+ }
1309
+ return;
1310
+ }
1311
+
1312
+ // 用掉,避免同一条被回复两次
1313
+ this.channel.registry.consume(pending.eventId);
1314
+ this.pendingReplies = this.pendingReplies.filter((p) => p.eventId !== pending.eventId);
1315
+
1316
+ // ⚠️ 选项字段是 `value`(不是 outcome);且审批与提问的**回执编码不同**,
1317
+ // 必须按事件节点自己的 answerShape 分派 —— 用错会把审批值塞进提问信封。
1318
+ let ans;
1319
+ try {
1320
+ if (pending.answerShape === "approval") {
1321
+ ans = await this.subscriber.answerApproval(pending.eventId, option.value);
1322
+ } else {
1323
+ const q = Array.isArray(pending.node && pending.node.questions) ? pending.node.questions[0] : null;
1324
+ const answers = q
1325
+ ? [{ id: q.id, selected: [String(option.label)] }]
1326
+ : [{ id: "answer", selected: [String(option.label)] }];
1327
+ ans = await this.subscriber.answerQuestion(pending.eventId, answers);
1328
+ }
1329
+ } catch (e) {
1330
+ ans = { ok: false, error: redact(String(e && e.message ? e.message : e)) };
1331
+ }
1332
+ this.#bumpToday("answered");
1333
+ if (ans && ans.ok === false) {
1334
+ // 回晚了 / 已被别处处理 —— 如实告知,不假装成功(§6 ①)
1335
+ await this.reply(from, `没能替你完成这个选择(${ans.error || "可能已经过期或被处理"})。任务那边已按"未批准"继续处理了,请到电脑上确认。`);
1336
+ await this.#resurfaceNext(from); // 这条废了也要把下一条顶到底部,别让用户往上翻
1337
+ return;
1338
+ }
1339
+ await this.reply(from, `已按你的选择处理:${option.label || digits}。`);
1340
+ // ★ 还有待拍板的 → 把下一条重发到底部,用户直接在最新那条上回数字即可。
1341
+ // (不重发的话用户得往上翻找哪条没答 —— 翻错就是"答错对象",正是业主报的那个 bug 的成因。)
1342
+ await this.#resurfaceNext(from);
1343
+ }
1344
+
1345
+ async reply(to, text) {
1346
+ if (!this.channel.account || !to) return false;
1347
+ // ★ 文本**必须是字符串**。历史上 `#helpText()` 对付费档直接 `return HELP_TEXT` ——
1348
+ // 那是个**数组**,而 `String(数组)` 会按逗号拼接、**一个换行都没有**:
1349
+ // 用户实测到的正是「/help 回一坨,没有换行也没有编号」。数组一律按行拼接,
1350
+ // 让"想给多行却给了数组"退化成**正确**的多行文本,而不是一坨。
1351
+ const body = Array.isArray(text) ? text.join("\n") : String(text == null ? "" : text);
1352
+ if (!body) return false;
1353
+ try {
1354
+ await this.channel.client.sendMessage({ to, text: body });
1355
+ this.channel.markPush(true);
1356
+ return true;
1357
+ } catch (e) {
1358
+ this.#noteFailure(`sendmessage 失败:${redact(String(e && e.message ? e.message : e))}`);
1359
+ return false;
1360
+ }
1361
+ }
1362
+
1363
+ #noteFailure(text) {
1364
+ this.channel.writeState({ last_error: String(text).slice(0, 200) });
1365
+ this.logger.warn(`[wechat] ${text}`);
1366
+ }
1367
+
1368
+ // ── 出站:DSH 事件 → 微信通知 ───────────────────────────────────────────
1369
+
1370
+ #startSubscriber() {
1371
+ // ⚠️ 建订阅器与挂事件处理器必须**分开**:早先把 on(...) 全写在 `if (subscriber) return;`
1372
+ // 之后,于是任何**预先注入**的订阅器都拿不到任何 handler —— 表现为"事件来了却什么都不发生",
1373
+ // 而且完全静默。现在无论订阅器是新建还是注入,都保证挂上处理器(WeakSet 去重,可重复调用)。
1374
+ if (!this.subscriber) {
1375
+ this.subscriber = createEventSubscriber({
1376
+ upstream: this.upstream,
1377
+ cookie: () => this.cookieOf(),
1378
+ follow: true,
1379
+ log: (level, message, meta) => {
1380
+ try { this.logger[level === "warn" ? "warn" : level === "error" ? "warn" : "info"](`[wechat/events] ${message}`, meta); } catch { /* 忽略 */ }
1381
+ }
1382
+ });
1383
+ }
1384
+ this.#wireSubscriber(this.subscriber);
1385
+ if (typeof this.subscriber.start === "function") this.subscriber.start();
1386
+ }
1387
+
1388
+ #wireSubscriber(sub) {
1389
+ this.wired = this.wired || new WeakSet();
1390
+ if (!sub || this.wired.has(sub)) return;
1391
+ this.wired.add(sub);
1392
+
1393
+ sub.on("event", (node) => {
1394
+ this.notify(node).catch((e) => this.logger.warn(`[wechat] 通知发送失败:${redact(String(e && e.message ? e.message : e))}`));
1395
+ });
1396
+ sub.on("gap", (info) => {
1397
+ // §6 ②:事件不重放 —— 断线窗口内的通知**漏了就是漏了**,必须如实说,不能装作没事。
1398
+ this.logger.warn(`[wechat/events] 订阅断线窗口 ${info && info.from ? new Date(info.from).toISOString() : "?"} → ${info && info.to ? new Date(info.to).toISOString() : "?"},该窗口内的通知不可补发`);
1399
+ this.#tellGap().catch(() => {});
1400
+ });
1401
+ sub.on("auth-error", (info) => {
1402
+ this.logger.warn(`[wechat/events] 鉴权失败(${info && info.surface}):需要刷新 harness cookie`);
1403
+ });
1404
+ }
1405
+
1406
+ async #tellGap() {
1407
+ if (!this.channel.account) return;
1408
+ const to = this.channel.account.userId;
1409
+ if (!to) return;
1410
+ await this.reply(to, "⚠️ 通知通道刚才断过线。这段时间里的提醒可能没有发给你(微信侧不会补发),如果有正在跑的任务,建议到电脑上看一眼。");
1411
+ }
1412
+
1413
+ /**
1414
+ * 把一条**事件**节点发到微信。可回执的登记进待答队列。
1415
+ * 公开方法:bridge 与测试都直接调用它(不叫 #notify 是因为它是本模块的主要出口之一)。
1416
+ */
1417
+ async notify(eventNode) {
1418
+ if (!eventNode || !this.channel.account) return { ok: false, reason: "not_bound" };
1419
+ const acct = this.channel.account;
1420
+
1421
+ // 免打扰:只放行"需要你决定"的与"报错"。用**事件侧**的 kind 判,不是文案侧的。
1422
+ const state = loadState(this.relayDir);
1423
+ if (state.quiet) {
1424
+ const important = [
1425
+ NODE_KINDS.APPROVAL_REQUEST,
1426
+ NODE_KINDS.USER_QUESTION,
1427
+ NODE_KINDS.PLAN_REVIEW,
1428
+ NODE_KINDS.SESSION_ERROR
1429
+ ].includes(eventNode.kind);
1430
+ if (!important) return { ok: false, reason: "quiet" };
1431
+ }
1432
+
1433
+ // 没有文案模板的节点:按设计另行处理,不是漏接线
1434
+ if (SPECIAL_EVENT_KINDS.includes(eventNode.kind)) {
1435
+ if (eventNode.kind === NODE_KINDS.EVENT_EXPIRED) {
1436
+ // §6 ①:过期要**明确告诉用户**,不能沉默 —— 用户以为还没过期最危险。
1437
+ await this.reply(acct.userId, "⌛ 刚才那条需要你决定的事已经过期了,DSH 已按「未批准」继续处理。如果还要做,请到电脑上重新发起。");
1438
+ }
1439
+ // gap 由 subscriber 的 'gap' 事件单独处理;fault 只记日志,不打扰用户。
1440
+ return { ok: false, reason: `special:${eventNode.kind}` };
1441
+ }
1442
+
1443
+ const { node: formatted, formatterKind } = toFormatterNode(eventNode);
1444
+ if (!formatted) {
1445
+ this.logger.warn(`[wechat] 未接线的节点 kind=${eventNode.kind}(消息未发出)`);
1446
+ return { ok: false, reason: `unmapped:${eventNode.kind}` };
1447
+ }
1448
+
1449
+ // 日报要能回答"今天干了啥" → 在既有的 notified/answered 之外,再记完成与失败。
1450
+ // (owner 口径:日报不该是"今天要做啥"的清单,而是"今天做了什么"的回顾)
1451
+ if (formatterKind === "stopped") {
1452
+ this.#bumpToday(String(eventNode.reason || "") === "completed" ? "completed" : "failed");
1453
+ }
1454
+
1455
+ // ── v2 富化 ────────────────────────────────────────────────────────────
1456
+ // 两条 v2 规则在这里落地,顺序有讲究:先判"完成推送要富化",再判"审批要不要降级为
1457
+ // 只能回电脑确认"。两者都**不走**通用模板,所以放在 buildOutboundNotification 之前。
1458
+ let built;
1459
+
1460
+ if (formatterKind === "stopped") {
1461
+ // 完成任务不能只推「任务已停止」—— 要带**会话名称 + 结论**,让用户不用打开电脑就知道结果。
1462
+ const sid = eventNode.sessionId || this.currentSessionId || "";
1463
+ let title = this.currentSessionTitle || "";
1464
+ if (sid) {
1465
+ const list = await this.#sessions();
1466
+ const hit = list ? list.find((s) => s.sessionId === sid) : null;
1467
+ if (hit && hit.title) title = hit.title;
1468
+ // 学到标题就记住,后面 /status 与 /ls 都能直接用
1469
+ if (hit && sid === this.currentSessionId && title !== this.currentSessionTitle) {
1470
+ this.#rememberSession(sid, title);
1471
+ }
1472
+ }
1473
+ const summary = sid ? await this.#sessionSummary(sid) : "";
1474
+ const c = formatCompletion({
1475
+ title,
1476
+ reason: eventNode.reason,
1477
+ summary,
1478
+ sessionId: sid,
1479
+ hanging: !summary
1480
+ // 判据是 **assign.new**(能不能开新任务),不是粗粒度 assign:
1481
+ // 后台只勾细粒度 assign.new 时,粗根判假 → 会给一个**能派活**的用户塞"接着交代属于会员功能"
1482
+ }, this.#can("assign.new") ? {} : { continuationHint: this.#continuationHint() });
1483
+ built = { text: c.text, replyable: false, eventId: "" };
1484
+ } else if (
1485
+ formatterKind === "approval" &&
1486
+ // ★ 安全边界:破坏性操作**不给一步回执**。加了 session/prompt 之后,这条通道能驱动
1487
+ // agent 改文件/跑命令,手机上点一下太便宜;必须回电脑上确认。
1488
+ isDestructiveTool(eventNode.toolName || formatted.tool, eventNode.reason || formatted.detail)
1489
+ ) {
1490
+ const tool = eventNode.toolName || formatted.tool || "(未提供)";
1491
+ const detail = String(eventNode.reason || formatted.detail || "").trim();
1492
+ // 现在被降级只有两种原因,文案要分别说清是**哪一种**(否则用户不知道该防什么):
1493
+ // · 有内容且命中破坏性模式 → 是这一步的**内容**危险;
1494
+ // · 内容为空但降级了 → 是**这个工具本身**属于删除/覆盖类(按工具名判的)。
1495
+ // ⚠️ 「看不到内容」不再降级(业主拍板:DSH 自身有权限控制,不要过严),所以这里
1496
+ // 不会出现"因为看不见所以不给点"的说法 —— 那样说会与实现不一致。
1497
+ built = {
1498
+ text: [
1499
+ "【需要你到电脑上确认】",
1500
+ `工具: ${tool}`,
1501
+ detail ? `原因: ${detail}` : "",
1502
+ "",
1503
+ detail
1504
+ ? "这一步的内容被判定为破坏性操作(删除 / 强推 / 覆盖等),不能从微信里一键放行。"
1505
+ : "这个工具本身属于删除 / 覆盖类,不能从微信里一键放行。",
1506
+ "请到电脑上确认;不处理的话 DSH 会按「未批准」继续。"
1507
+ ].filter(Boolean).join("\n"),
1508
+ replyable: false,
1509
+ eventId: ""
1510
+ };
1511
+ } else {
1512
+ built = buildOutboundNotification(formatted, this.channel.registry, this.#notifyOpts());
1513
+ }
1514
+
1515
+ if (!built || !built.text) return { ok: false, reason: "no_text" };
1516
+
1517
+ // 已经还有别的待拍板 → 明确说"不止这一条"。不说的话用户会以为答完就结束了,
1518
+ // 后面几条就变成"没人回应"(业主报的那个 bug 的后半段现象)。
1519
+ // ⚠️ 数字必须取**发送前**的快照:push 之后它会包含这一条自己。
1520
+ // ⚠️ 只数**决策**:提醒类(额度/会员到期)不占拍板位,更不该在提醒里说"你还有 N 条待拍板"。
1521
+ const isNotice = NOTICE_FORMATTER_KINDS.includes(formatterKind);
1522
+ const othersWaiting = built.replyable && built.eventId && !isNotice ? this.#decisionCount() : 0;
1523
+ const outgoing = othersWaiting > 0
1524
+ ? `${built.text}\n\n(你还有 ${othersWaiting + 1} 条待拍板,回完这条我会把下一条发到下面)`
1525
+ : built.text;
1526
+
1527
+ const sent = await this.reply(acct.userId, outgoing);
1528
+ if (sent && built.replyable && built.eventId) {
1529
+ this.pendingReplies.push({
1530
+ eventId: built.eventId,
1531
+ kind: formatterKind,
1532
+ answerShape: eventNode.answerShape || (formatterKind === "approval" ? "approval" : "question"),
1533
+ node: eventNode,
1534
+ // ★ 提醒类不参与 FIFO 认领(只有一条决策都没有时才会接数字),见 NOTICE_FORMATTER_KINDS
1535
+ notice: isNotice,
1536
+ at: this.clock()
1537
+ });
1538
+ // 队列上限:只保留最近若干条待答,避免无限增长
1539
+ while (this.pendingReplies.length > 20) this.pendingReplies.shift();
1540
+ this.#bumpToday("notified");
1541
+ }
1542
+ return { ok: sent, eventId: built.eventId || "" };
1543
+ }
1544
+
1545
+ /** 供 bridge / 账户轮询调用:P1 的额度提醒(挂双路钩子)。 */
1546
+ async notifyQuotaLow(detail = {}) {
1547
+ if (!this.subscriber) return { ok: false, reason: "not_running" };
1548
+ return this.notify(this.subscriber.quotaLow(detail));
1549
+ }
1550
+
1551
+ /** 供 bridge / 账户轮询调用:P1 的会员过期提醒(挂续费/带新用户双路钩子)。 */
1552
+ async notifyMembershipExpiring(detail = {}) {
1553
+ if (!this.subscriber) return { ok: false, reason: "not_running" };
1554
+ return this.notify(this.subscriber.membershipExpiring(detail));
1555
+ }
1556
+
1557
+ // ── 每日简报(兼作 24h 推送窗口心跳,§7) ────────────────────────────────
1558
+
1559
+ #startDigestTimer() {
1560
+ if (this.digestTask) return;
1561
+ // 每分钟检查一次:① 是否到了今天的简报时刻且今天还没发过 ② 会员是否临近到期
1562
+ this.digestTask = setInterval(() => {
1563
+ this.#maybeDigest().catch(() => {});
1564
+ this.#maybeRemindExpiry().catch(() => {});
1565
+ }, 60_000);
1566
+ if (this.digestTask.unref) this.digestTask.unref();
1567
+ }
1568
+
1569
+ /**
1570
+ * 会员/试用临近到期的提醒(提前 2 天、提前 1 天、到期当天)。
1571
+ *
1572
+ * ⚠️ 这条**以前根本不存在**:`notifyMembershipExpiring()` 只有定义、全仓零调用方 ——
1573
+ * 业主以为"提前两天发、提前一天也发",实际一次都没发过。这里补上唯一的触发点。
1574
+ *
1575
+ * 数据来源:`opts.accountInfo`(由 bridge 注入,复用它查档位时那次 `/api/me`,不额外打网络)。
1576
+ * 拿不到就**什么都不发** —— 宁可漏发一次,也不要拿"猜的到期日"去骚扰用户。
1577
+ * 每个阈值**一天只发一次**(落盘去重),避免一天里反复提醒。
1578
+ */
1579
+ async #maybeRemindExpiry() {
1580
+ if (this.stopping || !this.channel.account || !this.subscriber) return;
1581
+ if (typeof this.accountInfo !== "function") return;
1582
+ const now = this.clock();
1583
+ let info = null;
1584
+ try { info = await this.accountInfo(); } catch { info = null; }
1585
+ if (!info || typeof info !== "object") return;
1586
+ const endsAt = Number(info.plan_ends_at || info.trial_expires_at || 0);
1587
+ if (!Number.isFinite(endsAt) || endsAt <= 0) return;
1588
+ const days = Math.ceil((endsAt - now) / 86_400_000);
1589
+ // 只在这三个节点提醒:2 天 / 1 天 / 已到期(<=0)。其余日子保持安静。
1590
+ const slot = days <= 0 ? "expired" : days === 1 ? "1" : days === 2 ? "2" : "";
1591
+ if (!slot) return;
1592
+ const key = `${this.#today(now)}:${slot}`;
1593
+ const state = loadState(this.relayDir);
1594
+ if (state.last_membership_notice === key) return; // 这个阈值今天已提醒过
1595
+ const r = await this.notifyMembershipExpiring({
1596
+ state: slot === "expired" ? "expired" : "expiring",
1597
+ days: Math.max(0, days),
1598
+ plan: String(info.plan || "")
1599
+ });
1600
+ // 只有真发出去了才记账(与简报同一个道理:不能把没发出去的当已发)
1601
+ if (r && r.ok) this.channel.writeState({ last_membership_notice: key });
1602
+ }
1603
+
1604
+ #today(now = this.clock()) {
1605
+ const d = new Date(now);
1606
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
1607
+ }
1608
+
1609
+ /**
1610
+ * 每月消息额度「快用完了」的提醒(这是**唯一的**触发点)。
1611
+ *
1612
+ * ⚠️ 这条**以前根本不存在**:`notifyQuotaLow()` 只有定义、全仓零调用方 ——
1613
+ * 于是用户永远是在额度**归零那一刻**才撞上冷冰冰的拒绝,事先毫无预告。
1614
+ * 与 `#maybeRemindExpiry()` 同一套模式(落盘去重 + 只有真发出去了才记账),
1615
+ * 调用点也照它那样 fire-and-forget(见 `#cmdNew` / `#sendToSession`)。
1616
+ *
1617
+ * 触发口径(两个条件取**较宽松**的那个,谁先到用谁 —— 别让用户到 0 才收到):
1618
+ * · 已用 ≥ 80% ;或 · 剩余 ≤ 2 条
1619
+ * 频控:**每个自然月最多一次**(`#monthKey()` 落盘去重)。每发一条就念一遍额度是骚扰,
1620
+ * 而且微信里挡住消息的正是额度本身 —— 提醒的价值在"提前预告",不是"复读"。
1621
+ *
1622
+ * ⚠️ `messages_per_month` 为 0/缺省 = **不限**(付费档就是 0):必须**直接返回**,
1623
+ * 否则付费用户会被按月念叨"你额度快用完了"(他没有额度这回事)。
1624
+ * ⚠️ 提醒本身**不消耗**消息额度:它不走 `#msgQuotaBump()`,也不经 `handleInbound`
1625
+ * (那是"派活"的入口)—— 发提醒绝不能让用户的余额更少。
1626
+ */
1627
+ async #maybeRemindQuota() {
1628
+ if (this.stopping || !this.channel.account || !this.subscriber) return;
1629
+ const limit = Number((this.#limits() || {}).messages_per_month || 0);
1630
+ // 0 / 缺省 / 非法 = 不限 → 永不提醒(付费用户不该被念额度)
1631
+ if (!Number.isFinite(limit) || limit <= 0) return;
1632
+ const month = this.#monthKey();
1633
+ const used = this.msgUsage && this.msgUsage.month === month ? Number(this.msgUsage.count || 0) : 0;
1634
+ if (used <= 0) return; // 一条都还没发过:没有"快用完"这回事
1635
+ const remain = Math.max(0, limit - used);
1636
+ // 阈值 = min(80% 处, 剩余 2 条处)。`Math.max(1, …)` 保证不会在第 0 条就触发。
1637
+ const threshold = Math.min(Math.ceil(limit * 0.8), Math.max(1, limit - 2));
1638
+ if (used < threshold) return;
1639
+ const state = loadState(this.relayDir);
1640
+ if (state.last_quota_notice === month) return; // 这个自然月已经提醒过
1641
+ const r = await this.notifyQuotaLow({ state: "low", message: this.#quotaLowText(limit, used, remain) });
1642
+ // 只有真发出去了才记账(与简报/到期提醒同一个道理:没发出去的不能算已发)
1643
+ if (r && r.ok) this.channel.writeState({ last_quota_notice: month });
1644
+ }
1645
+
1646
+ /**
1647
+ * 额度提醒正文。**只讲事实 + 与该用户实际能力一致的引导**(契约 §8 红线 1:不多承诺)。
1648
+ *
1649
+ * ⚠️ 升级引导必须**看 caps**,不能背一句固定的"开通会员即可用全部功能":
1650
+ * 能力是后台按档位配的(契约 §2),写死的话术会承诺 `caps` 里根本没有的能力。
1651
+ * 这里的做法是:只有在用户**确实还不具备**开新任务能力(`assign`)时才提示"这是会员能力",
1652
+ * 其余情况只给 App 入口、不描述任何能力 —— 陈述少一句,总比多承诺一句强。
1653
+ */
1654
+ #quotaLowText(limit, used, remain) {
1655
+ const lines = [
1656
+ `本月 ${limit} 条消息额度已用 ${used} 条,还剩 ${remain} 条。`,
1657
+ `额度按自然月计算,下个月 1 号自动归零(重新给满 ${limit} 条)。`
1658
+ ];
1659
+ if (!this.#can("assign.new")) {
1660
+ // 按**实际缺的那一项**说,不笼统说"派活是会员能力":
1661
+ // 还能接着当前会话聊的人被这么说,会以为自己在微信里什么都做不了 ——
1662
+ // 少承诺同样是一种"话术与权限不一致"。
1663
+ lines.push("", this.#can("assign.continue")
1664
+ ? "在微信里开新任务属于会员能力;接着当前任务回复仍然可用(你能用的能力以 App 内展示为准)。"
1665
+ : "在微信里直接派活属于会员能力;开通后可继续使用(你能用的能力以 App 内展示为准)。");
1666
+ }
1667
+ if (this.appUrl) lines.push("", `👉 打开 App:${this.appUrl}`);
1668
+ return lines.join("\n");
1669
+ }
1670
+
1671
+ #bumpToday(field = "notified") {
1672
+ const day = this.#today();
1673
+ if (this.todayStats.day !== day) {
1674
+ this.todayStats = { day, notified: 0, answered: 0, completed: 0, failed: 0 };
1675
+ }
1676
+ this.todayStats[field] = (this.todayStats[field] || 0) + 1;
1677
+ }
1678
+
1679
+ /**
1680
+ * 每日简报(兼作 24h 推送窗口的心跳)。
1681
+ *
1682
+ * ⚠️ 旧实现有三个问题,一起修了:
1683
+ * ① 只把 `{notified, answered}` 交给下游,**没有 lines** → 用户每天收到的是兜底句
1684
+ * 「今天暂时没有要做的事。」—— 那不是"今天干了啥",等于白发(业主:"日报发的内容有点问题")。
1685
+ * ② 先把 `last_digest_day` 落盘**再**发送 → 一旦这次没发出去(网络抖动/节点被权限关掉),
1686
+ * 当天的简报就被**烧掉**、当天再也不会补发。
1687
+ * ③ 「读 state → 发送 → 写 state」是一段**跨 await 的读-改-写**:发送要等网络,
1688
+ * 这段窗口里第二个执行者会读到"今天还没发"→ **两条都发出去**
1689
+ * (2026-09-23 真机:业主收到两条简报)。现在用**原子占位**把"今天这条归我发"锁死。
1690
+ */
1691
+ async #maybeDigest() {
1692
+ if (this.stopping || !this.channel.account || !this.subscriber) return;
1693
+ const now = this.clock();
1694
+ const hour = new Date(now).getHours();
1695
+ if (hour !== this.digestHour) return;
1696
+ const day = this.#today(now);
1697
+ const state = loadState(this.relayDir);
1698
+ if (state.last_digest_day === day) return; // 今天已发
1699
+ // ★ 先原子占位再发送:两个实例/两次 tick 并存时,只有一个能拿到(见 #claimDigest)
1700
+ const claim = this.#claimDigest(day);
1701
+ if (!claim.ok) return;
1702
+ const s = this.todayStats;
1703
+ const lines = this.#digestLines();
1704
+ const node = this.subscriber.digestDue({ lines, notified: s.notified, answered: s.answered });
1705
+ let r = null;
1706
+ try {
1707
+ r = await this.notify(node);
1708
+ } finally {
1709
+ // 没真发出去 → 释放占位,留给下一 tick 补发(见上面 ②:不能把当天烧掉)
1710
+ if (!(r && r.ok)) this.#releaseDigest(claim);
1711
+ }
1712
+ // ★ 只有**真的发出去了**才记"今天已发"(见上面 ②)
1713
+ if (r && r.ok) this.channel.writeState({ last_digest_day: day });
1714
+ }
1715
+
1716
+ /**
1717
+ * 原子占位「今天这条简报由我来发」。
1718
+ *
1719
+ * 🔴 为什么光看 state 里的 `last_digest_day` 不够:那是**读-改-写**,中间隔着一次网络发送。
1720
+ * 典型撞车场景有两个,都真实发生过:
1721
+ * · 更新期间新旧 bridge **短暂共存**(旧的还没退、新的已起);
1722
+ * · 一次发送耗时超过 60s,下一次定时器 tick **叠**上来。
1723
+ * 两者都会让第二个执行者在写盘之前读到"今天还没发" → 用户收到两条。
1724
+ * `openSync(..., "wx")`(不存在才创建)由内核保证原子,只有一个执行者能拿到 ✅
1725
+ *
1726
+ * 生命期:发成功 → 占位**留着**(它比 state 更强,重启/换实例也不会丢);
1727
+ * 发失败 → 立刻由 `#releaseDigest` 释放,让下一 tick 补发;
1728
+ * 占位超过 10 分钟 = **陈旧**(进程在发送途中被杀)→ 接管它。
1729
+ * 宁可极小概率重发一次,也不要出现"那天彻底不发了"。
1730
+ */
1731
+ #claimDigest(day) {
1732
+ const file = path.join(this.relayDir, `.digest-${day}.sent`);
1733
+ const STALE_MS = 10 * 60_000;
1734
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1735
+ try {
1736
+ fs.closeSync(fs.openSync(file, "wx"));
1737
+ this.#pruneDigestClaims(day);
1738
+ return { ok: true, file };
1739
+ } catch (e) {
1740
+ if (!e || e.code !== "EEXIST") {
1741
+ // 目录不可写等:退回"这次不发",绝不因此把 bridge 打挂(简报是锦上添花)
1742
+ return { ok: false, file };
1743
+ }
1744
+ let age = 0;
1745
+ try { age = Date.now() - fs.statSync(file).mtimeMs; } catch { age = 0; }
1746
+ if (age < STALE_MS) return { ok: false, file }; // 别人正在发 / 今天已经发过
1747
+ try { fs.rmSync(file, { force: true }); } catch { return { ok: false, file }; }
1748
+ // 删掉陈旧占位后重抢一次
1749
+ }
1750
+ }
1751
+ return { ok: false, file };
1752
+ }
1753
+
1754
+ /** 发送失败时释放占位(交给下一 tick 补发)。 */
1755
+ #releaseDigest(claim) {
1756
+ if (!claim || !claim.file) return;
1757
+ try { fs.rmSync(claim.file, { force: true }); } catch { /* ignore */ }
1758
+ }
1759
+
1760
+ /** 清掉别的日期留下的占位文件(一天一个,不清理会一年攒 365 个)。 */
1761
+ #pruneDigestClaims(today) {
1762
+ try {
1763
+ for (const name of fs.readdirSync(this.relayDir)) {
1764
+ if (!name.startsWith(".digest-") || !name.endsWith(".sent")) continue;
1765
+ if (name === `.digest-${today}.sent`) continue;
1766
+ try { fs.rmSync(path.join(this.relayDir, name), { force: true }); } catch { /* ignore */ }
1767
+ }
1768
+ } catch { /* 目录读不了就算了:占位本身仍生效 */ }
1769
+ }
1770
+
1771
+ /** 简报正文:回答"**今天**干了啥"(业主口径:日报是回顾,不是待办清单)。 */
1772
+ #digestLines() {
1773
+ const s = this.todayStats;
1774
+ const lines = [];
1775
+ if (s.completed) lines.push(`· 完成任务 ${s.completed} 个`);
1776
+ if (s.failed) lines.push(`· 出错或中断 ${s.failed} 个`);
1777
+ if (s.notified) lines.push(`· 推送通知 ${s.notified} 条`);
1778
+ if (s.answered) lines.push(`· 你在微信里拍板 ${s.answered} 次`);
1779
+ if (!lines.length) lines.push("· 今天这台电脑上没有跑任务。");
1780
+ return lines;
1781
+ }
1782
+
1783
+ /**
1784
+ * 供测试驱动**真实**的简报路径(含"今天是否已发"与"只有发送成功才记账"两条判断)。
1785
+ * 与 `runDigestNow` 的区别:后者是运维手动补发、故意绕过这两条判断;这里用于验证它们。
1786
+ */
1787
+ async runMaybeDigestNow() {
1788
+ return this.#maybeDigest();
1789
+ }
1790
+
1791
+ /** 供测试/运维手动触发一次"到期提醒"检查(绕过定时器)。与 runDigestNow 同一目的。 */
1792
+ async runExpiryRemindNow() {
1793
+ return this.#maybeRemindExpiry();
1794
+ }
1795
+
1796
+ /**
1797
+ * 供测试/运维手动触发一次"额度将尽"检查。生产路径是**派活成功之后**自动调用
1798
+ * (见 `#cmdNew` / `#sendToSession`);单测要确定性地验它,所以单独开一个口子 ——
1799
+ * 与 `runExpiryRemindNow` / `runDigestNow` 同一目的。
1800
+ */
1801
+ async runQuotaRemindNow() {
1802
+ return this.#maybeRemindQuota();
1803
+ }
1804
+
1805
+ /**
1806
+ * 供测试/运维手动触发一次简报(绕过时刻判断)。
1807
+ * 生产路径走 #maybeDigest 的定时器;单测不能等一天,所以单独开一个口子。
1808
+ */
1809
+ async runDigestNow(extra = {}) {
1810
+ if (!this.subscriber) return { ok: false, reason: "not_running" };
1811
+ const node = this.subscriber.digestDue({
1812
+ lines: this.#digestLines(),
1813
+ notified: this.todayStats.notified,
1814
+ answered: this.todayStats.answered,
1815
+ ...extra
1816
+ });
1817
+ return this.notify(node);
1818
+ }
1819
+
1820
+ // ── 绑定控制面 ──────────────────────────────────────────────────────────
1821
+
1822
+ /** 开始绑定:取二维码 → 返回面板可直接 <img src> 的 data URL。 */
1823
+ /**
1824
+ * 本机是否已登录账号。
1825
+ * 判据:`.dsh-config.json` 里有 `phone` —— 该字段由面板登录成功后写入(安装器的 setup
1826
+ * 也会写)。读不到/损坏一律当**未登录**,宁可多要一次登录,也不能在未登录时放开遥控能力。
1827
+ */
1828
+ #hasAccount() {
1829
+ try {
1830
+ const cfg = readJson(path.join(this.relayDir, ".dsh-config.json"));
1831
+ return !!(cfg && typeof cfg.phone === "string" && cfg.phone.trim());
1832
+ } catch {
1833
+ return false;
1834
+ }
1835
+ }
1836
+
1837
+ async beginBind() {
1838
+ if (this.disabled) return { ok: false, error: "微信通道已禁用" };
1839
+ if (this.channel.account) return { ok: false, error: "已经绑定过了;如需更换请先解绑" };
1840
+ // ★ 权限门槛(业主拍板:必须注册登录后才能用微信机器人)。
1841
+ // 判据是**本机配置里有账号**(登录后才会写入)。放在最前面:没登录就连二维码都不给,
1842
+ // 而不是"给二维码但绑上用不了"——后者会让用户白扫一次,体验更差。
1843
+ // bridge 侧这道闸与面板侧"未登录不显示 tab"是双保险(bridge 可能被别的客户端调)。
1844
+ if (!this.#hasAccount()) {
1845
+ return { ok: false, error: "请先在「远程访问」面板登录账号,再连接微信机器人。", code: "login_required" };
1846
+ }
1847
+ this.cancelBind();
1848
+ try {
1849
+ const started = await startBind({
1850
+ client: this.channel.client,
1851
+ logger: this.logger,
1852
+ timeoutMs: this.bindTtlMs
1853
+ });
1854
+ this.bind = {
1855
+ started,
1856
+ createdAt: this.clock(),
1857
+ needVerifyCode: false,
1858
+ done: false,
1859
+ result: null,
1860
+ error: ""
1861
+ };
1862
+ // 进入 need_verifycode 时置位,让面板弹输入框(§3 状态机)
1863
+ started.session.onNeedVerifyCode = () => { if (this.bind) this.bind.needVerifyCode = true; };
1864
+ return {
1865
+ ok: true,
1866
+ qrcode_svg: started.qrcodeSvg || "",
1867
+ qrcode_url: started.qrcodeUrl || "",
1868
+ message: started.message
1869
+ };
1870
+ } catch (e) {
1871
+ const error = redact(String(e && e.message ? e.message : e));
1872
+ this.logger.warn(`[wechat] 取二维码失败:${error}`);
1873
+ return { ok: false, error };
1874
+ }
1875
+ }
1876
+
1877
+ /**
1878
+ * 推进绑定状态机一步。面板轮询调用。
1879
+ *
1880
+ * ⚠️ 必须严格按 `BindSession.next()` 的**真实返回契约**判分支 —— 这里踩过一次大坑:
1881
+ * 早先我按 `{state:"confirmed", account:{…}}` 读,而实际上 `next()` 的返回是
1882
+ * · 成功 `{ok:true, alreadyBound:false, token, accountId, baseUrl, userId, message}`(**没有 state/account**)
1883
+ * · 待续 `{ok:false, pending:true, status:"wait"|"scaned"|"unknown"}`
1884
+ * · 要码 `{ok:false, verifyNeeded:true, status:"need_verifycode", attempt}`
1885
+ * · 失败 `{ok:false, code, message}`(来自 fail())
1886
+ * 于是 `state` 恒为 "wait"、成功分支永不命中 —— **用户永远绑不上**,而且没有任何报错线索。
1887
+ * 是端到端绑定流程测试抓到的(wechat-e2e.test.mjs)。
1888
+ */
1889
+ async pollBind() {
1890
+ if (!this.bind) return { ok: true, state: "idle", bound: !!this.channel.account };
1891
+ if (this.bind.done) {
1892
+ return { ok: true, state: this.bind.result ? "confirmed" : "failed", bound: !!this.channel.account, error: this.bind.error };
1893
+ }
1894
+ let step;
1895
+ try {
1896
+ step = await this.bind.started.next();
1897
+ } catch (e) {
1898
+ this.bind.error = redact(String(e && e.message ? e.message : e));
1899
+ this.bind.done = true;
1900
+ return { ok: false, state: "failed", error: this.bind.error };
1901
+ }
1902
+ if (!step || typeof step !== "object") {
1903
+ return { ok: true, state: "wait", need_verify_code: !!this.bind.needVerifyCode, bound: false };
1904
+ }
1905
+
1906
+ // 1) 成功:token 是最硬的判据(没有 token 什么都做不了)
1907
+ if (step.ok === true && typeof step.token === "string" && step.token) {
1908
+ this.channel.adoptConfirmed({
1909
+ token: step.token,
1910
+ accountId: step.accountId,
1911
+ baseUrl: step.baseUrl,
1912
+ userId: step.userId
1913
+ });
1914
+ this.bind.done = true;
1915
+ this.bind.result = true;
1916
+ await this.startChannel(); // 绑定成功即上线,面板随后就能看到"已绑定"
1917
+ return { ok: true, state: "confirmed", bound: true };
1918
+ }
1919
+ // 2) 该 bot 之前已绑过 → 服务端不再下发凭据,但语义上是成功
1920
+ if (step.ok === true && step.alreadyBound === true) {
1921
+ this.bind.done = true;
1922
+ this.bind.result = true;
1923
+ return { ok: true, state: "already_bound", bound: !!this.channel.account };
1924
+ }
1925
+ // 3) 要配对码(必须每次表面化,否则用户第二次不会再看到输入框)
1926
+ if (step.verifyNeeded === true) {
1927
+ this.bind.needVerifyCode = true;
1928
+ return { ok: true, state: "need_verifycode", need_verify_code: true, bound: false };
1929
+ }
1930
+ // 4) 终态失败:带 code 且不再 pending
1931
+ if (step.ok === false && step.pending !== true && typeof step.code === "string" && step.code) {
1932
+ this.bind.done = true;
1933
+ this.bind.error = step.message || step.code;
1934
+ return { ok: false, state: "failed", code: step.code, error: this.bind.error };
1935
+ }
1936
+ // 5) 仍在推进(wait / scaned / unknown)
1937
+ this.bind.needVerifyCode = false;
1938
+ return {
1939
+ ok: true,
1940
+ state: typeof step.status === "string" ? step.status : "wait",
1941
+ need_verify_code: false,
1942
+ bound: false
1943
+ };
1944
+ }
1945
+
1946
+ /** 提交手机微信上显示的数字配对码。 */
1947
+ submitVerifyCode(code) {
1948
+ const c = String(code == null ? "" : code).trim();
1949
+ if (!/^[0-9]{1,8}$/.test(c)) return { ok: false, error: "请输入手机微信上显示的数字" };
1950
+ if (!this.bind) return { ok: false, error: "当前没有进行中的绑定" };
1951
+ const ok = this.bind.started.submitVerifyCode(c);
1952
+ if (ok) this.bind.needVerifyCode = false;
1953
+ return ok ? { ok: true } : { ok: false, error: "配对码未被接受,请重试" };
1954
+ }
1955
+
1956
+ cancelBind() {
1957
+ if (!this.bind) return { ok: true, cancelled: false };
1958
+ try { if (this.bind.started.session && this.bind.started.session.cancel) this.bind.started.session.cancel(); } catch { /* 忽略 */ }
1959
+ this.bind = null;
1960
+ return { ok: true, cancelled: true };
1961
+ }
1962
+
1963
+ /** 解绑:停轮询 → notifystop → 删凭据 → 状态置未绑定。 */
1964
+ async unbind() {
1965
+ await this.stopChannel();
1966
+ const r = await this.channel.unbind();
1967
+ this.pendingReplies = [];
1968
+ this.subscriber = null;
1969
+ // 允许重新绑定
1970
+ this.stopping = false;
1971
+ return { ok: true, notify_error: r.notifyError || "" };
1972
+ }
1973
+ }
1974
+
1975
+ export function createWeChatRuntime(opts) {
1976
+ return new WeChatRuntime(opts);
1977
+ }
1978
+
1979
+ /** 读控制面发现文件(插件宿主半边用)。 */
1980
+ export function readControlFile(relayDir) {
1981
+ return readJson(path.join(relayDir, CONTROL_FILE));
1982
+ }
1983
+
1984
+ export { saveState, loadState, SessionCooldown };