@mrrisega/dsh-remote 0.7.12-beta.1 → 0.7.12-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 (25) hide show
  1. package/clients/dsh-remote/dingtalk-bot.mjs +1318 -0
  2. package/clients/dsh-remote/docs/feishu-bot-contract.md +177 -0
  3. package/clients/dsh-remote/dsh-bridge.mjs +398 -86
  4. package/clients/dsh-remote/feishu-bot.mjs +1668 -0
  5. package/clients/dsh-remote/mirror-diag-script.js +53 -14
  6. package/clients/dsh-remote/mobile-adapter.mjs +355 -40
  7. package/clients/dsh-remote/test/bridge-http-abort.test.mjs +143 -0
  8. package/clients/dsh-remote/test/bridge-slow-response.test.mjs +107 -0
  9. package/clients/dsh-remote/test/bridge-tunnel-receiver.test.mjs +135 -0
  10. package/clients/dsh-remote/test/dingtalk-bot.test.mjs +550 -0
  11. package/clients/dsh-remote/test/feishu-bot.test.mjs +839 -0
  12. package/clients/dsh-remote/test/mirror-diag.test.mjs +47 -2
  13. package/clients/dsh-remote/test/mobile-adapter-load-watch.test.mjs +208 -9
  14. package/dsh-setup.mjs +1 -1
  15. package/package.json +2 -2
  16. package/packages/dsh-remote-web/lib/client.js +332 -4
  17. package/packages/dsh-remote-web/lib/index.js +367 -21
  18. package/packages/dsh-remote-web/package.json +1 -1
  19. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +7 -1
  20. package/packages/dsh-remote-web/runtime/clients/dsh-remote/im-format.mjs +372 -0
  21. package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +495 -43
  22. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +238 -12
  23. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +77 -6
  24. package/packages/dsh-remote-web/test/im-bots.test.mjs +224 -2
  25. package/packages/dsh-remote-web/test/panel-ia.test.mjs +197 -32
@@ -51,6 +51,17 @@ import { EventEmitter } from "node:events";
51
51
  import WebSocket from "ws";
52
52
 
53
53
  import { createEventSubscriber, NODE_KINDS } from "./dsh-events.mjs";
54
+ // 通道感知格式化(2026-11-08:企微/钉钉 markdown 不支持表格,发前统一适配;飞书/微信各有分支)
55
+ import { formatForChannel } from "./im-format.mjs";
56
+ // 会员能力表 = **单一真相源**:直接复用微信通道那套(WECHAT_CAPABILITY_TABLE / limitsFor / grantsCap),
57
+ // 不另造一套。企微/飞书/钉钉将来都套同一套判定(见 research/.../im-channel-membership-design.md)。
58
+ import {
59
+ WECHAT_CAPABILITY_TABLE,
60
+ WECHAT_TIER_LIMITS,
61
+ capabilitiesFor,
62
+ grantsCap,
63
+ limitsFor
64
+ } from "./wechat-runtime.mjs";
54
65
 
55
66
  // ===========================================================================
56
67
  // 1. 常量 / 契约文件名
