@mrrisega/dsh-remote 0.7.12-beta.4 → 0.7.12-beta.6

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.
@@ -51,6 +51,8 @@ import WebSocket from "ws";
51
51
 
52
52
  import { createEventSubscriber, NODE_KINDS } from "./dsh-events.mjs";
53
53
  import { formatForChannel } from "./im-format.mjs";
54
+ // 命令层(2026-11-08):/help /status /stop /ls /use /unbind 在进模型前拦下执行(三家共用)
55
+ import { executeImCommand, writeImBotsSession } from "./im-commands.mjs";
54
56
  import {
55
57
  WECHAT_CAPABILITY_TABLE,
56
58
  WECHAT_TIER_LIMITS,
@@ -91,6 +93,15 @@ export const TOPIC_CARD_CALLBACK = "/v1.0/card/instances/callback";
91
93
  export const DEFAULT_SUBSCRIPTIONS = Object.freeze([{ type: "CALLBACK", topic: TOPIC_ROBOT }]);
92
94
  /** ticket 有效期(官方:90s / 一次性)。状态文件只记过期时间,不存 ticket。 */
93
95
  export const TICKET_TTL_MS = 90_000;
96
+ /**
97
+ * 回复通道持久化文件(相对 relayDir;0600):缓存最近一次入站带来的
98
+ * `{sessionWebhook, sessionWebhookExpiredTime, senderStaffId, conversationId, conversationType, boundChatId, at}`。
99
+ * 为什么不写状态文件:sessionWebhook 里带会话令牌,状态文件是给面板/浏览器看的(只放元数据)。
100
+ * 为什么要落盘:进程重启后「回答回传」仍要能发出去(Lead 实测 bug:只存内存会丢,回传直接发不出)。
101
+ */
102
+ export const REPLY_FILE = "im-dingtalk-reply.json";
103
+ /** webhook 过期后降级 openapi 时给用户/日志的明确原因(面板据此提示"再发一条消息激活")。 */
104
+ const NO_REPLY_CHANNEL_HINT = "回复通道不可用:请在钉钉里再发一条消息激活(或配置 robotCode 走 OpenAPI)";
94
105
  /** 重连退避:1s 起、翻倍、60s 封顶(+0~1s 抖动,照官方 SDK 行为)。 */
95
106
  export const RECONNECT_BASE_MS = 1_000;
96
107
  export const RECONNECT_MAX_MS = 60_000;
@@ -189,8 +200,8 @@ export function normalizeBotMessage(data) {
189
200
  sessionWebhookExpiredTime: Number(d.sessionWebhookExpiredTime) || 0,
190
201
  robotCode: String(d.robotCode || ""),
191
202
  createAt: Number(d.createAt) || 0,
192
- /** 主动发消息/去重用的目标:群=conversationId,单聊=senderStaffId。 */
193
- chatId: conversationId || senderStaffId,
203
+ /** 主动发消息/去重用的目标:群=conversationId,单聊=senderStaffId(单聊 conversationId 不能当 chatId,否则会被误判成群聊)。 */
204
+ chatId: (conversationType === "2" ? conversationId : senderStaffId) || conversationId || senderStaffId,
194
205
  raw: d
195
206
  };
196
207
  }
