dsh-remote-web 0.7.12-beta.5 → 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.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.7.12-beta.
|
|
3
|
+
"version": "0.7.12-beta.6",
|
|
4
4
|
"description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -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
|
-
|
|
281
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
951
|
-
*
|
|
952
|
-
*
|
|
953
|
-
*
|
|
1091
|
+
* 当前可用的回复通道(**三层降级**):
|
|
1092
|
+
* ① 缓存 sessionWebhook(未过期;被动回复,最快、不需要 token)
|
|
1093
|
+
* ② openapi(robotCode + senderStaffId/conversationId 都有)
|
|
1094
|
+
* ③ none(明确原因,面板提示"再发一条消息激活")
|
|
954
1095
|
*/
|
|
955
|
-
|
|
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
|
|
958
|
-
const
|
|
959
|
-
|
|
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)
|
|
962
|
-
|
|
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 >
|
|
1168
|
+
if (at - lastNoTargetLogAt > 60_000) {
|
|
968
1169
|
lastNoTargetLogAt = at;
|
|
969
|
-
log.
|
|
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
|
-
|
|
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
|
-
/**
|
|
1181
|
+
/** 回复一句人话:优先本次入站的 webhook,再走三层降级;失败不抛。 */
|
|
975
1182
|
function reply(info, text) {
|
|
976
|
-
const webhook = info && info.sessionWebhook ? info.sessionWebhook :
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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
|
-
|
|
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); },
|
|
@@ -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")) {
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* im-commands — 三家 IM 机器人(企微 / 钉钉 / 飞书)共用的**命令层**(2026-11-08)。
|
|
3
|
+
*
|
|
4
|
+
* 业主实测:在钉钉里发 `/ls` 期望列会话,但当时没有命令层 → `/ls` 被当成 prompt 转给了模型。
|
|
5
|
+
* 命令层把这类「控制类消息」在**进模型之前**拦下、自己执行并回复;解析器/文案/能力门禁三家共用,
|
|
6
|
+
* 各通道只负责把回复发出去(企微被动回复 / 钉钉 sessionWebhook / 飞书 im/v1/messages)。
|
|
7
|
+
*
|
|
8
|
+
* 命令表(能力门禁复用 `WECHAT_CAPABILITY_TABLE`,与会员升级文案同口径):
|
|
9
|
+
* | 命令 | 能力 | 免费 | 说明 |
|
|
10
|
+
* | /help | 无 | ✅ | 列出全部命令(含会员命令标注) |
|
|
11
|
+
* | /status | 无 | ✅ | 连接状态 + 当前会话 + 本月剩余指令 |
|
|
12
|
+
* | /stop | 无 | ✅ | 急停当前任务(安全阀必须免费、立即生效)|
|
|
13
|
+
* | /ls | sessions | ❌ | 最近 ≤10 个会话(标题/索引/当前标记) |
|
|
14
|
+
* | /use <N> | sessions | ❌ | 切换当前绑定会话(写配置 sessionId) |
|
|
15
|
+
* | /unbind | 无 | ✅ | 解绑/退订本会话推送(合规:随时可退订)|
|
|
16
|
+
*
|
|
17
|
+
* 未命中的 `/xxx`:回帮助提示(含"未知命令"),**绝不转给模型**。
|
|
18
|
+
* 回复一律用纯文本列表(不用 Markdown 表格;各通道发送前还会走 im-format 兜底)。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
|
|
23
|
+
import { WECHAT_CAPABILITY_TABLE, grantsCap } from "./wechat-runtime.mjs";
|
|
24
|
+
import { IM_BOTS_FILE, atomicWriteJson, readJsonFile } from "./wecom-bot.mjs";
|
|
25
|
+
|
|
26
|
+
/** 命令表(顺序即 /help 展示顺序)。`free` 只是文档标记;真正门禁看 caps。 */
|
|
27
|
+
export const IM_COMMANDS = Object.freeze([
|
|
28
|
+
Object.freeze({ name: "/help", caps: Object.freeze([]), free: true, usage: "/help", summary: "查看命令" }),
|
|
29
|
+
Object.freeze({ name: "/status", caps: Object.freeze([]), free: true, usage: "/status", summary: "连接状态 / 当前会话 / 本月额度" }),
|
|
30
|
+
Object.freeze({ name: "/stop", caps: Object.freeze([]), free: true, usage: "/stop", summary: "急停当前任务" }),
|
|
31
|
+
Object.freeze({ name: "/ls", caps: Object.freeze(["sessions"]), free: false, usage: "/ls", summary: "列出最近会话(会员)" }),
|
|
32
|
+
Object.freeze({ name: "/use", caps: Object.freeze(["sessions"]), free: false, usage: "/use <序号>", summary: "切换当前会话(会员)" }),
|
|
33
|
+
Object.freeze({ name: "/unbind", caps: Object.freeze([]), free: true, usage: "/unbind", summary: "解绑本会话推送(随时可退订)" })
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/** 会话标题截断(按码点,避免切坏 emoji/中文)。 */
|
|
37
|
+
export function truncateTitle(title, max = 24) {
|
|
38
|
+
const s = String(title == null ? "" : title).replace(/\s+/g, " ").trim();
|
|
39
|
+
if (!s) return "";
|
|
40
|
+
const chars = Array.from(s);
|
|
41
|
+
return chars.length <= max ? s : `${chars.slice(0, Math.max(1, max - 1)).join("")}…`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function pad2(n) { return String(n).padStart(2, "0"); }
|
|
45
|
+
function fmtTime(at) {
|
|
46
|
+
const n = Number(at) || 0;
|
|
47
|
+
if (!n) return "";
|
|
48
|
+
try {
|
|
49
|
+
const d = new Date(n);
|
|
50
|
+
return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
51
|
+
} catch { return ""; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 解析一条消息是否为命令。
|
|
56
|
+
* @returns {{name:string, arg:string, unknown?:boolean, raw:string}|null} null = 普通文本(照旧转给模型)
|
|
57
|
+
*/
|
|
58
|
+
export function parseImCommand(text) {
|
|
59
|
+
const raw = String(text == null ? "" : text).trim();
|
|
60
|
+
if (!raw.startsWith("/")) return null;
|
|
61
|
+
const m = /^\/([a-zA-Z]*)(?:\s+([^\s]+))?(?:\s+([\s\S]*))?$/.exec(raw);
|
|
62
|
+
if (!m) return { name: "/", arg: "", unknown: true, raw };
|
|
63
|
+
const word = String(m[1] || "").toLowerCase();
|
|
64
|
+
return { name: word ? `/${word}` : "/", arg: String(m[2] || "").toLowerCase(), rest: m[3] || "", unknown: !word, raw };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 当前 caps 是否具备该命令要求(点号前缀规则,与会员表同源)。 */
|
|
68
|
+
export function commandAllowed(command, caps) {
|
|
69
|
+
const list = Array.isArray(caps) ? caps : [];
|
|
70
|
+
return (command.caps || []).every((c) => grantsCap(list, c));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function appPromo(ctx) {
|
|
74
|
+
const base = String((ctx && ctx.appUrl) || "").trim().replace(/\/+$/, "");
|
|
75
|
+
if (!base) return "";
|
|
76
|
+
return `${base}/promo`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** /help:列出全部命令(免费/会员标注;免费档会提示哪些需要会员)。 */
|
|
80
|
+
export function helpText(ctx = {}) {
|
|
81
|
+
const caps = Array.isArray(ctx.caps) ? ctx.caps : [];
|
|
82
|
+
const lines = ["📋 可用命令(命令不会发给模型,直接执行)", ""];
|
|
83
|
+
for (const cmd of IM_COMMANDS) {
|
|
84
|
+
const allowed = commandAllowed(cmd, caps);
|
|
85
|
+
const mark = cmd.free ? "" : allowed ? "(会员)" : "(会员·当前档不可用)";
|
|
86
|
+
lines.push(`${cmd.usage} — ${cmd.summary}${mark}`);
|
|
87
|
+
}
|
|
88
|
+
lines.push("", "免费档可用的命令:/help /status /stop /unbind(急停与退订永远免费)。");
|
|
89
|
+
if (caps.includes("sessions")) lines.push("你的档位已包含会话管理(/ls、/use)。");
|
|
90
|
+
return lines.join("\n");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 未知 /xxx:不转给模型,回"未知命令 + 帮助提示"。 */
|
|
94
|
+
export function unknownCommandText(cmd, ctx = {}) {
|
|
95
|
+
const name = cmd && cmd.name && cmd.name !== "/" ? cmd.name : cmd && cmd.raw ? String(cmd.raw).split(/\s+/)[0] : "/?";
|
|
96
|
+
return `未知命令:${name}\n\n${helpText(ctx)}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 会员功能被拦:与现有升级文案同口径(能做什么/去哪升级),只是更简短。 */
|
|
100
|
+
export function upgradeText(commandName, ctx = {}) {
|
|
101
|
+
const promo = appPromo(ctx);
|
|
102
|
+
const lines = [
|
|
103
|
+
`🔒 ${commandName} 是会员功能(列出/切换会话)`,
|
|
104
|
+
"",
|
|
105
|
+
"免费可用:/help /status /stop /unbind(急停、退订永远免费)",
|
|
106
|
+
"升级 Pro:指令不限量、可开新任务、可切换/多会话"
|
|
107
|
+
];
|
|
108
|
+
if (promo) lines.push(`👉 ${promo}`);
|
|
109
|
+
return lines.join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** /status:与状态文件同源的连接/会话/额度摘要。 */
|
|
113
|
+
export async function statusText(ctx = {}) {
|
|
114
|
+
const snap = typeof ctx.statusSnapshot === "function" ? await ctx.statusSnapshot() : (ctx.status || {});
|
|
115
|
+
const s = snap && typeof snap === "object" ? snap : {};
|
|
116
|
+
const channel = String(s.channelLabel || ctx.channelLabel || "IM");
|
|
117
|
+
const connected = s.connected === true;
|
|
118
|
+
const authed = s.authenticated === true;
|
|
119
|
+
const state = String(s.state || (connected ? "connected" : "idle"));
|
|
120
|
+
const stateLine = connected
|
|
121
|
+
? (authed ? "✅ 已连接(认证通过)" : "✅ 已连接(等待认证)")
|
|
122
|
+
: `⚠️ 未连接(${state}${s.lastError ? `:${truncateTitle(s.lastError, 40)}` : ""})`;
|
|
123
|
+
const conv = truncateTitle(s.currentSessionTitle || s.boundSessionTitle || "", 24) || "未选择";
|
|
124
|
+
const convId = String(s.currentSessionId || s.boundSessionId || "");
|
|
125
|
+
const shortId = convId ? convId.replace(/^session-/, "").slice(0, 8) : "";
|
|
126
|
+
const limit = Number(s.messagesPerMonth) || 0;
|
|
127
|
+
const used = Number(s.usage && s.usage.messages) || 0;
|
|
128
|
+
const quotaLine = limit > 0
|
|
129
|
+
? `本月指令:已用 ${used}/${limit}${s.tier ? `(${s.tier})` : ""}`
|
|
130
|
+
: `本月指令:不限${s.tier ? `(${s.tier})` : ""}`;
|
|
131
|
+
const lines = [
|
|
132
|
+
"📊 连接状态",
|
|
133
|
+
`· 通道:${channel}`,
|
|
134
|
+
`· 状态:${stateLine}`,
|
|
135
|
+
`· 当前会话:${conv}${shortId ? `(#${shortId})` : ""}`,
|
|
136
|
+
`· ${quotaLine}`
|
|
137
|
+
];
|
|
138
|
+
if (s.remainingMessages !== null && s.remainingMessages !== undefined && limit > 0) {
|
|
139
|
+
lines.push(`· 剩余:${Number(s.remainingMessages) || 0} 条`);
|
|
140
|
+
}
|
|
141
|
+
return lines.join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** ctx 的 currentSessionId/currentSessionTitle 允许函数或字符串(各通道实现风格不一)。 */
|
|
145
|
+
function resolveMaybeFn(value) {
|
|
146
|
+
try { return typeof value === "function" ? String(value() || "") : String(value || ""); } catch { return ""; }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 统一取最近会话列表(ctx.sessions 返回 [{sessionId,title,updatedAt,running}])。 */
|
|
150
|
+
export async function listSessions(ctx = {}) {
|
|
151
|
+
if (typeof ctx.sessions !== "function") return [];
|
|
152
|
+
const rows = await ctx.sessions();
|
|
153
|
+
if (!Array.isArray(rows)) return [];
|
|
154
|
+
return rows
|
|
155
|
+
.filter((r) => r && typeof r.sessionId === "string" && r.sessionId)
|
|
156
|
+
.slice(0, 10)
|
|
157
|
+
.map((r) => ({
|
|
158
|
+
sessionId: r.sessionId,
|
|
159
|
+
title: String(r.title || r.sessionTitle || "").trim() || r.sessionId,
|
|
160
|
+
updatedAt: Number(r.updatedAt) || 0,
|
|
161
|
+
running: r.running === true
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** /ls:≤10 个会话,标题截断 24 + 索引 + 当前标记 + 更新时间。 */
|
|
166
|
+
export async function lsText(ctx = {}) {
|
|
167
|
+
const rows = await listSessions(ctx);
|
|
168
|
+
const current = resolveMaybeFn(ctx.currentSessionId) || String(ctx.activeSessionId || "");
|
|
169
|
+
if (!rows.length) {
|
|
170
|
+
return "📂 最近没有可列出的会话。\n\n先在电脑的 DeepSeek Harness 里打开/运行一个会话,再发 /ls。";
|
|
171
|
+
}
|
|
172
|
+
const lines = ["📂 最近会话(最多 10 个)", ""];
|
|
173
|
+
rows.forEach((r, i) => {
|
|
174
|
+
const isCurrent = r.sessionId === current;
|
|
175
|
+
const time = fmtTime(r.updatedAt);
|
|
176
|
+
const marks = [isCurrent ? "(当前)" : "", r.running ? "· 运行中" : "", time ? `· ${time}` : ""].filter(Boolean).join(" ");
|
|
177
|
+
lines.push(`${i + 1}. ${truncateTitle(r.title, 24)}${marks ? ` ${marks}` : ""}`);
|
|
178
|
+
});
|
|
179
|
+
lines.push("", "回复 /use <序号> 切换会话(例如 /use 1)。");
|
|
180
|
+
return { text: lines.join("\n"), rows };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** /use <N>:切换当前绑定会话(写配置 sessionId/sessionTitle)。 */
|
|
184
|
+
export async function useText(ctx = {}, arg = "") {
|
|
185
|
+
const rows = await listSessions(ctx);
|
|
186
|
+
if (!rows.length) return "现在没有可切换的会话。先在电脑上打开一个会话,再发 /ls 看看。";
|
|
187
|
+
const raw = String(arg || "").trim();
|
|
188
|
+
if (!raw) return "用法:/use <序号>(先发 /ls 查看会话列表,例如 /use 1)";
|
|
189
|
+
const n = Number(raw);
|
|
190
|
+
if (!Number.isInteger(n) || n < 1 || n > rows.length) {
|
|
191
|
+
return `序号超出范围:当前列表是 1-${rows.length}。先发 /ls 查看列表,再用 /use <序号> 切换。`;
|
|
192
|
+
}
|
|
193
|
+
const pick = rows[n - 1];
|
|
194
|
+
if (typeof ctx.switchSession !== "function") return "当前通道还不支持切换会话(订阅器未就绪),请在电脑上切换。";
|
|
195
|
+
const r = await ctx.switchSession({ sessionId: pick.sessionId, title: pick.title });
|
|
196
|
+
if (!r || r.ok !== true) {
|
|
197
|
+
return `切换失败:${truncateTitle((r && (r.message || r.reason)) || "未知错误", 60)}。可以到电脑上切换,或稍后再试。`;
|
|
198
|
+
}
|
|
199
|
+
return `✅ 已切换到 #${n} ${truncateTitle(pick.title, 24) || pick.sessionId}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** /stop:急停当前任务(免费、立即生效;回执明确)。 */
|
|
203
|
+
export async function stopText(ctx = {}) {
|
|
204
|
+
const current = resolveMaybeFn(ctx.currentSessionId) || String(ctx.activeSessionId || "");
|
|
205
|
+
if (!current) return "当前没有正在运行的会话,无需急停。";
|
|
206
|
+
if (typeof ctx.cancelSession !== "function") return "急停失败:后台还没连上事件通道(订阅器未就绪)。请在电脑上停止任务。";
|
|
207
|
+
const title = truncateTitle(resolveMaybeFn(ctx.currentSessionTitle) || ctx.activeSessionTitle || current, 24);
|
|
208
|
+
let r;
|
|
209
|
+
try { r = await ctx.cancelSession({ sessionId: current }); } catch (e) {
|
|
210
|
+
r = { ok: false, message: e && e.message ? e.message : String(e) };
|
|
211
|
+
}
|
|
212
|
+
if (r && r.ok === true) return `⏹ 已急停:${title}(已发送中止,正在停止当前任务)`;
|
|
213
|
+
return `⏹ 急停请求已发出,但没收到确认:${truncateTitle((r && (r.message || r.reason)) || "未知错误", 60)}。请到电脑上确认任务是否已停。`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** /unbind:解绑本会话推送(免费;合规要求任何人可退订)。 */
|
|
217
|
+
export async function unbindText(ctx = {}) {
|
|
218
|
+
if (typeof ctx.unbind !== "function") return "解绑失败:当前通道暂不支持解绑,请在电脑的「远程访问」里关闭该通道。";
|
|
219
|
+
let r;
|
|
220
|
+
try { r = await ctx.unbind(); } catch (e) { r = { ok: false, message: e && e.message ? e.message : String(e) }; }
|
|
221
|
+
if (r && r.ok === true) return "✅ 已解绑:这台电脑不再向本会话推送通知。想重新接收,发任意消息即可重新绑定。";
|
|
222
|
+
return `解绑失败:${truncateTitle((r && (r.message || r.reason)) || "未知错误", 60)}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 执行一条命令(**命中即返回 handled:true,调用方直接回复 text 并 return,绝不进模型**)。
|
|
227
|
+
*
|
|
228
|
+
* @param {{text:string, ctx:object}} args
|
|
229
|
+
* ctx 契约(各通道按自己能力提供,缺的降级):
|
|
230
|
+
* · caps[]、tier、appUrl、channelLabel
|
|
231
|
+
* · sessions(): Promise<[{sessionId,title,updatedAt,running}]>
|
|
232
|
+
* · currentSessionId(): string、currentSessionTitle?: string、activeSessionId?
|
|
233
|
+
* · cancelSession({sessionId})、switchSession({sessionId,title})、unbind()
|
|
234
|
+
* · statusSnapshot(): Promise<{connected,authenticated,state,lastError,tier,usage,messagesPerMonth,remainingMessages,currentSessionTitle,currentSessionId}>
|
|
235
|
+
* @returns {Promise<{handled:boolean, ok?:boolean, gated?:boolean, command?:string, text?:string, rows?:Array}>}
|
|
236
|
+
*/
|
|
237
|
+
export async function executeImCommand({ text, ctx = {} } = {}) {
|
|
238
|
+
const cmd = parseImCommand(text);
|
|
239
|
+
if (!cmd) return { handled: false };
|
|
240
|
+
const caps = Array.isArray(ctx.caps) ? ctx.caps : [];
|
|
241
|
+
const known = IM_COMMANDS.find((c) => c.name === cmd.name);
|
|
242
|
+
if (!known) return { handled: true, ok: false, command: cmd.name, text: unknownCommandText(cmd, ctx) };
|
|
243
|
+
if (!commandAllowed(known, caps)) {
|
|
244
|
+
return { handled: true, ok: false, gated: true, command: known.name, text: upgradeText(known.name, ctx) };
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
switch (known.name) {
|
|
248
|
+
case "/help":
|
|
249
|
+
return { handled: true, ok: true, command: known.name, text: helpText(ctx) };
|
|
250
|
+
case "/status":
|
|
251
|
+
return { handled: true, ok: true, command: known.name, text: await statusText(ctx) };
|
|
252
|
+
case "/stop":
|
|
253
|
+
return { handled: true, ok: true, command: known.name, text: await stopText(ctx) };
|
|
254
|
+
case "/unbind":
|
|
255
|
+
return { handled: true, ok: true, command: known.name, text: await unbindText(ctx) };
|
|
256
|
+
case "/ls": {
|
|
257
|
+
const r = await lsText(ctx);
|
|
258
|
+
return { handled: true, ok: true, command: known.name, text: typeof r === "string" ? r : r.text, rows: typeof r === "string" ? [] : r.rows };
|
|
259
|
+
}
|
|
260
|
+
case "/use":
|
|
261
|
+
return { handled: true, ok: true, command: known.name, text: await useText(ctx, cmd.arg) };
|
|
262
|
+
default:
|
|
263
|
+
return { handled: true, ok: false, command: known.name, text: unknownCommandText(cmd, ctx) };
|
|
264
|
+
}
|
|
265
|
+
} catch (e) {
|
|
266
|
+
return {
|
|
267
|
+
handled: true,
|
|
268
|
+
ok: false,
|
|
269
|
+
command: known.name,
|
|
270
|
+
text: `命令执行失败:${truncateTitle(e && e.message ? e.message : e, 60)}。可以到电脑上看一眼,或稍后再试。`
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** 能力表(导出方便各通道显示"会员功能"标记;单一真相源)。 */
|
|
276
|
+
export const IM_COMMAND_CAPABILITY_TABLE = WECHAT_CAPABILITY_TABLE;
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 切换会话时写 `im-bots.json` 对应通道的 `sessionId/sessionTitle`(0600 原子写,保留其它段)。
|
|
280
|
+
* 各通道 controller 会热更新并在下一次入站用新会话。
|
|
281
|
+
*/
|
|
282
|
+
export function writeImBotsSession(relayDir, channel, { sessionId, sessionTitle } = {}) {
|
|
283
|
+
const dir = String(relayDir || "");
|
|
284
|
+
const ch = String(channel || "");
|
|
285
|
+
if (!dir || !ch) return { ok: false, reason: "no_target" };
|
|
286
|
+
const sid = String(sessionId || "").trim();
|
|
287
|
+
if (!sid) return { ok: false, reason: "no_session_id" };
|
|
288
|
+
try {
|
|
289
|
+
const file = path.join(dir, IM_BOTS_FILE);
|
|
290
|
+
const prev = readJsonFile(file);
|
|
291
|
+
const base = prev && typeof prev === "object" && !Array.isArray(prev) ? prev : {};
|
|
292
|
+
const cur = base[ch] && typeof base[ch] === "object" ? base[ch] : {};
|
|
293
|
+
atomicWriteJson(file, {
|
|
294
|
+
...base,
|
|
295
|
+
version: 1,
|
|
296
|
+
[ch]: { ...cur, sessionId: sid, sessionTitle: truncateTitle(sessionTitle || "", 60) }
|
|
297
|
+
}, { onWarn: () => { /* 静默 */ } });
|
|
298
|
+
return { ok: true };
|
|
299
|
+
} catch (e) {
|
|
300
|
+
return { ok: false, reason: e && e.message ? e.message : String(e) };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -53,6 +53,9 @@ import WebSocket from "ws";
|
|
|
53
53
|
import { createEventSubscriber, NODE_KINDS } from "./dsh-events.mjs";
|
|
54
54
|
// 通道感知格式化(2026-11-08:企微/钉钉 markdown 不支持表格,发前统一适配;飞书/微信各有分支)
|
|
55
55
|
import { formatForChannel } from "./im-format.mjs";
|
|
56
|
+
// 命令层(2026-11-08):/help /status /stop /ls /use /unbind 在**进模型前**拦下执行;
|
|
57
|
+
// 解析/文案/能力门禁三家共用(与会员升级文案同口径)。
|
|
58
|
+
import { executeImCommand, writeImBotsSession } from "./im-commands.mjs";
|
|
56
59
|
// 会员能力表 = **单一真相源**:直接复用微信通道那套(WECHAT_CAPABILITY_TABLE / limitsFor / grantsCap),
|
|
57
60
|
// 不另造一套。企微/飞书/钉钉将来都套同一套判定(见 research/.../im-channel-membership-design.md)。
|
|
58
61
|
import {
|
|
@@ -716,8 +719,8 @@ export function createWecomBot(options = {}) {
|
|
|
716
719
|
const approvalCardTtlMs = positiveNumber(options.approvalCardTtlMs, APPROVAL_CARD_TTL_MS);
|
|
717
720
|
const onApprovalDecision = typeof options.onApprovalDecision === "function" ? options.onApprovalDecision : null;
|
|
718
721
|
// 会话可选绑定(task-24):配置固定的会话(空 = 跟随活跃会话)
|
|
719
|
-
|
|
720
|
-
|
|
722
|
+
let configuredSessionId = String(options.configuredSessionId || "").trim();
|
|
723
|
+
let configuredSessionTitle = truncateText(options.configuredSessionTitle || "", 60);
|
|
721
724
|
// ── 会员判定(2026-11-07 业主拍板:通知免费、审批免费、互动半阉割)──
|
|
722
725
|
// capsProvider 由 bridge 注入(复用微信通道的 resolveWechatTier:字符串档位或 {plan,caps,limits,rev});
|
|
723
726
|
// 拿不到 / 未注入 → **按 free fail-closed**。
|
|
@@ -1367,8 +1370,87 @@ export function createWecomBot(options = {}) {
|
|
|
1367
1370
|
}
|
|
1368
1371
|
|
|
1369
1372
|
/** 把入站文本转给 DSH 会话(尽力,失败给用户一句人话)。 */
|
|
1373
|
+
/**
|
|
1374
|
+
* 命令层(三家共用 `im-commands.mjs`):命中返回 true(已回复,调用方直接 return)。
|
|
1375
|
+
* 回复走被动回复/主动推送(replyText),不经过模型、不计额度。
|
|
1376
|
+
*/
|
|
1377
|
+
async function handleCommand(frame, info) {
|
|
1378
|
+
const text = String((info && info.text) || "");
|
|
1379
|
+
if (!/^\s*\//.test(text)) return false; // 快路径:非命令不构造 ctx
|
|
1380
|
+
const cmd = await executeImCommand({
|
|
1381
|
+
text,
|
|
1382
|
+
ctx: {
|
|
1383
|
+
caps: capsNow(),
|
|
1384
|
+
tier: tierNow(),
|
|
1385
|
+
appUrl,
|
|
1386
|
+
channelLabel: "企业微信",
|
|
1387
|
+
sessions: async () => {
|
|
1388
|
+
try { await refreshSessions({ force: true }); } catch { /* 忽略 */ }
|
|
1389
|
+
return sessionList.map((x) => ({ sessionId: x.id, title: x.title, updatedAt: x.updatedAt, running: false }));
|
|
1390
|
+
},
|
|
1391
|
+
currentSessionId: () => {
|
|
1392
|
+
const known = boundSessionId || activeSession.id || "";
|
|
1393
|
+
if (known) return known;
|
|
1394
|
+
if (configuredSessionId && canCap("sessions")) return configuredSessionId;
|
|
1395
|
+
if (sessionList.length) return sessionList[0].id;
|
|
1396
|
+
const sub = subscriber;
|
|
1397
|
+
try {
|
|
1398
|
+
if (sub && typeof sub.sessions === "function") {
|
|
1399
|
+
const ids = sub.sessions();
|
|
1400
|
+
if (Array.isArray(ids) && ids.length) return String(ids[0]);
|
|
1401
|
+
}
|
|
1402
|
+
} catch { /* 忽略 */ }
|
|
1403
|
+
return "";
|
|
1404
|
+
},
|
|
1405
|
+
currentSessionTitle: () => titleOfSession(boundSessionId || activeSession.id, activeSession.title || ""),
|
|
1406
|
+
cancelSession: async ({ sessionId }) => {
|
|
1407
|
+
const sub = subscriber;
|
|
1408
|
+
if (!sub || typeof sub.cancelSession !== "function") return { ok: false, message: "订阅器未就绪" };
|
|
1409
|
+
try {
|
|
1410
|
+
const r = await sub.cancelSession({ sessionId });
|
|
1411
|
+
return r && typeof r === "object" ? r : { ok: Boolean(r) };
|
|
1412
|
+
} catch (e) { return { ok: false, message: e && e.message ? e.message : String(e) }; }
|
|
1413
|
+
},
|
|
1414
|
+
switchSession: async ({ sessionId, title }) => {
|
|
1415
|
+
const r = writeImBotsSession(relayDir, "wecom", { sessionId, sessionTitle: title });
|
|
1416
|
+
if (r.ok) {
|
|
1417
|
+
configuredSessionId = sessionId;
|
|
1418
|
+
configuredSessionTitle = truncateText(title || "", 60);
|
|
1419
|
+
writeStatus();
|
|
1420
|
+
}
|
|
1421
|
+
return r;
|
|
1422
|
+
},
|
|
1423
|
+
unbind: async () => {
|
|
1424
|
+
state.boundChatId = "";
|
|
1425
|
+
writeStatus();
|
|
1426
|
+
return { ok: true };
|
|
1427
|
+
},
|
|
1428
|
+
statusSnapshot: async () => ({
|
|
1429
|
+
connected: state.connected === true,
|
|
1430
|
+
authenticated: state.authenticated === true,
|
|
1431
|
+
state: state.connected ? "connected" : "idle",
|
|
1432
|
+
lastError: state.lastError || "",
|
|
1433
|
+
tier: tierNow(),
|
|
1434
|
+
usage: { month: monthKey(), messages: usageThisMonth() },
|
|
1435
|
+
messagesPerMonth: messageLimitNow(),
|
|
1436
|
+
remainingMessages: messageLimitNow() ? Math.max(0, messageLimitNow() - usageThisMonth()) : null,
|
|
1437
|
+
currentSessionId: boundSessionId || activeSession.id || "",
|
|
1438
|
+
currentSessionTitle: titleOfSession(boundSessionId || activeSession.id, activeSession.title || "")
|
|
1439
|
+
})
|
|
1440
|
+
}
|
|
1441
|
+
});
|
|
1442
|
+
if (!cmd || !cmd.handled) return false;
|
|
1443
|
+
replyText(frame, cmd.text);
|
|
1444
|
+
emitter.emit("command", { ...info, command: cmd.command, ok: cmd.ok === true, gated: cmd.gated === true });
|
|
1445
|
+
log.info(`命令 ${cmd.command}${cmd.gated ? "(会员闸拦截)" : ""} 已回复(不转模型)`);
|
|
1446
|
+
return true;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1370
1449
|
async function dispatchInbound(frame, info) {
|
|
1371
1450
|
const sub = subscriber;
|
|
1451
|
+
// ⓪ 命令层:命中即执行并回复,**绝不进模型**,也不计入额度(/stop、/unbind 永远免费)
|
|
1452
|
+
const cmd = await handleCommand(frame, info);
|
|
1453
|
+
if (cmd) return;
|
|
1372
1454
|
// ① 会员闸(通知/审批免费;派活受能力与月度额度约束,fail-closed)
|
|
1373
1455
|
const gated = await membershipGate(frame, info);
|
|
1374
1456
|
if (gated) return;
|
package/runtime/dsh-setup.mjs
CHANGED
|
@@ -149,7 +149,7 @@ function findDepWs(startDir) {
|
|
|
149
149
|
* 漏同步就会出现「入口脚本在、依赖不在」的半装运行时(插件半据此判定「运行环境已就绪」,
|
|
150
150
|
* 却永远起不来 bridge —— 2026-09-23 用户反馈的现场)。`src/channel-ref.mjs` 同理(本安装器也 import)。
|
|
151
151
|
*/
|
|
152
|
-
const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "wecom-bot.mjs", "im-format.mjs", "dingtalk-bot.mjs", "dingtalk-auth.mjs", "feishu-bot.mjs", "src/lifecycle.mjs", "src/channel-ref.mjs"];
|
|
152
|
+
const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "wecom-bot.mjs", "im-format.mjs", "dingtalk-bot.mjs", "dingtalk-auth.mjs", "im-commands.mjs", "feishu-bot.mjs", "src/lifecycle.mjs", "src/channel-ref.mjs"];
|
|
153
153
|
|
|
154
154
|
/**
|
|
155
155
|
* 把"客户端脚本"补齐到配置目录。
|