@@ -707,6 +718,21 @@ export function createWecomBot(options = {}) {
707
718
  // 会话可选绑定(task-24):配置固定的会话(空 = 跟随活跃会话)
708
719
  const configuredSessionId = String(options.configuredSessionId || "").trim();
709
720
  const configuredSessionTitle = truncateText(options.configuredSessionTitle || "", 60);
721
+ // ── 会员判定(2026-11-07 业主拍板:通知免费、审批免费、互动半阉割)──
722
+ // capsProvider 由 bridge 注入(复用微信通道的 resolveWechatTier:字符串档位或 {plan,caps,limits,rev});
723
+ // 拿不到 / 未注入 → **按 free fail-closed**。
724
+ const capsProvider = typeof options.capsProvider === "function" ? options.capsProvider : null;
725
+ const tierOverride = typeof options.tier === "string" && options.tier.trim() ? options.tier.trim() : ""; // 仅测试
726
+ const capabilityTable = options.capabilityTable && typeof options.capabilityTable === "object" ? options.capabilityTable : WECHAT_CAPABILITY_TABLE;
727
+ const limitTable = options.limitTable && typeof options.limitTable === "object" ? options.limitTable : WECHAT_TIER_LIMITS;
728
+ const appUrl = String(options.appUrl || "").trim();
729
+ // 通道格式适配(wecom 当前唯一实现;钉钉/飞书接入时传对应 channel 即可)
730
+ const formatChannel = String(options.formatChannel || "wecom").trim() || "wecom";
731
+ const upgradeUrl = String(options.upgradeUrl || "").trim()
732
+ || (appUrl ? `${appUrl.replace(/\/+$/, "")}/promo` : "");
733
+ const tierRefreshMs = positiveNumber(options.tierRefreshMs, 10 * 60 * 1000);
734
+ const tierDenyThrottleMs = Number.isFinite(Number(options.tierDenyThrottleMs))
735
+ ? Math.max(0, Number(options.tierDenyThrottleMs)) : 5000;
710
736
  const log = createScopedLogger({ sink: options.logger, secrets: [secret], prefix: "[wecom]" });
711
737
 
712
738
  const statusFile = relayDir ? path.join(relayDir, IM_BOT_STATUS_FILE) : "";
@@ -740,6 +766,14 @@ export function createWecomBot(options = {}) {
740
766
  previewClicks: 0
741
767
  };
742
768
 
769
+ /** 会员状态:档位 + 服务端权益包 + 本月用量(跨重启从状态文件恢复)。 */
770
+ const membership = {
771
+ tier: "", // 最近一次成功取到的档位(空 = 还没取到 → 对外按 free)
772
+ entitlements: null, // {rev, plan, caps[], limits{}}(服务端权威)
773
+ checkedAt: 0,
774
+ usage: { month: "", count: 0 }
775
+ };
776
+
743
777
  let ws = null;
744
778
  let stopped = true;
745
779
  let reqSeq = 0;
@@ -749,6 +783,7 @@ export function createWecomBot(options = {}) {
749
783
  let authTimer = null;
750
784
  let heartbeatTimer = null;
751
785
  let reconnectTimer = null;
786
+ let tierTimer = null;
752
787
  let pingOutstanding = 0;
753
788
  let subscriber = null;
754
789
  let ownsSubscriber = false;
@@ -785,15 +820,165 @@ export function createWecomBot(options = {}) {
785
820
  const notifiedKeys = new Map(); // dedupe key -> at
786
821
  const notifyTimes = []; // 限速窗口
787
822
 
788
- // 重启后沿用上次绑定的会话(status 文件里那一份;不是密钥,泄露风险低)
823
+ // 重启后沿用上次绑定的会话 + 本月用量/档位快照(都不含密钥/正文)
789
824
  if (relayDir) {
790
825
  try {
791
826
  const prev = readJsonFile(statusFile);
792
- const bid = prev && prev.wecom && typeof prev.wecom.boundChatId === "string" ? prev.wecom.boundChatId : "";
793
- if (bid) state.boundChatId = bid;
827
+ const w = prev && prev.wecom && typeof prev.wecom === "object" ? prev.wecom : {};
828
+ if (typeof w.boundChatId === "string" && w.boundChatId) state.boundChatId = w.boundChatId;
829
+ // 用量必须恢复(否则重启 = 免费用户额度清零);档位/权益包只在有 provider 时恢复,
830
+ // 且下次刷新会用服务端权威值覆盖 —— 没有 provider 时一律按 free fail-closed。
831
+ if (w.usage && typeof w.usage === "object" && typeof w.usage.month === "string") {
832
+ membership.usage = { month: w.usage.month, count: Math.max(0, Number(w.usage.messages ?? w.usage.count) || 0) };
833
+ }
834
+ if (capsProvider && typeof w.tier === "string" && w.tier) {
835
+ membership.tier = w.tier;
836
+ membership.entitlements = {
837
+ rev: typeof w.entitlementsRev === "string" ? w.entitlementsRev : "",
838
+ plan: w.tier,
839
+ caps: Array.isArray(w.caps) ? w.caps.slice() : null,
840
+ limits: w.limits && typeof w.limits === "object" ? w.limits : null
841
+ };
842
+ }
794
843
  } catch { /* 忽略 */ }
795
844
  }
796
845
 
846
+ // ---------- 会员判定(通知免费 / 审批免费 / 互动半阉割) ----------
847
+
848
+ /** 月份键(用量按月归零;与微信通道同口径)。 */
849
+ function monthKey(at = now()) {
850
+ const d = new Date(at);
851
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
852
+ }
853
+ /** 当前档位(测试可注入 tier 覆盖;从没取到过 → free)。 */
854
+ function tierNow() {
855
+ if (tierOverride) return tierOverride;
856
+ return membership.tier || "free";
857
+ }
858
+ /**
859
+ * 当前生效能力:服务端权益包 caps 优先(**是数组就算数,空数组=什么都不给**),
860
+ * 否则按档位查内置表。判据是"是不是数组"而不是"非空"——空数组是服务端权威的"清空",
861
+ * 当成"没给"会 fail-open(与微信通道 2026-09-23 的修法一致)。
862
+ */
863
+ function capsNow() {
864
+ const server = membership.entitlements && Array.isArray(membership.entitlements.caps)
865
+ ? membership.entitlements.caps : null;
866
+ if (server) return server;
867
+ return capabilitiesFor(tierNow(), capabilityTable) || [];
868
+ }
869
+ /** 当前生效限制(messages_per_month:0/缺省=不限)。 */
870
+ function limitsNow() {
871
+ const server = membership.entitlements && membership.entitlements.limits;
872
+ if (server && typeof server === "object") return server;
873
+ return limitsFor(tierNow(), limitTable) || {};
874
+ }
875
+ function canCap(cap) { return grantsCap(capsNow(), cap); }
876
+ /** 本月已用指令数(跨月自动归零)。 */
877
+ function usageThisMonth() {
878
+ const m = monthKey();
879
+ return membership.usage.month === m ? Number(membership.usage.count || 0) : 0;
880
+ }
881
+ /** 每月指令上限(0 = 不限)。 */
882
+ function messageLimitNow() {
883
+ const n = Number((limitsNow() || {}).messages_per_month || 0);
884
+ return Number.isFinite(n) && n > 0 ? n : 0;
885
+ }
886
+ /** 配置里的固定会话是否因免费档被忽略(`sessions` 是会员能力)。 */
887
+ function bindingGatedNow() {
888
+ return Boolean(configuredSessionId) && !canCap("sessions");
889
+ }
890
+
891
+ /**
892
+ * 刷新档位(带节流)。拿不到时**沿用上次已知档位**(首启即 free,fail-closed)。
893
+ * 契约与微信通道一致:字符串档位(旧)或 {plan,caps,limits,rev}(权益包)。
894
+ */
895
+ async function refreshTier({ force = false, minIntervalMs = 30_000 } = {}) {
896
+ if (!capsProvider) return tierNow();
897
+ const at = now();
898
+ if (!force && at - membership.checkedAt < minIntervalMs) return tierNow();
899
+ membership.checkedAt = at;
900
+ let got = null;
901
+ try {
902
+ got = await capsProvider();
903
+ } catch (e) {
904
+ log.warn(`读取账号档位失败(沿用上次):${truncateText(e && e.message ? e.message : e, 100)}`);
905
+ return tierNow();
906
+ }
907
+ const isObj = Boolean(got) && typeof got === "object";
908
+ const plan = String((isObj ? got.plan : got) || "").trim();
909
+ if (!plan) return tierNow(); // 空 = 这次没取到 → 沿用(绝不因为网络抖动把付费用户降级)
910
+ const caps = isObj && Array.isArray(got.caps) ? got.caps : null;
911
+ const limits = isObj && got.limits && typeof got.limits === "object" ? got.limits : null;
912
+ const rev = isObj ? String(got.rev || "") : "";
913
+ const changed = plan !== membership.tier;
914
+ membership.tier = plan;
915
+ if (caps || limits) {
916
+ membership.entitlements = {
917
+ rev,
918
+ plan,
919
+ caps: caps || (membership.entitlements && membership.entitlements.caps) || null,
920
+ limits: limits || (membership.entitlements && membership.entitlements.limits) || null
921
+ };
922
+ }
923
+ if (changed) log.info(`账号档位:${plan}${caps ? "(含权益包)" : ""}`);
924
+ writeStatus();
925
+ return tierNow();
926
+ }
927
+ /** 判能力前先确保档位新鲜(被拒时用更短节流;买完马上重试能进)。 */
928
+ async function canCapNow(cap) {
929
+ if (canCap(cap)) return true;
930
+ await refreshTier({ minIntervalMs: tierDenyThrottleMs });
931
+ return canCap(cap);
932
+ }
933
+ /** 记一条指令用量(**只在真的派出去之后**调用;失败不扣额度)。 */
934
+ function bumpUsage() {
935
+ const m = monthKey();
936
+ const used = membership.usage.month === m ? Number(membership.usage.count || 0) : 0;
937
+ membership.usage = { month: m, count: used + 1 };
938
+ writeStatus();
939
+ }
940
+
941
+ /** 升级文案(**一次说完**:能做什么 / 还剩几条 / 去哪升级)。 */
942
+ function upgradeText(reason) {
943
+ const freeLimit = Number((limitsFor("free", limitTable) || {}).messages_per_month || 20);
944
+ const used = usageThisMonth();
945
+ const rows = [
946
+ reason === "quota"
947
+ ? `📵 本月免费指令已用完(${used}/${freeLimit},下月 1 号自动恢复)。`
948
+ : "🔒 继续在企微里派活/追问是会员功能。",
949
+ "",
950
+ "免费可以用:接收任务通知、点审批卡(**不占额度**)"
951
+ + (reason === "quota" ? "" : `;每月 ${freeLimit} 条指令(本月已用 ${used}/${freeLimit})`),
952
+ "升级 Pro:指令不限量、可开新任务、可切换会话"
953
+ ];
954
+ if (upgradeUrl) rows.push(`👉 ${upgradeUrl}`);
955
+ return rows.join("\n");
956
+ }
957
+
958
+ /** 入站会员闸:返回 null = 放行;返回对象 = 已被拦下(已回复用户)。 */
959
+ async function membershipGate(frame, info) {
960
+ // ① 能力闸:没有 assign.continue → 不能派活(先尝试刷新档位:可能刚升级)
961
+ if (!canCap("assign.continue")) {
962
+ if (await canCapNow("assign.continue")) return null;
963
+ replyText(frame, upgradeText("capability"));
964
+ emitter.emit("inbound-gated", { ...info, reason: "capability", tier: tierNow() });
965
+ log.info(`入站被会员闸拦下(无 assign.continue,tier=${tierNow()})`);
966
+ return { reason: "capability" };
967
+ }
968
+ // ② 额度闸:免费档每月 N 条(0 = 不限)。超额前先刷新档位(可能刚买)
969
+ const limit = messageLimitNow();
970
+ if (limit > 0 && usageThisMonth() >= limit) {
971
+ await refreshTier({ minIntervalMs: tierDenyThrottleMs });
972
+ const limit2 = messageLimitNow();
973
+ if (!(limit2 > 0 && usageThisMonth() >= limit2)) return null;
974
+ replyText(frame, upgradeText("quota"));
975
+ emitter.emit("inbound-gated", { ...info, reason: "quota", tier: tierNow(), limit: limit2 });
976
+ log.info(`入站被额度闸拦下(${usageThisMonth()}/${limit2},tier=${tierNow()})`);
977
+ return { reason: "quota" };
978
+ }
979
+ return null;
980
+ }
981
+
797
982
  // ---------- 状态文件(契约) ----------
798
983
 
799
984
  /** 契约里的 wecom 段(**字段名冻结**,插件按它渲染;不含 secret)。 */
@@ -814,7 +999,16 @@ export function createWecomBot(options = {}) {
814
999
  sessions: normalizeSessionStatusList(sessionList),
815
1000
  activeSessionId: activeSession.id || "",
816
1001
  activeSessionTitle: activeSession.title || "",
817
- boundSessionId: boundSessionId || ""
1002
+ boundSessionId: boundSessionId || "",
1003
+ // ── 会员判定(2026-11-07):档位 / 能力 / 本月用量 / 绑定是否被门槛忽略 ──
1004
+ // caps 只放**能力名**(非敏感);插件面板可只用 capsCount/remainingMessages。
1005
+ tier: tierNow(),
1006
+ caps: capsNow().slice(),
1007
+ capsCount: capsNow().length,
1008
+ bindingGated: bindingGatedNow(),
1009
+ messagesPerMonth: messageLimitNow(),
1010
+ remainingMessages: messageLimitNow() ? Math.max(0, messageLimitNow() - usageThisMonth()) : null,
1011
+ usage: { month: membership.usage.month || monthKey(), messages: usageThisMonth() }
818
1012
  };
819
1013
  }
820
1014
 
@@ -1175,6 +1369,9 @@ export function createWecomBot(options = {}) {
1175
1369
  /** 把入站文本转给 DSH 会话(尽力,失败给用户一句人话)。 */
1176
1370
  async function dispatchInbound(frame, info) {
1177
1371
  const sub = subscriber;
1372
+ // ① 会员闸(通知/审批免费;派活受能力与月度额度约束,fail-closed)
1373
+ const gated = await membershipGate(frame, info);
1374
+ if (gated) return;
1178
1375
  const sessionId = await resolveSessionId(sub);
1179
1376
  if (!sub || typeof sub.promptSession !== "function") {
1180
1377
  log.warn("入站消息无法转发:事件订阅器未就绪(详见启动日志)");
@@ -1197,6 +1394,8 @@ export function createWecomBot(options = {}) {
1197
1394
  if (result && result.ok) {
1198
1395
  // 记下"这次入站交给了哪个会话":该会话下一次 turn/end 要把回答正文回传给企微
1199
1396
  lastInbound = { sessionId, at: now() };
1397
+ // 额度只统计**真的派出去的**指令(审批回执/预览按钮不经过这里,也就不计数)
1398
+ bumpUsage();
1200
1399
  // 状态文件对外表达"当前实际转发目标"(面板据此显示绑定关系)
1201
1400
  if (boundSessionId !== sessionId) { boundSessionId = sessionId; writeStatus(); }
1202
1401
  log.info(`入站文本已转给会话 session=${shortSession(sessionId)}`);
@@ -1236,9 +1435,12 @@ export function createWecomBot(options = {}) {
1236
1435
  return cached && cached.title ? cached.title : "";
1237
1436
  }
1238
1437
 
1239
- /** 转发目标优先级(task-24):配置固定 → 最近活跃 → 会话列表第一个。 */
1438
+ /**
1439
+ * 转发目标优先级(task-24):配置固定 → 最近活跃 → 会话列表第一个。
1440
+ * ⚠️ 配置固定会话是**会员能力**(sessions):免费档忽略(见 bindingGatedNow)。
1441
+ */
1240
1442
  function computeBoundSessionId() {
1241
- if (configuredSessionId) return configuredSessionId;
1443
+ if (configuredSessionId && canCap("sessions")) return configuredSessionId;
1242
1444
  if (activeSession.id) return activeSession.id;
1243
1445
  return sessionList.length ? sessionList[0].id : "";
1244
1446
  }
@@ -1247,7 +1449,8 @@ export function createWecomBot(options = {}) {
1247
1449
  function syncSessionState() {
1248
1450
  if (activeSession.id) activeSession.title = titleOfSession(activeSession.id, activeSession.title);
1249
1451
  boundSessionId = computeBoundSessionId();
1250
- if (configuredSessionId && !sessionList.some((s) => s.id === configuredSessionId)) {
1452
+ // 免费档不把"配置固定会话"补进列表(否则它会变成 sessions[0] 被拿去转发)
1453
+ if (configuredSessionId && canCap("sessions") && !sessionList.some((s) => s.id === configuredSessionId)) {
1251
1454
  sessionList = normalizeSessionStatusList([
1252
1455
  { id: configuredSessionId, title: configuredSessionTitle, updatedAt: 0 },
1253
1456
  ...sessionList
@@ -1326,7 +1529,9 @@ export function createWecomBot(options = {}) {
1326
1529
  if (got) return got;
1327
1530
  }
1328
1531
  } catch { /* 忽略 */ }
1329
- if (configuredSessionId) return configuredSessionId;
1532
+ // 配置固定的会话是**会员能力**(sessions):免费档忽略它,回退"跟随活跃会话",
1533
+ // 状态文件里 bindingGated=true(面板据此把会话下拉置灰 + 标会员徽章)。
1534
+ if (configuredSessionId && canCap("sessions")) return configuredSessionId;
1330
1535
  if (activeSession.id) return activeSession.id;
1331
1536
  if (sessionList.length === 0) {
1332
1537
  try { await refreshSessions({ force: true }); } catch { /* 忽略 */ }
@@ -1360,7 +1565,8 @@ export function createWecomBot(options = {}) {
1360
1565
 
1361
1566
  /** 回复一句人话:优先被动回复(不需要绑定),拿不到入站 req_id 时退回主动推送。 */
1362
1567
  function replyText(frame, text) {
1363
- const content = truncateText(text, 500);
1568
+ // 被动回复同样按通道格式化(确认文案里可能有列表/表格;也防止超长撑爆客户端)
1569
+ const content = formatForChannel(formatChannel, { text: text == null ? "" : String(text), maxChars: 500 });
1364
1570
  const r = replyStream(frame, "", content, true);
1365
1571
  if (r.ok) return r;
1366
1572
  const target = state.boundChatId || targetChatId;
@@ -1374,9 +1580,15 @@ export function createWecomBot(options = {}) {
1374
1580
  * @param {string} text
1375
1581
  * @returns {{ok:boolean, reqId?:string, reason?:string}}
1376
1582
  */
1377
- function sendMarkdown(chatid, text) {
1583
+ function sendMarkdown(chatid, text, opts = {}) {
1378
1584
  const to = String(chatid || "").trim();
1379
- const content = truncateText(text == null ? "" : text, NOTIFY_MAX_CHARS);
1585
+ // 2026-11-08:所有主动 markdown(通知/回答回传/确认文案)统一按通道能力格式化:
1586
+ // 表格→可读列表、标题→加粗、分隔线→空行、wecom 4096 字节截断(优先段落边界)。
1587
+ const content = formatForChannel(formatChannel, {
1588
+ text: text == null ? "" : String(text),
1589
+ maxBytes: opts.maxBytes,
1590
+ maxChars: opts.maxChars
1591
+ });
1380
1592
  if (!to) return { ok: false, reason: "no_chatid" };
1381
1593
  if (!content) return { ok: false, reason: "empty_text" };
1382
1594
  if (!state.authenticated) return { ok: false, reason: "not_authenticated" };
@@ -2015,7 +2227,13 @@ export function createWecomBot(options = {}) {
2015
2227
  cards: state.cards,
2016
2228
  approvalDecisions: state.approvalDecisions,
2017
2229
  previews: state.previews,
2018
- previewClicks: state.previewClicks
2230
+ previewClicks: state.previewClicks,
2231
+ // 会员判定(2026-11-07):档位/能力/本月用量/绑定是否被门槛忽略
2232
+ tier: tierNow(),
2233
+ caps: capsNow().slice(),
2234
+ bindingGated: bindingGatedNow(),
2235
+ messagesPerMonth: messageLimitNow(),
2236
+ usage: { month: membership.usage.month || monthKey(), messages: usageThisMonth() }
2019
2237
  };
2020
2238
  }
2021
2239
 
@@ -2036,6 +2254,12 @@ export function createWecomBot(options = {}) {
2036
2254
  writeStatus();
2037
2255
  ensureSubscriber();
2038
2256
  connect();
2257
+ // 会员档位:启动时刷一次(不 await,不阻塞连接),之后每 10 分钟校准一次
2258
+ void refreshTier({ force: true }).catch(() => {});
2259
+ if (!tierTimer && capsProvider) {
2260
+ tierTimer = setInterval(() => { void refreshTier().catch(() => {}); }, tierRefreshMs);
2261
+ if (typeof tierTimer.unref === "function") tierTimer.unref();
2262
+ }
2039
2263
  return status();
2040
2264
  },
2041
2265
 
@@ -2049,6 +2273,8 @@ export function createWecomBot(options = {}) {
2049
2273
  state.nextRetryInMs = 0;
2050
2274
  try { if (reconnectTimer) clearTimeout(reconnectTimer); } catch { /* 忽略 */ }
2051
2275
  reconnectTimer = null;
2276
+ try { if (tierTimer) clearInterval(tierTimer); } catch { /* 忽略 */ }
2277
+ tierTimer = null;
2052
2278
  stopHeartbeat();
2053
2279
  clearAuthTimer();
2054
2280
  for (const [, p] of pending) { try { clearTimeout(p.timer); } catch { /* 忽略 */ } }
@@ -1315,21 +1315,47 @@ function upstreamPort() {
1315
1315
  let upstreamDiscoveryAt = 0;
1316
1316
  let upstreamDiscoveryInflight = null;
1317
1317
  const UPSTREAM_DISCOVERY_COOLDOWN_MS = 15_000;
1318
+ /**
1319
+ * 连续未命中后的**指数退避**:watcher 每 10s 轮询一次,上游长期不可达(dsh web 关着)时
1320
+ * 每轮都全量扫本机端口 = 每 10s 敲一遍微信等客户端端口(2026-11-07 微信 CPU 高的放大器之一)。
1321
+ * 现在:15s → 30s → 60s → 300s 封顶;端口文件一变(事件驱动)立即清零重试。
1322
+ */
1323
+ const UPSTREAM_DISCOVERY_BACKOFF_MS = [15_000, 30_000, 60_000, 300_000];
1324
+ let upstreamDiscoveryFails = 0;
1325
+ let upstreamDiscoveryNextAt = 0;
1326
+ let warnedUpstreamDiscoveryBackoff = false;
1318
1327
 
1319
1328
  /**
1320
1329
  * 动态发现上游(带身份校验),成功后写入 `discoveredUpstream` 供全局复用。
1321
- * @param {boolean} force 忽略冷却(探测失败后的重试用)
1330
+ * @param {boolean} force 忽略冷却/退避(仅限安装瞬间等一次性场景;**轮询路径不要传 true**)
1322
1331
  */
1323
1332
  async function refreshUpstreamDiscovery(force = false) {
1324
1333
  if (upstreamDiscoveryInflight) return upstreamDiscoveryInflight;
1325
1334
  const now = Date.now();
1335
+ // ⚠️ force 只绕过「普通冷却」,**绝不绕过失败退避**:watcher 每 10s 会 force 一次,
1336
+ // 若 force 能打穿退避,就退化成"上游一直不可达 = 每 10s 全量扫一遍本机端口"
1337
+ // (2026-11-07 微信 CPU 事故的放大器)。端口文件一变,watchUpstreamFile 会清零退避。
1338
+ if (now < upstreamDiscoveryNextAt) return upstreamUrl();
1326
1339
  if (!force && now - upstreamDiscoveryAt < UPSTREAM_DISCOVERY_COOLDOWN_MS) return upstreamUrl();
1327
1340
  upstreamDiscoveryAt = now;
1328
1341
  upstreamDiscoveryInflight = (async () => {
1329
1342
  try {
1330
1343
  const found = await discoverUpstream({ relayDir: CONFIG_DIR, log: (m) => console.log(`[dsh-remote] ${m}`) });
1331
1344
  // 「提示地址验证通过」与「候选命中」都值得记下来;fallback(什么都没找到)不污染现有判断
1332
- if (found.url && found.source !== "fallback") discoveredUpstream = found.url;
1345
+ if (found.url && found.source !== "fallback") {
1346
+ discoveredUpstream = found.url;
1347
+ upstreamDiscoveryFails = 0;
1348
+ upstreamDiscoveryNextAt = 0;
1349
+ warnedUpstreamDiscoveryBackoff = false;
1350
+ } else {
1351
+ upstreamDiscoveryFails += 1;
1352
+ const wait = UPSTREAM_DISCOVERY_BACKOFF_MS[Math.min(upstreamDiscoveryFails - 1, UPSTREAM_DISCOVERY_BACKOFF_MS.length - 1)];
1353
+ upstreamDiscoveryNextAt = Date.now() + wait;
1354
+ if (!warnedUpstreamDiscoveryBackoff) {
1355
+ warnedUpstreamDiscoveryBackoff = true;
1356
+ console.log(`[dsh-remote] 上游发现暂未命中 → ${Math.round(wait / 1000)}s 后再试(连续 ${upstreamDiscoveryFails} 次,退避最长 5 分钟;避免反复扫描本机端口)`);
1357
+ }
1358
+ }
1333
1359
  return upstreamUrl();
1334
1360
  } catch {
1335
1361
  return upstreamUrl();
@@ -1340,6 +1366,45 @@ async function refreshUpstreamDiscovery(force = false) {
1340
1366
  return upstreamDiscoveryInflight;
1341
1367
  }
1342
1368
 
1369
+ /**
1370
+ * 事件驱动上游端口(2026-11-07):插件半每次启动/换端口都会重写 `<CONFIG_DIR>/.dsh-upstream`
1371
+ * (**确认过:实际文件名就是 `.dsh-upstream`,`.dsh-config` 里没有 upstream 字段**)。
1372
+ *
1373
+ * 用 `fs.watchFile`(1s 一次 stat,**不扫端口**)监听它:
1374
+ * · 内容变了 → 直接采用新地址 + 清掉冷却/退避 → 立刻 `ensureBridge()`;
1375
+ * · 于是"上游端口变了"不再依赖每 10s 的全量端口扫描(那正是微信 CPU 高的放大器)。
1376
+ * 监听不可用时静默退回原来的 10s 轮询(行为不回归)。
1377
+ *
1378
+ * @param {() => Promise<any>} [onChange]
1379
+ */
1380
+ let upstreamFileWatch = null;
1381
+ function watchUpstreamFile(onChange) {
1382
+ if (upstreamFileWatch) return;
1383
+ const file = path.join(CONFIG_DIR, UPSTREAM_FILE);
1384
+ let last = "";
1385
+ try { last = String(fs.readFileSync(file, "utf8")).trim(); } catch { last = ""; }
1386
+ try {
1387
+ upstreamFileWatch = { file, last };
1388
+ fs.watchFile(file, { interval: 1000, persistent: false }, () => {
1389
+ let next = "";
1390
+ try { next = String(fs.readFileSync(file, "utf8")).trim(); } catch { next = ""; }
1391
+ if (!next || next === upstreamFileWatch.last) return;
1392
+ upstreamFileWatch.last = next;
1393
+ if (!/^https?:\/\/[^\s]+$/i.test(next)) return;
1394
+ discoveredUpstream = next.replace(/\/+$/, "");
1395
+ // 新端口 = 新的发现时机:清冷却/退避,下一次 ensureBridge 立刻采用
1396
+ upstreamDiscoveryAt = 0;
1397
+ upstreamDiscoveryFails = 0;
1398
+ upstreamDiscoveryNextAt = 0;
1399
+ warnedUpstreamDiscoveryBackoff = false;
1400
+ reconcileUpstreamEnvFromFile();
1401
+ if (typeof onChange === "function") {
1402
+ Promise.resolve().then(() => onChange()).catch(() => {});
1403
+ }
1404
+ });
1405
+ } catch { /* watch 不可用:保持 10s 轮询 */ }
1406
+ }
1407
+
1343
1408
  /** 上游是否**有响应**(判据刻意宽松:4xx/5xx 也算"端口在听",那种情况问题在别处)。 */
1344
1409
  async function upstreamReachable(url = upstreamUrl(), timeoutMs = 1200) {
1345
1410
  try {
@@ -1374,6 +1439,8 @@ async function isDshWebUp(timeoutMs = 1200) {
1374
1439
  }
1375
1440
  } catch { /* 没有端口文件:继续走动态发现 */ }
1376
1441
  const before = upstreamUrl();
1442
+ // 探测失败时主动发现一次(force = 绕过 15s 普通冷却;**失败退避仍在**,见 refreshUpstreamDiscovery),
1443
+ // 所以 watcher 每 10s 调用也不会演变成"每 10s 全量扫描本机端口"。
1377
1444
  const after = await refreshUpstreamDiscovery(true);
1378
1445
  if (after === before) return false;
1379
1446
  return upstreamReachable(after, timeoutMs);
@@ -1947,6 +2014,7 @@ async function runBridge() {
1947
2014
 
1948
2015
  // 先做一次动态发现再打印/派生:否则日志会长期写着错误的端口(用户实测就卡在
1949
2016
  // 「等待 dsh web(127.0.0.1:3080)启动...」而真实上游在 43120)。
2017
+ // 只在这一处 force 一次(进程启动必然需要一次真实发现);轮询路径一律走冷却/退避。
1950
2018
  await refreshUpstreamDiscovery(true);
1951
2019
  console.log(`[dsh-remote] 等待 dsh web(${upstreamUrl()})启动...`);
1952
2020
  let bridgeProc = null;
@@ -2055,9 +2123,10 @@ async function runBridge() {
2055
2123
  const alive = await checkUpstream();
2056
2124
  if (alive && childStopped(bridgeProc)) {
2057
2125
  starting = true;
2058
- // dsh 重启可能换端口(Electron 每次启动端口可能变):拉起前**强制重新发现**,
2059
- // 否则 discoveredUpstream 缓存旧端口 → bridge 连不上上游 → 设备掉线(2026-10-25 实测)。
2060
- await refreshUpstreamDiscovery(true);
2126
+ // dsh 重启可能换端口(Electron 每次启动端口可能变):拉起前再确认一次最新地址。
2127
+ // 走冷却/退避(不 force):新端口通常已由 `.dsh-upstream` 的 watch 或 isDshWebUp 的文件优先读到;
2128
+ // 真没有时退避会在几秒内放开,且不会演变成"每轮全量扫描"。
2129
+ await refreshUpstreamDiscovery();
2061
2130
  console.log("[dsh-remote] dsh web 在线,启动 bridge...");
2062
2131
  // 清除代理环境变量(bridge 需直连 relay,不受本机代理影响)
2063
2132
  const childEnv = {
@@ -2204,7 +2273,9 @@ async function runBridge() {
2204
2273
  }
2205
2274
 
2206
2275
  await ensureBridge();
2207
- setInterval(ensureBridge, 10000); // 每 10s 检查
2276
+ // 事件驱动:插件写 `.dsh-upstream` 的瞬间就采用新端口(替代"每 10s 全量扫描")
2277
+ watchUpstreamFile(ensureBridge);
2278
+ setInterval(ensureBridge, 10000); // 每 10s 检查(只为 keep-bridge-alive;发现本身已按冷却/退避)
2208
2279
  console.log("[dsh-remote] 守护运行中(Ctrl-C 退出)");
2209
2280
  }
2210
2281