@@ -277,8 +288,8 @@ export function createDingtalkBot(options = {}) {
277
288
  const relayDir = options.relayDir ? String(options.relayDir) : "";
278
289
  const allowFrom = normalizeAllowList(options.allowFrom);
279
290
  const targetChatId = String(options.targetChatId || "").trim();
280
- const configuredSessionId = String(options.configuredSessionId || "").trim();
281
- const configuredSessionTitle = truncateText(options.configuredSessionTitle || "", 60);
291
+ let configuredSessionId = String(options.configuredSessionId || "").trim();
292
+ let configuredSessionTitle = truncateText(options.configuredSessionTitle || "", 60);
282
293
  const now = typeof options.clock === "function" ? options.clock : () => Date.now();
283
294
  const doFetch = typeof options.fetchImpl === "function" ? options.fetchImpl : globalThis.fetch;
284
295
  const wsFactory = typeof options.wsFactory === "function" ? options.wsFactory : (url) => new WebSocket(url);
@@ -322,7 +333,9 @@ export function createDingtalkBot(options = {}) {
322
333
  boundSessionWebhook: "",
323
334
  boundSessionWebhookExpiredAt: 0,
324
335
  messages: 0,
325
- gated: 0
336
+ gated: 0,
337
+ /** 最近一次回复失败原因(人话;写状态 replyChannel.lastError,不覆盖连接错误)。 */
338
+ replyChannelError: ""
326
339
  };
327
340
  const membership = { tier: "", entitlements: null, checkedAt: 0, usage: { month: "", count: 0 } };
328
341
 
@@ -355,6 +368,19 @@ export function createDingtalkBot(options = {}) {
355
368
  // 配置里持久化的 sessionWebhook(插件/上次运行留下的):首次入站前也能主动发;
356
369
  // ⚠️ webhook 里带会话令牌,**绝不写状态文件**,只在内存里用。
357
370
  if (options.sessionWebhook) state.boundSessionWebhook = String(options.sessionWebhook || "");
371
+ // 上次入站缓存的回复通道(跨重启可用):im-dingtalk-reply.json(0600)
372
+ if (relayDir) {
373
+ try {
374
+ const saved = readJsonFile(path.join(relayDir, REPLY_FILE));
375
+ if (saved && typeof saved === "object") {
376
+ if (typeof saved.sessionWebhook === "string" && saved.sessionWebhook) state.boundSessionWebhook = saved.sessionWebhook;
377
+ if (Number.isFinite(Number(saved.sessionWebhookExpiredTime))) state.boundSessionWebhookExpiredAt = Number(saved.sessionWebhookExpiredTime);
378
+ if (typeof saved.senderStaffId === "string" && saved.senderStaffId) state.boundUserId = saved.senderStaffId;
379
+ if (typeof saved.conversationId === "string" && saved.conversationId) state.boundConversationId = saved.conversationId;
380
+ if (typeof saved.boundChatId === "string" && saved.boundChatId) state.boundChatId = saved.boundChatId;
381
+ }
382
+ } catch { /* 忽略坏文件 */ }
383
+ }
358
384
  if (targetChatId && !state.boundChatId) state.boundChatId = targetChatId;
359
385
 
360
386
  // 重启恢复:用量 / 绑定目标 / 档位快照(均不含凭据)
@@ -502,6 +528,8 @@ export function createDingtalkBot(options = {}) {
502
528
  credentialSource,
503
529
  robotCodeConfigured: Boolean(robotCode),
504
530
  capabilities: capabilitiesNow(),
531
+ // 回复通道三层降级(面板显示「可用/需重新发一条消息激活」;不含 webhook 原值)
532
+ replyChannel: replyChannelStatus(),
505
533
  // 会员 + 用量(与企微对齐)
506
534
  tier: tierNow(),
507
535
  caps: capsNow().slice(),
@@ -760,7 +788,30 @@ export function createDingtalkBot(options = {}) {
760
788
  }
761
789
  }
762
790
 
763
- /** 更新「回复目标」:sessionWebhook 优先(即时回复),同时记住 openapi 需要的 conversation/user。 */
791
+ /** 写回复通道缓存到磁盘(0600;含会话令牌,绝不进状态/日志)。 */
792
+ function persistReplyChannel() {
793
+ if (!relayDir) return;
794
+ try {
795
+ atomicWriteJson(path.join(relayDir, REPLY_FILE), {
796
+ version: 1,
797
+ sessionWebhook: state.boundSessionWebhook || "",
798
+ sessionWebhookExpiredTime: Number(state.boundSessionWebhookExpiredAt) || 0,
799
+ senderStaffId: state.boundUserId || "",
800
+ conversationId: state.boundConversationId || "",
801
+ boundChatId: state.boundChatId || "",
802
+ at: now()
803
+ }, { onWarn: (m) => log.warn(m) });
804
+ } catch (e) {
805
+ log.warn(`回复通道缓存写入失败(忽略):${truncateText(e && e.message ? e.message : e, 100)}`);
806
+ }
807
+ }
808
+
809
+ /** 日志/状态里只用 webhook 的 host(整条 URL 含会话令牌,绝不外泄)。 */
810
+ function webhookHost(url) {
811
+ try { return new URL(String(url)).host; } catch { return chatIdHash(String(url)) || "?"; }
812
+ }
813
+
814
+ /** 更新「回复目标」:缓存 sessionWebhook(被动回复)+ senderStaffId/conversationId(openapi 降级用)。 */
764
815
  function bindTarget(info) {
765
816
  if (info.conversationId) state.boundConversationId = info.conversationId;
766
817
  if (info.senderStaffId) state.boundUserId = info.senderStaffId;
@@ -769,9 +820,13 @@ export function createDingtalkBot(options = {}) {
769
820
  log.info(`已绑定推送会话 chat=${chatIdHash(info.chatId)}${info.isGroup ? "(群)" : "(单聊)"}`);
770
821
  }
771
822
  if (info.sessionWebhook) {
823
+ const changed = info.sessionWebhook !== state.boundSessionWebhook;
772
824
  state.boundSessionWebhook = info.sessionWebhook;
773
825
  state.boundSessionWebhookExpiredAt = info.sessionWebhookExpiredTime || 0;
826
+ state.replyChannelError = "";
827
+ if (changed) log.info(`已缓存回复通道 sessionWebhook(host=${webhookHost(info.sessionWebhook)},过期=${state.boundSessionWebhookExpiredAt ? new Date(state.boundSessionWebhookExpiredAt).toISOString() : "未知"})`);
774
828
  }
829
+ persistReplyChannel();
775
830
  writeStatus();
776
831
  }
777
832
 
@@ -803,9 +858,95 @@ export function createDingtalkBot(options = {}) {
803
858
  void dispatchInbound(info).catch((e) => log.warn(`入站转发失败(已忽略):${truncateText(e && e.message ? e.message : e, 120)}`));
804
859
  }
805
860
 
806
- /** 入站 → 会员闸 → 转发到 DSH 会话(尽力、可降级)。 */
861
+ /** 命令层:命中已回复返回 true(不转模型、不计额度;回复走三层回复通道)。 */
862
+ async function handleCommand(info) {
863
+ const text = String((info && info.text) || "");
864
+ if (!/^\s*\//.test(text)) return false;
865
+ const cmd = await executeImCommand({
866
+ text,
867
+ ctx: {
868
+ caps: capsNow(),
869
+ tier: tierNow(),
870
+ appUrl: String(options.appUrl || ""),
871
+ channelLabel: "钉钉",
872
+ sessions: async () => {
873
+ const sub = subscriber;
874
+ if (!sub) return [];
875
+ let rows = [];
876
+ try {
877
+ if (typeof sub.listSessions === "function") {
878
+ const r = await sub.listSessions();
879
+ rows = Array.isArray(r) ? r : (r && r.ok && Array.isArray(r.sessions) ? r.sessions : []);
880
+ }
881
+ } catch { rows = []; }
882
+ if ((!Array.isArray(rows) || rows.length === 0) && typeof sub.sessions === "function") {
883
+ try { rows = (sub.sessions() || []).map((id) => ({ sessionId: String(id) })); } catch { rows = []; }
884
+ }
885
+ return (Array.isArray(rows) ? rows : []).map((x) => ({
886
+ sessionId: String(x.sessionId || x.id || ""),
887
+ title: String(x.title || ""),
888
+ updatedAt: Number(x.updatedAt) || 0,
889
+ running: x.running === true
890
+ })).filter((x) => x.sessionId);
891
+ },
892
+ currentSessionId: () => {
893
+ const known = boundSessionId || lastSeenSessionId || "";
894
+ if (known) return known;
895
+ if (configuredSessionId && canCap("sessions")) return configuredSessionId;
896
+ const sub = subscriber;
897
+ try {
898
+ if (sub && typeof sub.sessions === "function") {
899
+ const ids = sub.sessions();
900
+ if (Array.isArray(ids) && ids.length) return String(ids[0]);
901
+ }
902
+ } catch { /* 忽略 */ }
903
+ return "";
904
+ },
905
+ currentSessionTitle: () => "",
906
+ cancelSession: async ({ sessionId }) => {
907
+ const sub = subscriber;
908
+ if (!sub || typeof sub.cancelSession !== "function") return { ok: false, message: "订阅器未就绪" };
909
+ try {
910
+ const r = await sub.cancelSession({ sessionId });
911
+ return r && typeof r === "object" ? r : { ok: Boolean(r) };
912
+ } catch (e) { return { ok: false, message: e && e.message ? e.message : String(e) }; }
913
+ },
914
+ switchSession: async ({ sessionId, title }) => {
915
+ const r = writeImBotsSession(relayDir, "dingtalk", { sessionId, sessionTitle: title });
916
+ if (r.ok) {
917
+ configuredSessionId = sessionId;
918
+ configuredSessionTitle = truncateText(title || "", 60);
919
+ boundSessionId = sessionId;
920
+ writeStatus();
921
+ }
922
+ return r;
923
+ },
924
+ unbind: async () => { clearReplyChannel(); return { ok: true }; },
925
+ statusSnapshot: async () => ({
926
+ connected: state.connected === true,
927
+ authenticated: state.authenticated === true,
928
+ state: state.phase,
929
+ lastError: state.lastError || "",
930
+ tier: tierNow(),
931
+ usage: { month: monthKey(), messages: usageThisMonth() },
932
+ messagesPerMonth: messageLimitNow(),
933
+ remainingMessages: messageLimitNow() ? Math.max(0, messageLimitNow() - usageThisMonth()) : null,
934
+ currentSessionId: boundSessionId || lastSeenSessionId || "",
935
+ currentSessionTitle: ""
936
+ })
937
+ }
938
+ });
939
+ if (!cmd || !cmd.handled) return false;
940
+ reply(info, cmd.text);
941
+ emitter.emit("command", { ...info, command: cmd.command, ok: cmd.ok === true, gated: cmd.gated === true });
942
+ log.info(`命令 ${cmd.command}${cmd.gated ? "(会员闸拦截)" : ""} 已回复(不转模型)`);
943
+ return true;
944
+ }
945
+
946
+ /** 入站 → 命令层 → 会员闸 → 转发到 DSH 会话(尽力、可降级)。 */
807
947
  async function dispatchInbound(info) {
808
948
  const sub = subscriber;
949
+ if (await handleCommand(info)) return;
809
950
  const gated = await membershipGate(info);
810
951
  if (gated) return;
811
952
  const sessionId = await resolveSessionId(sub);
@@ -947,42 +1088,114 @@ export function createDingtalkBot(options = {}) {
947
1088
  }
948
1089
 
949
1090
  /**
950
- * 发一条 markdown(通知/回答回传/确认文案统一入口)。
951
- * 目标选择:① 最近一次入站的 sessionWebhook(未过期,最快、无需 token)
952
- * ② replyMode=openapi(或 webhook 不可用)且有 robotCode → OpenAPI
953
- * ③ 都没有 → 记日志跳过(不抛错)
1091
+ * 当前可用的回复通道(**三层降级**):
1092
+ * ① 缓存 sessionWebhook(未过期;被动回复,最快、不需要 token)
1093
+ * ② openapi(robotCode + senderStaffId/conversationId 都有)
1094
+ * ③ none(明确原因,面板提示"再发一条消息激活")
954
1095
  */
955
- async function sendMarkdown(text, opts = {}) {
1096
+ function replyChannelNow() {
1097
+ const expiresAt = Number(state.boundSessionWebhookExpiredAt) || 0;
1098
+ const webhookUsable = Boolean(state.boundSessionWebhook) && (!expiresAt || now() < expiresAt);
1099
+ if (webhookUsable) return { kind: "sessionWebhook", webhook: state.boundSessionWebhook, expiresAt, reason: "" };
1100
+ if (robotCode && (state.boundUserId || state.boundConversationId)) {
1101
+ return {
1102
+ kind: "openapi",
1103
+ webhook: "",
1104
+ expiresAt: 0,
1105
+ reason: state.boundSessionWebhook ? "sessionWebhook_expired" : "no_webhook"
1106
+ };
1107
+ }
1108
+ return {
1109
+ kind: "none",
1110
+ webhook: "",
1111
+ expiresAt,
1112
+ reason: state.boundSessionWebhook ? "sessionWebhook_expired" : (robotCode ? "no_target_user" : "no_robot_code"),
1113
+ lastError: NO_REPLY_CHANNEL_HINT
1114
+ };
1115
+ }
1116
+
1117
+ /** 状态文件里的 replyChannel 元数据(**绝不含 webhook 原值**)。 */
1118
+ function replyChannelStatus() {
1119
+ const ch = replyChannelNow();
1120
+ return {
1121
+ kind: ch.kind,
1122
+ expiresAt: Number(ch.expiresAt) || 0,
1123
+ lastError: truncateText(maskSecrets(state.replyChannelError || "", [clientSecret]), 160),
1124
+ hasWebhook: Boolean(state.boundSessionWebhook)
1125
+ };
1126
+ }
1127
+
1128
+ function markReplyChannelError(detail) {
1129
+ state.replyChannelError = truncateText(String(detail || ""), 160);
1130
+ writeStatus();
1131
+ }
1132
+
1133
+ /**
1134
+ * 统一回复入口(回答回传/通知/命令回复都走这里)。
1135
+ * ① 缓存 sessionWebhook → ② 失败/过期降级 openapi → ③ 都不可用:**明确日志 + 状态人话,绝不静默**。
1136
+ */
1137
+ async function sendViaReplyChannel(text, opts = {}) {
956
1138
  const title = opts.title || "";
957
- const webhook = state.boundSessionWebhook;
958
- const webhookOk = Boolean(webhook) && (!state.boundSessionWebhookExpiredAt || now() < state.boundSessionWebhookExpiredAt);
959
- if (replyMode !== "openapi" && webhookOk) {
1139
+ const preferredWebhook = String(opts.webhook || "").trim();
1140
+ const channel = replyChannelNow();
1141
+ const webhook = preferredWebhook || (channel.kind === "sessionWebhook" ? channel.webhook : "");
1142
+ if (webhook) {
960
1143
  const r = await replySessionWebhook(webhook, { title, text });
961
- if (r.ok) return r;
962
- log.warn(`sessionWebhook 回复失败(${r.reason}),尝试 OpenAPI 兜底`);
1144
+ if (r.ok) {
1145
+ if (state.replyChannelError) { state.replyChannelError = ""; writeStatus(); }
1146
+ return { ...r, kind: "sessionWebhook" };
1147
+ }
1148
+ // JSDoc: 只记 host + 错误原因(整条 URL 带会话令牌,绝不外泄)
1149
+ log.warn(`sessionWebhook 回复失败(host=${webhookHost(webhook)},${truncateText(r.reason, 60)})→ 尝试 OpenAPI 兜底`);
1150
+ markReplyChannelError(`sessionWebhook 回复失败:${truncateText(r.reason, 80)};${NO_REPLY_CHANNEL_HINT}`);
1151
+ }
1152
+ if (robotCode && (state.boundUserId || state.boundConversationId)) {
1153
+ const fb = await sendOpenapi(text, { title });
1154
+ if (fb.ok) return { ...fb, kind: "openapi" };
1155
+ log.warn(`OpenAPI 兜底也失败:${truncateText(fb.reason, 80)}`);
1156
+ markReplyChannelError(`OpenAPI 回复失败:${truncateText(fb.reason, 80)};${NO_REPLY_CHANNEL_HINT}`);
1157
+ return { ok: false, kind: "none", reason: "no_reply_channel", detail: fb.reason || "" };
1158
+ }
1159
+ // 缺 robotCode:给更具体的可操作错误(与能力降级口径一致,不是笼统的"没通道")
1160
+ if (!robotCode) {
1161
+ const detail = "未配置 robotCode:openapi 主动发送不可用(接收消息与 sessionWebhook 回复不受影响)。"
1162
+ + "请在插件「钉钉机器人」里补 robotCode。";
1163
+ log.warn(`no_robot_code:${detail}`);
1164
+ markReplyChannelError(`no_robot_code:${detail}`);
1165
+ return { ok: false, kind: "none", reason: "no_robot_code", detail, capabilities: capabilitiesNow() };
963
1166
  }
964
- const fallback = await sendOpenapi(text, { title });
965
- if (fallback.ok) return fallback;
966
1167
  const at = now();
967
- if (at - lastNoTargetLogAt > 10 * 60 * 1000) {
1168
+ if (at - lastNoTargetLogAt > 60_000) {
968
1169
  lastNoTargetLogAt = at;
969
- log.info(`没有可用的回复通道(sessionWebhook 未就绪 / openapi 未配置):${truncateText(fallback.reason || "?", 60)}`);
1170
+ log.warn(`no_reply_channel:${NO_REPLY_CHANNEL_HINT}(detail=${truncateText(channel.reason, 40)},webhook=${state.boundSessionWebhook ? "expired" : "none"},robotCode=${robotCode ? "有" : "无"},userId=${state.boundUserId ? "有" : "无"})`);
970
1171
  }
971
- return fallback;
1172
+ markReplyChannelError(`no_reply_channel:${NO_REPLY_CHANNEL_HINT}`);
1173
+ return { ok: false, kind: "none", reason: "no_reply_channel", detail: NO_REPLY_CHANNEL_HINT };
1174
+ }
1175
+
1176
+ /** 兼容旧入口:通知/回答回传/命令回复统一走三层回复通道。 */
1177
+ async function sendMarkdown(text, opts = {}) {
1178
+ return sendViaReplyChannel(text, opts);
972
1179
  }
973
1180
 
974
- /** 回复一句人话:优先 sessionWebhook(即时),失败/缺失时退回主动发。 */
1181
+ /** 回复一句人话:优先本次入站的 webhook,再走三层降级;失败不抛。 */
975
1182
  function reply(info, text) {
976
- const webhook = info && info.sessionWebhook ? info.sessionWebhook : state.boundSessionWebhook;
977
- if (webhook) {
978
- void replySessionWebhook(webhook, { text }).then((r) => {
979
- if (r.ok) return;
980
- log.warn(`回复失败(${r.reason}),尝试主动发兜底`);
981
- void sendMarkdown(text).catch(() => {});
982
- }).catch(() => {});
983
- return;
1183
+ const webhook = info && info.sessionWebhook ? info.sessionWebhook : "";
1184
+ void sendViaReplyChannel(text, { webhook }).catch(() => {});
1185
+ }
1186
+
1187
+ /** 解绑推送(/unbind 命令用):清内存 + 删持久化回复通道 + 清状态绑定字段。 */
1188
+ function clearReplyChannel() {
1189
+ state.boundSessionWebhook = "";
1190
+ state.boundSessionWebhookExpiredAt = 0;
1191
+ state.boundUserId = "";
1192
+ state.boundConversationId = "";
1193
+ state.boundChatId = "";
1194
+ state.replyChannelError = "";
1195
+ if (relayDir) {
1196
+ try { fs.rmSync(path.join(relayDir, REPLY_FILE), { force: true }); } catch { /* 忽略 */ }
984
1197
  }
985
- void sendMarkdown(text).catch(() => {});
1198
+ writeStatus();
986
1199
  }
987
1200
 
988
1201
  // ---------- DSH 事件订阅(通知 + 回答回传 + 活跃会话) ----------
@@ -1131,6 +1344,8 @@ export function createDingtalkBot(options = {}) {
1131
1344
  credentialSource,
1132
1345
  robotCodeConfigured: Boolean(robotCode),
1133
1346
  capabilities: capabilitiesNow(),
1347
+ // 回复通道三层降级(面板显示「可用/需重新发一条消息激活」;不含 webhook 原值)
1348
+ replyChannel: replyChannelStatus(),
1134
1349
  subscriptions: subscriptions.map((s) => String(s.topic || "")).filter(Boolean),
1135
1350
  messages: state.messages,
1136
1351
  gated: state.gated,
@@ -1192,6 +1407,8 @@ export function createDingtalkBot(options = {}) {
1192
1407
  status,
1193
1408
  replySessionWebhook,
1194
1409
  sendMarkdown,
1410
+ sendViaReplyChannel,
1411
+ clearReplyChannel,
1195
1412
  getAccessToken,
1196
1413
  on(event, listener) { emitter.on(event, listener); return () => emitter.off(event, listener); },
1197
1414
  once(event, listener) { emitter.once(event, listener); return () => emitter.off(event, listener); },
@@ -124,6 +124,15 @@ import { createAgentRuntime } from "./agent/index.mjs";
124
124
  import { createWecomBotController } from "./wecom-bot.mjs";
125
125
  // 钉钉 Stream 模式长连接(2026-11-08):与企微同构、零新增依赖;默认关闭,配置在 im-bots.json 的 dingtalk 段。
126
126
  import { createDingtalkBotController } from "./dingtalk-bot.mjs";
127
+ // 钉钉「扫码一键创建机器人」授权会话:**持久化 + 断点续轮询**(2026-11-08)。
128
+ // 根因:device_code 只存在插件进程内存 → 面板关/宿主重启就丢,而凭据只在 poll SUCCESS 那次返回。
129
+ // 现在授权会话落盘在 <relayDir>/im-dingtalk-auth.json,bridge 启动即续轮询,桥在跑就能接住。
130
+ import {
131
+ resumeDingtalkAuth,
132
+ startDingtalkAuthCommandWatcher,
133
+ stopDingtalkAuthCommandWatcher,
134
+ stopDingtalkAuthPolling
135
+ } from "./dingtalk-auth.mjs";
127
136
  // 飞书通道:官方 SDK 按需下载(41MB,不随包分发)、懒加载;未启用时零副作用
128
137
  import { createFeishuBotController } from "./feishu-bot.mjs";
129
138
 
@@ -3358,6 +3367,29 @@ function imBotLog(level, message) {
3358
3367
  }
3359
3368
 
3360
3369
  function startImBots() {
3370
+ // ⓪ 钉钉扫码授权断点续轮询(fire-and-forget,绝不阻塞隧道/连接主链路):
3371
+ // 上次没等到用户确认就重启/关面板也没关系 —— 会话在磁盘上,桥一跑就继续 poll,
3372
+ // 成功后自动写 im-bots.json(credentialSource:"scan")→ controller 热更新把机器人连上。
3373
+ try {
3374
+ void resumeDingtalkAuth({
3375
+ relayDir: RELAY_DIR,
3376
+ logger: imBotLog,
3377
+ onWarn: (m) => imBotLog("warn", m),
3378
+ onSuccess: () => { try { startImBots(); } catch { /* 忽略 */ } }
3379
+ }).catch((e) => {
3380
+ console.warn(`[bridge] 钉钉扫码授权续轮询失败(不影响远程访问): ${redactText(e)}`);
3381
+ });
3382
+ // 控制命令文件:插件写 <relayDir>/im-dingtalk-auth-cmd.json(begin/cancel),桥读并执行。
3383
+ // 插件不需要 device_code,也不需要 HTTP 端点 —— 只表达意图;桥在跑就能接住。
3384
+ startDingtalkAuthCommandWatcher({
3385
+ relayDir: RELAY_DIR,
3386
+ logger: imBotLog,
3387
+ onWarn: (m) => imBotLog("warn", m),
3388
+ onSuccess: () => { try { startImBots(); } catch { /* 忽略 */ } }
3389
+ });
3390
+ } catch (e) {
3391
+ console.warn(`[bridge] 钉钉扫码授权续轮询初始化失败(忽略): ${redactText(e)}`);
3392
+ }
3361
3393
  // ① 企业微信(现有)
3362
3394
  try {
3363
3395
  if (!imBots) {
@@ -3437,6 +3469,9 @@ function startImBots() {
3437
3469
 
3438
3470
  /** 优雅退出 IM 机器人通道(企微 + 钉钉,互不影响)。失败只记日志。 */
3439
3471
  async function stopImBots() {
3472
+ // 先停扫码授权轮询/命令监听(**保留** auth 文件:下次启动能续轮询;用户显式取消才删文件)
3473
+ try { stopDingtalkAuthCommandWatcher(); } catch { /* 忽略 */ }
3474
+ try { stopDingtalkAuthPolling(); } catch { /* 忽略 */ }
3440
3475
  const wecom = imBots;
3441
3476
  const dingtalk = imBotsDingtalk;
3442
3477
  const feishu = imBotsFeishu;
@@ -81,6 +81,8 @@ import {
81
81
  APPROVAL_CARD_TTL_MS
82
82
  } from "./wecom-bot.mjs";
83
83
  import { formatForChannel } from "./im-format.mjs";
84
+ // 命令层(2026-11-08):/help /status /stop /ls /use /unbind 在进模型前拦下执行(三家共用)
85
+ import { executeImCommand, writeImBotsSession } from "./im-commands.mjs";
84
86
  import {
85
87
  WECHAT_CAPABILITY_TABLE,
86
88
  WECHAT_TIER_LIMITS,
@@ -1233,6 +1235,16 @@ export function createFeishuBot({
1233
1235
  return r;
1234
1236
  },
1235
1237
 
1238
+ /** 解绑接收人(/unbind 命令用):清内存绑定 + 触发状态刷新;之后收到消息会自动重新绑定。 */
1239
+ clearBinding() {
1240
+ state.boundChatId = "";
1241
+ state.boundChatType = "";
1242
+ state.boundChatName = "";
1243
+ state.boundSource = "";
1244
+ emitStatus();
1245
+ return { ok: true };
1246
+ },
1247
+
1236
1248
  /** 发审批卡片(返回值带 messageId,供回执后原地更新)。 */
1237
1249
  async sendCard(to, card, opts = {}) {
1238
1250
  if (!sender) return { ok: false, reason: "发送器未初始化(连接尚未建立)" };
@@ -1565,9 +1577,98 @@ export function createFeishuBotController(options = {}) {
1565
1577
  }
1566
1578
 
1567
1579
  // ── 入站 ────────────────────────────────────────────────────────────────
1580
+ /** 入站命令层:命中返回 true(已回复,调用方直接 return)。 */
1581
+ async function handleInboundCommand(msg) {
1582
+ const text = String((msg && msg.text) || "");
1583
+ if (!/^\s*\//.test(text)) return false;
1584
+ const cmd = await executeImCommand({
1585
+ text,
1586
+ ctx: {
1587
+ caps: capsNow(),
1588
+ tier: tierNow(),
1589
+ appUrl,
1590
+ channelLabel: "飞书",
1591
+ sessions: async () => {
1592
+ const sub = subscriber;
1593
+ if (!sub) return [];
1594
+ let rows = [];
1595
+ try {
1596
+ if (typeof sub.listSessions === "function") {
1597
+ const r = await sub.listSessions();
1598
+ rows = Array.isArray(r) ? r : (r && r.ok && Array.isArray(r.sessions) ? r.sessions : []);
1599
+ }
1600
+ } catch { rows = []; }
1601
+ if ((!Array.isArray(rows) || rows.length === 0) && typeof sub.sessions === "function") {
1602
+ try { rows = (sub.sessions() || []).map((id) => ({ sessionId: String(id) })); } catch { rows = []; }
1603
+ }
1604
+ return (Array.isArray(rows) ? rows : []).map((x) => ({
1605
+ sessionId: String(x.sessionId || x.id || ""),
1606
+ title: String(x.title || ""),
1607
+ updatedAt: Number(x.updatedAt) || 0,
1608
+ running: x.running === true
1609
+ })).filter((x) => x.sessionId);
1610
+ },
1611
+ currentSessionId: () => {
1612
+ const cfgId = String((configNow && configNow.sessionId) || "");
1613
+ if (cfgId) return cfgId;
1614
+ const sub = subscriber;
1615
+ try {
1616
+ if (sub && typeof sub.sessions === "function") {
1617
+ const ids = sub.sessions();
1618
+ if (Array.isArray(ids) && ids.length) return String(ids[0]);
1619
+ }
1620
+ } catch { /* 忽略 */ }
1621
+ return "";
1622
+ },
1623
+ currentSessionTitle: () => String((configNow && configNow.sessionTitle) || ""),
1624
+ cancelSession: async ({ sessionId }) => {
1625
+ const sub = subscriber;
1626
+ if (!sub || typeof sub.cancelSession !== "function") return { ok: false, message: "订阅器未就绪" };
1627
+ try {
1628
+ const r = await sub.cancelSession({ sessionId });
1629
+ return r && typeof r === "object" ? r : { ok: Boolean(r) };
1630
+ } catch (e) { return { ok: false, message: e && e.message ? e.message : String(e) }; }
1631
+ },
1632
+ switchSession: async ({ sessionId, title }) => {
1633
+ const r = writeImBotsSession(relayDir, "feishu", { sessionId, sessionTitle: title });
1634
+ if (r.ok && configNow) configNow = { ...configNow, sessionId, sessionTitle: title || "" };
1635
+ return r;
1636
+ },
1637
+ unbind: async () => {
1638
+ try { if (bot && typeof bot.clearBinding === "function") bot.clearBinding(); } catch { /* 忽略 */ }
1639
+ return { ok: true };
1640
+ },
1641
+ statusSnapshot: async () => {
1642
+ const bs = bot && typeof bot.status === "function" ? bot.status() : null;
1643
+ return {
1644
+ connected: state.connected === true,
1645
+ authenticated: state.authenticated === true,
1646
+ state: state.state,
1647
+ lastError: state.lastError || "",
1648
+ tier: tierNow(),
1649
+ usage: { month: monthKey(clock()), messages: usageThisMonth() },
1650
+ messagesPerMonth: Number(limitsNow().messages_per_month || 0),
1651
+ remainingMessages: Number(limitsNow().messages_per_month || 0) > 0
1652
+ ? Math.max(0, Number(limitsNow().messages_per_month || 0) - usageThisMonth()) : null,
1653
+ currentSessionId: String((configNow && configNow.sessionId) || (bs && bs.boundChatId) || ""),
1654
+ currentSessionTitle: String((configNow && configNow.sessionTitle) || (bs && bs.boundChatName) || "")
1655
+ };
1656
+ }
1657
+ }
1658
+ });
1659
+ if (!cmd || !cmd.handled) return false;
1660
+ try {
1661
+ await bot.sendText(msg.chatId, cmd.text, { receiveIdType: "chat_id" });
1662
+ } catch { /* 回复失败不阻塞 */ }
1663
+ log.info(`命令 ${cmd.command}${cmd.gated ? "(会员闸拦截)" : ""} 已回复(不转模型)`);
1664
+ return true;
1665
+ }
1666
+
1568
1667
  async function onInboundMessage(msg) {
1569
1668
  if (!msg || !msg.text) return;
1570
1669
  const at = Number(clock()) || Date.now();
1670
+ // ⓪ 命令层:命中即执行并回复,**绝不进模型**,也不计入额度(/stop、/unbind 永远免费)
1671
+ if (await handleInboundCommand(msg)) return;
1571
1672
  // 白名单在 bot 层已过滤;这里做会员闸:派活要 assign.continue + 月度额度
1572
1673
  await refreshTier({ minIntervalMs: 30_000 });
1573
1674
  if (!canCap("assign.continue")) {