@mrrisega/dsh-remote 0.7.12-beta.5 → 0.8.0
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/clients/dsh-remote/apply-pending-plugin.mjs +171 -0
- package/clients/dsh-remote/dingtalk-bot.mjs +283 -63
- package/clients/dsh-remote/feishu-bot.mjs +127 -1
- package/clients/dsh-remote/im-commands.mjs +302 -0
- package/clients/dsh-remote/im-format.mjs +105 -0
- package/clients/dsh-remote/test/dingtalk-bot.test.mjs +297 -1
- package/clients/dsh-remote/test/feishu-bot.test.mjs +108 -3
- package/clients/dsh-remote/test/im-caps-fixture.mjs +8 -0
- package/clients/dsh-remote/test/im-commands.test.mjs +283 -0
- package/clients/dsh-remote/test/im-format.test.mjs +60 -0
- package/clients/dsh-remote/test/wecom-bot.test.mjs +104 -89
- package/clients/dsh-remote/wecom-bot.mjs +138 -102
- package/dsh-setup.mjs +1 -1
- package/package.json +2 -2
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dingtalk-auth.mjs +631 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dingtalk-bot.mjs +249 -32
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +35 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/feishu-bot.mjs +101 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/im-commands.mjs +302 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +84 -2
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +1 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 手动应用「待安装的插件更新」(桌面端专用)。
|
|
4
|
+
*
|
|
5
|
+
* 背景:桌面端应用运行期间**不能**改写它的 profile 目录(会让应用退出/重启)。
|
|
6
|
+
* 正常路径是:面板点「检查更新」→ 更新被暂存到 ~/.dsh-remote/pending-plugin/ +
|
|
7
|
+
* 写 `.plugin-update-pending.json` → 桥在**检测到应用退出后**自动替换。
|
|
8
|
+
*
|
|
9
|
+
* 但实测(2026-11-10)在部分机器上桥的"应用退出检测"没能触发(应用重启太快 /
|
|
10
|
+
* 桥被 launchd 重启导致计时清零 / 端口探测误判),于是用户反复重启仍停在旧版。
|
|
11
|
+
* 这个脚本就是那条链路的**手动、确定性版本**:应用完全退出后跑一次即可。
|
|
12
|
+
*
|
|
13
|
+
* 用法:
|
|
14
|
+
* node ~/.dsh-remote/clients/dsh-remote/apply-pending-plugin.mjs
|
|
15
|
+
* node ... --force # 跳过"应用是否在运行"的检查(仅在确认已退出时用)
|
|
16
|
+
* node ... --dry-run # 只看会做什么,不动文件
|
|
17
|
+
*
|
|
18
|
+
* 安全:先复制到 `<插件目录>.staging-<pid>` → 校验必需文件 → 原子 rename 替换;
|
|
19
|
+
* 失败保留旧版并回滚。绝不删除用户数据。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import net from "node:net";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import os from "node:os";
|
|
26
|
+
import { execSync } from "node:child_process";
|
|
27
|
+
|
|
28
|
+
const RELAY_DIR = process.env.DSH_RELAY_DIR || path.join(os.homedir(), ".dsh-remote");
|
|
29
|
+
const MARKER = path.join(RELAY_DIR, ".plugin-update-pending.json");
|
|
30
|
+
const APPLIED = path.join(RELAY_DIR, ".plugin-update-applied.json");
|
|
31
|
+
const REQUIRED = ["package.json", "lib/index.js", "lib/client.js", "cordis.patch.yml"];
|
|
32
|
+
|
|
33
|
+
const args = new Set(process.argv.slice(2));
|
|
34
|
+
const force = args.has("--force");
|
|
35
|
+
const dryRun = args.has("--dry-run");
|
|
36
|
+
|
|
37
|
+
function fail(msg) {
|
|
38
|
+
console.error(`✖ ${msg}`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
function ok(msg) {
|
|
42
|
+
console.log(`✔ ${msg}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readJson(file) {
|
|
46
|
+
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 桌面应用的 web 上游是否还活着(活=应用没退干净)。 */
|
|
50
|
+
function probePort(port, timeoutMs = 500) {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
if (!port) return resolve(false);
|
|
53
|
+
const sock = net.connect({ host: "127.0.0.1", port });
|
|
54
|
+
let done = false;
|
|
55
|
+
const fin = (v) => { if (done) return; done = true; try { sock.destroy(); } catch { /* 忽略 */ } resolve(v); };
|
|
56
|
+
sock.setTimeout(timeoutMs, () => fin(false));
|
|
57
|
+
sock.once("connect", () => fin(true));
|
|
58
|
+
sock.once("error", () => fin(false));
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 桌面应用进程是否在跑(macOS:DeepSeek Harness;Linux/其它:dsh web / electron)。 */
|
|
63
|
+
function appProcessRunning() {
|
|
64
|
+
try {
|
|
65
|
+
const out = execSync("ps -axo pid=,command=", { encoding: "utf8", timeout: 4000 });
|
|
66
|
+
const self = process.pid;
|
|
67
|
+
return out
|
|
68
|
+
.split("\n")
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.some((line) => {
|
|
71
|
+
const m = line.trim().match(/^(\d+)\s+(.*)$/);
|
|
72
|
+
if (!m) return false;
|
|
73
|
+
if (Number(m[1]) === self) return false;
|
|
74
|
+
const cmd = m[2];
|
|
75
|
+
if (/apply-pending-plugin\.mjs/.test(cmd)) return false;
|
|
76
|
+
return /DeepSeek Harness\.app\/Contents\/MacOS\/DeepSeek Harness/.test(cmd)
|
|
77
|
+
|| /(?:^|\s)dsh(?:\s|$)/.test(cmd) && /web/.test(cmd)
|
|
78
|
+
|| /electron.*dsh/i.test(cmd);
|
|
79
|
+
});
|
|
80
|
+
} catch {
|
|
81
|
+
return false; // 检测不可用 → 不阻塞(有 --force 语义兜底)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 从状态/上次日志里猜上游端口(应用每次启动端口随机)。 */
|
|
86
|
+
function guessUpstreamPort() {
|
|
87
|
+
const st = readJson(path.join(RELAY_DIR, ".dsh-bridge-state.json")) || {};
|
|
88
|
+
for (const k of ["upstream", "upstream_url", "UPSTREAM"]) {
|
|
89
|
+
const v = st[k];
|
|
90
|
+
if (typeof v === "string" && /:\d+/.test(v)) {
|
|
91
|
+
try { return Number(new URL(v).port) || 0; } catch { /* 继续 */ }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function main() {
|
|
98
|
+
const marker = readJson(MARKER);
|
|
99
|
+
if (!marker) {
|
|
100
|
+
console.log("没有待安装的插件更新(marker 不存在)。");
|
|
101
|
+
console.log("如果面板刚点过「检查更新」,请先回到面板再点一次,然后再运行本命令。");
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
const stageDir = String(marker.stageDir || "");
|
|
105
|
+
const profileDir = String(marker.profileDir || "");
|
|
106
|
+
const name = String(marker.pluginDirName || "dsh-remote-web-plugin");
|
|
107
|
+
const version = String(marker.version || "?");
|
|
108
|
+
if (!stageDir || !profileDir || !fs.existsSync(stageDir)) {
|
|
109
|
+
fail(`暂存目录不存在:${stageDir || "(空)"}。请回面板重新点一次「检查更新」。`);
|
|
110
|
+
}
|
|
111
|
+
console.log(`待安装版本:${version}`);
|
|
112
|
+
console.log(`暂存目录:${stageDir}`);
|
|
113
|
+
console.log(`目标插件:${path.join(profileDir, name)}`);
|
|
114
|
+
|
|
115
|
+
const missing = REQUIRED.filter((rel) => !fs.existsSync(path.join(stageDir, rel)));
|
|
116
|
+
if (missing.length) fail(`暂存包不完整,缺少:${missing.join(", ")}。请回面板重新更新。`);
|
|
117
|
+
|
|
118
|
+
const port = guessUpstreamPort();
|
|
119
|
+
const portAlive = port ? await probePort(port) : false;
|
|
120
|
+
const procAlive = appProcessRunning();
|
|
121
|
+
const running = portAlive || procAlive;
|
|
122
|
+
if (running && !force) {
|
|
123
|
+
fail(
|
|
124
|
+
`检测到桌面应用仍在运行(${portAlive ? `127.0.0.1:${port} 在监听` : "进程还在"})。\n` +
|
|
125
|
+
` 请先 Cmd+Q **完全退出** DeepSeek Harness(不是关窗口),再运行本命令。\n` +
|
|
126
|
+
` 若你确认已退出仍报这个错:加 --force 跳过检查。`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (running && force) console.warn(`⚠️ 应用可能仍在运行(端口 ${port} 在监听),按 --force 继续;若应用崩溃请重开。`);
|
|
130
|
+
|
|
131
|
+
const dest = path.join(profileDir, name);
|
|
132
|
+
const stage = `${dest}.staging-${process.pid}`;
|
|
133
|
+
const backup = `${dest}.backup-${process.pid}`;
|
|
134
|
+
|
|
135
|
+
if (dryRun) {
|
|
136
|
+
console.log(`[dry-run] 将把 ${stageDir} 原子替换到 ${dest}(当前版本 ${readJson(path.join(dest, "package.json"))?.version || "?"})`);
|
|
137
|
+
process.exit(0);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
142
|
+
fs.cpSync(stageDir, stage, { recursive: true });
|
|
143
|
+
for (const rel of REQUIRED) {
|
|
144
|
+
if (!fs.existsSync(path.join(stage, rel))) throw new Error(`暂存包缺少 ${rel}`);
|
|
145
|
+
}
|
|
146
|
+
const had = fs.existsSync(dest);
|
|
147
|
+
if (had) {
|
|
148
|
+
fs.rmSync(backup, { recursive: true, force: true });
|
|
149
|
+
fs.renameSync(dest, backup);
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
fs.renameSync(stage, dest);
|
|
153
|
+
} catch (e) {
|
|
154
|
+
if (had) { try { fs.renameSync(backup, dest); } catch { /* 忽略 */ } }
|
|
155
|
+
throw e;
|
|
156
|
+
}
|
|
157
|
+
if (had) { try { fs.rmSync(backup, { recursive: true, force: true }); } catch { /* 忽略 */ } }
|
|
158
|
+
try {
|
|
159
|
+
fs.writeFileSync(APPLIED, JSON.stringify({ version, appliedAt: Date.now(), profileDir, via: "manual-cli" }, null, 2) + "\n");
|
|
160
|
+
} catch { /* 忽略 */ }
|
|
161
|
+
try { fs.rmSync(MARKER, { force: true }); fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* 忽略 */ }
|
|
162
|
+
const nowVer = readJson(path.join(dest, "package.json"))?.version || "?";
|
|
163
|
+
ok(`插件已更新:${version} → 当前 ${nowVer}`);
|
|
164
|
+
console.log("现在可以重新打开 DeepSeek Harness(打开后版本与更新应显示新版本)。");
|
|
165
|
+
} catch (e) {
|
|
166
|
+
try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* 忽略 */ }
|
|
167
|
+
fail(`安装失败(已保留旧版):${e && e.message ? e.message : e}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
main();
|
|
@@ -50,7 +50,14 @@ import { EventEmitter } from "node:events";
|
|
|
50
50
|
import WebSocket from "ws";
|
|
51
51
|
|
|
52
52
|
import { createEventSubscriber, NODE_KINDS } from "./dsh-events.mjs";
|
|
53
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
SUMMARY_MAX_CHARS,
|
|
55
|
+
buildTurnNotification,
|
|
56
|
+
extractTurnSummary,
|
|
57
|
+
formatForChannel
|
|
58
|
+
} from "./im-format.mjs";
|
|
59
|
+
// 命令层(2026-11-08):/help /status /stop /ls /use /unbind 在进模型前拦下执行(三家共用)
|
|
60
|
+
import { executeImCommand, writeImBotsSession } from "./im-commands.mjs";
|
|
54
61
|
import {
|
|
55
62
|
WECHAT_CAPABILITY_TABLE,
|
|
56
63
|
WECHAT_TIER_LIMITS,
|
|
@@ -91,6 +98,15 @@ export const TOPIC_CARD_CALLBACK = "/v1.0/card/instances/callback";
|
|
|
91
98
|
export const DEFAULT_SUBSCRIPTIONS = Object.freeze([{ type: "CALLBACK", topic: TOPIC_ROBOT }]);
|
|
92
99
|
/** ticket 有效期(官方:90s / 一次性)。状态文件只记过期时间,不存 ticket。 */
|
|
93
100
|
export const TICKET_TTL_MS = 90_000;
|
|
101
|
+
/**
|
|
102
|
+
* 回复通道持久化文件(相对 relayDir;0600):缓存最近一次入站带来的
|
|
103
|
+
* `{sessionWebhook, sessionWebhookExpiredTime, senderStaffId, conversationId, conversationType, boundChatId, at}`。
|
|
104
|
+
* 为什么不写状态文件:sessionWebhook 里带会话令牌,状态文件是给面板/浏览器看的(只放元数据)。
|
|
105
|
+
* 为什么要落盘:进程重启后「回答回传」仍要能发出去(Lead 实测 bug:只存内存会丢,回传直接发不出)。
|
|
106
|
+
*/
|
|
107
|
+
export const REPLY_FILE = "im-dingtalk-reply.json";
|
|
108
|
+
/** webhook 过期后降级 openapi 时给用户/日志的明确原因(面板据此提示"再发一条消息激活")。 */
|
|
109
|
+
const NO_REPLY_CHANNEL_HINT = "回复通道不可用:请在钉钉里再发一条消息激活(或配置 robotCode 走 OpenAPI)";
|
|
94
110
|
/** 重连退避:1s 起、翻倍、60s 封顶(+0~1s 抖动,照官方 SDK 行为)。 */
|
|
95
111
|
export const RECONNECT_BASE_MS = 1_000;
|
|
96
112
|
export const RECONNECT_MAX_MS = 60_000;
|
|
@@ -189,8 +205,8 @@ export function normalizeBotMessage(data) {
|
|
|
189
205
|
sessionWebhookExpiredTime: Number(d.sessionWebhookExpiredTime) || 0,
|
|
190
206
|
robotCode: String(d.robotCode || ""),
|
|
191
207
|
createAt: Number(d.createAt) || 0,
|
|
192
|
-
/** 主动发消息/去重用的目标:群=conversationId,单聊=senderStaffId
|
|
193
|
-
chatId: conversationId || senderStaffId,
|
|
208
|
+
/** 主动发消息/去重用的目标:群=conversationId,单聊=senderStaffId(单聊 conversationId 不能当 chatId,否则会被误判成群聊)。 */
|
|
209
|
+
chatId: (conversationType === "2" ? conversationId : senderStaffId) || conversationId || senderStaffId,
|
|
194
210
|
raw: d
|
|
195
211
|
};
|
|
196
212
|
}
|
|
@@ -277,8 +293,8 @@ export function createDingtalkBot(options = {}) {
|
|
|
277
293
|
const relayDir = options.relayDir ? String(options.relayDir) : "";
|
|
278
294
|
const allowFrom = normalizeAllowList(options.allowFrom);
|
|
279
295
|
const targetChatId = String(options.targetChatId || "").trim();
|
|
280
|
-
|
|
281
|
-
|
|
296
|
+
let configuredSessionId = String(options.configuredSessionId || "").trim();
|
|
297
|
+
let configuredSessionTitle = truncateText(options.configuredSessionTitle || "", 60);
|
|
282
298
|
const now = typeof options.clock === "function" ? options.clock : () => Date.now();
|
|
283
299
|
const doFetch = typeof options.fetchImpl === "function" ? options.fetchImpl : globalThis.fetch;
|
|
284
300
|
const wsFactory = typeof options.wsFactory === "function" ? options.wsFactory : (url) => new WebSocket(url);
|
|
@@ -322,7 +338,9 @@ export function createDingtalkBot(options = {}) {
|
|
|
322
338
|
boundSessionWebhook: "",
|
|
323
339
|
boundSessionWebhookExpiredAt: 0,
|
|
324
340
|
messages: 0,
|
|
325
|
-
gated: 0
|
|
341
|
+
gated: 0,
|
|
342
|
+
/** 最近一次回复失败原因(人话;写状态 replyChannel.lastError,不覆盖连接错误)。 */
|
|
343
|
+
replyChannelError: ""
|
|
326
344
|
};
|
|
327
345
|
const membership = { tier: "", entitlements: null, checkedAt: 0, usage: { month: "", count: 0 } };
|
|
328
346
|
|
|
@@ -355,6 +373,19 @@ export function createDingtalkBot(options = {}) {
|
|
|
355
373
|
// 配置里持久化的 sessionWebhook(插件/上次运行留下的):首次入站前也能主动发;
|
|
356
374
|
// ⚠️ webhook 里带会话令牌,**绝不写状态文件**,只在内存里用。
|
|
357
375
|
if (options.sessionWebhook) state.boundSessionWebhook = String(options.sessionWebhook || "");
|
|
376
|
+
// 上次入站缓存的回复通道(跨重启可用):im-dingtalk-reply.json(0600)
|
|
377
|
+
if (relayDir) {
|
|
378
|
+
try {
|
|
379
|
+
const saved = readJsonFile(path.join(relayDir, REPLY_FILE));
|
|
380
|
+
if (saved && typeof saved === "object") {
|
|
381
|
+
if (typeof saved.sessionWebhook === "string" && saved.sessionWebhook) state.boundSessionWebhook = saved.sessionWebhook;
|
|
382
|
+
if (Number.isFinite(Number(saved.sessionWebhookExpiredTime))) state.boundSessionWebhookExpiredAt = Number(saved.sessionWebhookExpiredTime);
|
|
383
|
+
if (typeof saved.senderStaffId === "string" && saved.senderStaffId) state.boundUserId = saved.senderStaffId;
|
|
384
|
+
if (typeof saved.conversationId === "string" && saved.conversationId) state.boundConversationId = saved.conversationId;
|
|
385
|
+
if (typeof saved.boundChatId === "string" && saved.boundChatId) state.boundChatId = saved.boundChatId;
|
|
386
|
+
}
|
|
387
|
+
} catch { /* 忽略坏文件 */ }
|
|
388
|
+
}
|
|
358
389
|
if (targetChatId && !state.boundChatId) state.boundChatId = targetChatId;
|
|
359
390
|
|
|
360
391
|
// 重启恢复:用量 / 绑定目标 / 档位快照(均不含凭据)
|
|
@@ -502,6 +533,8 @@ export function createDingtalkBot(options = {}) {
|
|
|
502
533
|
credentialSource,
|
|
503
534
|
robotCodeConfigured: Boolean(robotCode),
|
|
504
535
|
capabilities: capabilitiesNow(),
|
|
536
|
+
// 回复通道三层降级(面板显示「可用/需重新发一条消息激活」;不含 webhook 原值)
|
|
537
|
+
replyChannel: replyChannelStatus(),
|
|
505
538
|
// 会员 + 用量(与企微对齐)
|
|
506
539
|
tier: tierNow(),
|
|
507
540
|
caps: capsNow().slice(),
|
|
@@ -760,7 +793,30 @@ export function createDingtalkBot(options = {}) {
|
|
|
760
793
|
}
|
|
761
794
|
}
|
|
762
795
|
|
|
763
|
-
/**
|
|
796
|
+
/** 写回复通道缓存到磁盘(0600;含会话令牌,绝不进状态/日志)。 */
|
|
797
|
+
function persistReplyChannel() {
|
|
798
|
+
if (!relayDir) return;
|
|
799
|
+
try {
|
|
800
|
+
atomicWriteJson(path.join(relayDir, REPLY_FILE), {
|
|
801
|
+
version: 1,
|
|
802
|
+
sessionWebhook: state.boundSessionWebhook || "",
|
|
803
|
+
sessionWebhookExpiredTime: Number(state.boundSessionWebhookExpiredAt) || 0,
|
|
804
|
+
senderStaffId: state.boundUserId || "",
|
|
805
|
+
conversationId: state.boundConversationId || "",
|
|
806
|
+
boundChatId: state.boundChatId || "",
|
|
807
|
+
at: now()
|
|
808
|
+
}, { onWarn: (m) => log.warn(m) });
|
|
809
|
+
} catch (e) {
|
|
810
|
+
log.warn(`回复通道缓存写入失败(忽略):${truncateText(e && e.message ? e.message : e, 100)}`);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** 日志/状态里只用 webhook 的 host(整条 URL 含会话令牌,绝不外泄)。 */
|
|
815
|
+
function webhookHost(url) {
|
|
816
|
+
try { return new URL(String(url)).host; } catch { return chatIdHash(String(url)) || "?"; }
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** 更新「回复目标」:缓存 sessionWebhook(被动回复)+ senderStaffId/conversationId(openapi 降级用)。 */
|
|
764
820
|
function bindTarget(info) {
|
|
765
821
|
if (info.conversationId) state.boundConversationId = info.conversationId;
|
|
766
822
|
if (info.senderStaffId) state.boundUserId = info.senderStaffId;
|
|
@@ -769,9 +825,13 @@ export function createDingtalkBot(options = {}) {
|
|
|
769
825
|
log.info(`已绑定推送会话 chat=${chatIdHash(info.chatId)}${info.isGroup ? "(群)" : "(单聊)"}`);
|
|
770
826
|
}
|
|
771
827
|
if (info.sessionWebhook) {
|
|
828
|
+
const changed = info.sessionWebhook !== state.boundSessionWebhook;
|
|
772
829
|
state.boundSessionWebhook = info.sessionWebhook;
|
|
773
830
|
state.boundSessionWebhookExpiredAt = info.sessionWebhookExpiredTime || 0;
|
|
831
|
+
state.replyChannelError = "";
|
|
832
|
+
if (changed) log.info(`已缓存回复通道 sessionWebhook(host=${webhookHost(info.sessionWebhook)},过期=${state.boundSessionWebhookExpiredAt ? new Date(state.boundSessionWebhookExpiredAt).toISOString() : "未知"})`);
|
|
774
833
|
}
|
|
834
|
+
persistReplyChannel();
|
|
775
835
|
writeStatus();
|
|
776
836
|
}
|
|
777
837
|
|
|
@@ -803,9 +863,95 @@ export function createDingtalkBot(options = {}) {
|
|
|
803
863
|
void dispatchInbound(info).catch((e) => log.warn(`入站转发失败(已忽略):${truncateText(e && e.message ? e.message : e, 120)}`));
|
|
804
864
|
}
|
|
805
865
|
|
|
806
|
-
/**
|
|
866
|
+
/** 命令层:命中已回复返回 true(不转模型、不计额度;回复走三层回复通道)。 */
|
|
867
|
+
async function handleCommand(info) {
|
|
868
|
+
const text = String((info && info.text) || "");
|
|
869
|
+
if (!/^\s*\//.test(text)) return false;
|
|
870
|
+
const cmd = await executeImCommand({
|
|
871
|
+
text,
|
|
872
|
+
ctx: {
|
|
873
|
+
caps: capsNow(),
|
|
874
|
+
tier: tierNow(),
|
|
875
|
+
appUrl: String(options.appUrl || ""),
|
|
876
|
+
channelLabel: "钉钉",
|
|
877
|
+
sessions: async () => {
|
|
878
|
+
const sub = subscriber;
|
|
879
|
+
if (!sub) return [];
|
|
880
|
+
let rows = [];
|
|
881
|
+
try {
|
|
882
|
+
if (typeof sub.listSessions === "function") {
|
|
883
|
+
const r = await sub.listSessions();
|
|
884
|
+
rows = Array.isArray(r) ? r : (r && r.ok && Array.isArray(r.sessions) ? r.sessions : []);
|
|
885
|
+
}
|
|
886
|
+
} catch { rows = []; }
|
|
887
|
+
if ((!Array.isArray(rows) || rows.length === 0) && typeof sub.sessions === "function") {
|
|
888
|
+
try { rows = (sub.sessions() || []).map((id) => ({ sessionId: String(id) })); } catch { rows = []; }
|
|
889
|
+
}
|
|
890
|
+
return (Array.isArray(rows) ? rows : []).map((x) => ({
|
|
891
|
+
sessionId: String(x.sessionId || x.id || ""),
|
|
892
|
+
title: String(x.title || ""),
|
|
893
|
+
updatedAt: Number(x.updatedAt) || 0,
|
|
894
|
+
running: x.running === true
|
|
895
|
+
})).filter((x) => x.sessionId);
|
|
896
|
+
},
|
|
897
|
+
currentSessionId: () => {
|
|
898
|
+
const known = boundSessionId || lastSeenSessionId || "";
|
|
899
|
+
if (known) return known;
|
|
900
|
+
if (configuredSessionId && canCap("sessions")) return configuredSessionId;
|
|
901
|
+
const sub = subscriber;
|
|
902
|
+
try {
|
|
903
|
+
if (sub && typeof sub.sessions === "function") {
|
|
904
|
+
const ids = sub.sessions();
|
|
905
|
+
if (Array.isArray(ids) && ids.length) return String(ids[0]);
|
|
906
|
+
}
|
|
907
|
+
} catch { /* 忽略 */ }
|
|
908
|
+
return "";
|
|
909
|
+
},
|
|
910
|
+
currentSessionTitle: () => "",
|
|
911
|
+
cancelSession: async ({ sessionId }) => {
|
|
912
|
+
const sub = subscriber;
|
|
913
|
+
if (!sub || typeof sub.cancelSession !== "function") return { ok: false, message: "订阅器未就绪" };
|
|
914
|
+
try {
|
|
915
|
+
const r = await sub.cancelSession({ sessionId });
|
|
916
|
+
return r && typeof r === "object" ? r : { ok: Boolean(r) };
|
|
917
|
+
} catch (e) { return { ok: false, message: e && e.message ? e.message : String(e) }; }
|
|
918
|
+
},
|
|
919
|
+
switchSession: async ({ sessionId, title }) => {
|
|
920
|
+
const r = writeImBotsSession(relayDir, "dingtalk", { sessionId, sessionTitle: title });
|
|
921
|
+
if (r.ok) {
|
|
922
|
+
configuredSessionId = sessionId;
|
|
923
|
+
configuredSessionTitle = truncateText(title || "", 60);
|
|
924
|
+
boundSessionId = sessionId;
|
|
925
|
+
writeStatus();
|
|
926
|
+
}
|
|
927
|
+
return r;
|
|
928
|
+
},
|
|
929
|
+
unbind: async () => { clearReplyChannel(); return { ok: true }; },
|
|
930
|
+
statusSnapshot: async () => ({
|
|
931
|
+
connected: state.connected === true,
|
|
932
|
+
authenticated: state.authenticated === true,
|
|
933
|
+
state: state.phase,
|
|
934
|
+
lastError: state.lastError || "",
|
|
935
|
+
tier: tierNow(),
|
|
936
|
+
usage: { month: monthKey(), messages: usageThisMonth() },
|
|
937
|
+
messagesPerMonth: messageLimitNow(),
|
|
938
|
+
remainingMessages: messageLimitNow() ? Math.max(0, messageLimitNow() - usageThisMonth()) : null,
|
|
939
|
+
currentSessionId: boundSessionId || lastSeenSessionId || "",
|
|
940
|
+
currentSessionTitle: ""
|
|
941
|
+
})
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
if (!cmd || !cmd.handled) return false;
|
|
945
|
+
reply(info, cmd.text);
|
|
946
|
+
emitter.emit("command", { ...info, command: cmd.command, ok: cmd.ok === true, gated: cmd.gated === true });
|
|
947
|
+
log.info(`命令 ${cmd.command}${cmd.gated ? "(会员闸拦截)" : ""} 已回复(不转模型)`);
|
|
948
|
+
return true;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/** 入站 → 命令层 → 会员闸 → 转发到 DSH 会话(尽力、可降级)。 */
|
|
807
952
|
async function dispatchInbound(info) {
|
|
808
953
|
const sub = subscriber;
|
|
954
|
+
if (await handleCommand(info)) return;
|
|
809
955
|
const gated = await membershipGate(info);
|
|
810
956
|
if (gated) return;
|
|
811
957
|
const sessionId = await resolveSessionId(sub);
|
|
@@ -947,42 +1093,114 @@ export function createDingtalkBot(options = {}) {
|
|
|
947
1093
|
}
|
|
948
1094
|
|
|
949
1095
|
/**
|
|
950
|
-
*
|
|
951
|
-
*
|
|
952
|
-
*
|
|
953
|
-
*
|
|
1096
|
+
* 当前可用的回复通道(**三层降级**):
|
|
1097
|
+
* ① 缓存 sessionWebhook(未过期;被动回复,最快、不需要 token)
|
|
1098
|
+
* ② openapi(robotCode + senderStaffId/conversationId 都有)
|
|
1099
|
+
* ③ none(明确原因,面板提示"再发一条消息激活")
|
|
954
1100
|
*/
|
|
955
|
-
|
|
1101
|
+
function replyChannelNow() {
|
|
1102
|
+
const expiresAt = Number(state.boundSessionWebhookExpiredAt) || 0;
|
|
1103
|
+
const webhookUsable = Boolean(state.boundSessionWebhook) && (!expiresAt || now() < expiresAt);
|
|
1104
|
+
if (webhookUsable) return { kind: "sessionWebhook", webhook: state.boundSessionWebhook, expiresAt, reason: "" };
|
|
1105
|
+
if (robotCode && (state.boundUserId || state.boundConversationId)) {
|
|
1106
|
+
return {
|
|
1107
|
+
kind: "openapi",
|
|
1108
|
+
webhook: "",
|
|
1109
|
+
expiresAt: 0,
|
|
1110
|
+
reason: state.boundSessionWebhook ? "sessionWebhook_expired" : "no_webhook"
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
return {
|
|
1114
|
+
kind: "none",
|
|
1115
|
+
webhook: "",
|
|
1116
|
+
expiresAt,
|
|
1117
|
+
reason: state.boundSessionWebhook ? "sessionWebhook_expired" : (robotCode ? "no_target_user" : "no_robot_code"),
|
|
1118
|
+
lastError: NO_REPLY_CHANNEL_HINT
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/** 状态文件里的 replyChannel 元数据(**绝不含 webhook 原值**)。 */
|
|
1123
|
+
function replyChannelStatus() {
|
|
1124
|
+
const ch = replyChannelNow();
|
|
1125
|
+
return {
|
|
1126
|
+
kind: ch.kind,
|
|
1127
|
+
expiresAt: Number(ch.expiresAt) || 0,
|
|
1128
|
+
lastError: truncateText(maskSecrets(state.replyChannelError || "", [clientSecret]), 160),
|
|
1129
|
+
hasWebhook: Boolean(state.boundSessionWebhook)
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function markReplyChannelError(detail) {
|
|
1134
|
+
state.replyChannelError = truncateText(String(detail || ""), 160);
|
|
1135
|
+
writeStatus();
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* 统一回复入口(回答回传/通知/命令回复都走这里)。
|
|
1140
|
+
* ① 缓存 sessionWebhook → ② 失败/过期降级 openapi → ③ 都不可用:**明确日志 + 状态人话,绝不静默**。
|
|
1141
|
+
*/
|
|
1142
|
+
async function sendViaReplyChannel(text, opts = {}) {
|
|
956
1143
|
const title = opts.title || "";
|
|
957
|
-
const
|
|
958
|
-
const
|
|
959
|
-
|
|
1144
|
+
const preferredWebhook = String(opts.webhook || "").trim();
|
|
1145
|
+
const channel = replyChannelNow();
|
|
1146
|
+
const webhook = preferredWebhook || (channel.kind === "sessionWebhook" ? channel.webhook : "");
|
|
1147
|
+
if (webhook) {
|
|
960
1148
|
const r = await replySessionWebhook(webhook, { title, text });
|
|
961
|
-
if (r.ok)
|
|
962
|
-
|
|
1149
|
+
if (r.ok) {
|
|
1150
|
+
if (state.replyChannelError) { state.replyChannelError = ""; writeStatus(); }
|
|
1151
|
+
return { ...r, kind: "sessionWebhook" };
|
|
1152
|
+
}
|
|
1153
|
+
// JSDoc: 只记 host + 错误原因(整条 URL 带会话令牌,绝不外泄)
|
|
1154
|
+
log.warn(`sessionWebhook 回复失败(host=${webhookHost(webhook)},${truncateText(r.reason, 60)})→ 尝试 OpenAPI 兜底`);
|
|
1155
|
+
markReplyChannelError(`sessionWebhook 回复失败:${truncateText(r.reason, 80)};${NO_REPLY_CHANNEL_HINT}`);
|
|
1156
|
+
}
|
|
1157
|
+
if (robotCode && (state.boundUserId || state.boundConversationId)) {
|
|
1158
|
+
const fb = await sendOpenapi(text, { title });
|
|
1159
|
+
if (fb.ok) return { ...fb, kind: "openapi" };
|
|
1160
|
+
log.warn(`OpenAPI 兜底也失败:${truncateText(fb.reason, 80)}`);
|
|
1161
|
+
markReplyChannelError(`OpenAPI 回复失败:${truncateText(fb.reason, 80)};${NO_REPLY_CHANNEL_HINT}`);
|
|
1162
|
+
return { ok: false, kind: "none", reason: "no_reply_channel", detail: fb.reason || "" };
|
|
1163
|
+
}
|
|
1164
|
+
// 缺 robotCode:给更具体的可操作错误(与能力降级口径一致,不是笼统的"没通道")
|
|
1165
|
+
if (!robotCode) {
|
|
1166
|
+
const detail = "未配置 robotCode:openapi 主动发送不可用(接收消息与 sessionWebhook 回复不受影响)。"
|
|
1167
|
+
+ "请在插件「钉钉机器人」里补 robotCode。";
|
|
1168
|
+
log.warn(`no_robot_code:${detail}`);
|
|
1169
|
+
markReplyChannelError(`no_robot_code:${detail}`);
|
|
1170
|
+
return { ok: false, kind: "none", reason: "no_robot_code", detail, capabilities: capabilitiesNow() };
|
|
963
1171
|
}
|
|
964
|
-
const fallback = await sendOpenapi(text, { title });
|
|
965
|
-
if (fallback.ok) return fallback;
|
|
966
1172
|
const at = now();
|
|
967
|
-
if (at - lastNoTargetLogAt >
|
|
1173
|
+
if (at - lastNoTargetLogAt > 60_000) {
|
|
968
1174
|
lastNoTargetLogAt = at;
|
|
969
|
-
log.
|
|
1175
|
+
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
1176
|
}
|
|
971
|
-
|
|
1177
|
+
markReplyChannelError(`no_reply_channel:${NO_REPLY_CHANNEL_HINT}`);
|
|
1178
|
+
return { ok: false, kind: "none", reason: "no_reply_channel", detail: NO_REPLY_CHANNEL_HINT };
|
|
972
1179
|
}
|
|
973
1180
|
|
|
974
|
-
/**
|
|
1181
|
+
/** 兼容旧入口:通知/回答回传/命令回复统一走三层回复通道。 */
|
|
1182
|
+
async function sendMarkdown(text, opts = {}) {
|
|
1183
|
+
return sendViaReplyChannel(text, opts);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/** 回复一句人话:优先本次入站的 webhook,再走三层降级;失败不抛。 */
|
|
975
1187
|
function reply(info, text) {
|
|
976
|
-
const webhook = info && info.sessionWebhook ? info.sessionWebhook :
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1188
|
+
const webhook = info && info.sessionWebhook ? info.sessionWebhook : "";
|
|
1189
|
+
void sendViaReplyChannel(text, { webhook }).catch(() => {});
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/** 解绑推送(/unbind 命令用):清内存 + 删持久化回复通道 + 清状态绑定字段。 */
|
|
1193
|
+
function clearReplyChannel() {
|
|
1194
|
+
state.boundSessionWebhook = "";
|
|
1195
|
+
state.boundSessionWebhookExpiredAt = 0;
|
|
1196
|
+
state.boundUserId = "";
|
|
1197
|
+
state.boundConversationId = "";
|
|
1198
|
+
state.boundChatId = "";
|
|
1199
|
+
state.replyChannelError = "";
|
|
1200
|
+
if (relayDir) {
|
|
1201
|
+
try { fs.rmSync(path.join(relayDir, REPLY_FILE), { force: true }); } catch { /* 忽略 */ }
|
|
984
1202
|
}
|
|
985
|
-
|
|
1203
|
+
writeStatus();
|
|
986
1204
|
}
|
|
987
1205
|
|
|
988
1206
|
// ---------- DSH 事件订阅(通知 + 回答回传 + 活跃会话) ----------
|
|
@@ -1034,27 +1252,49 @@ export function createDingtalkBot(options = {}) {
|
|
|
1034
1252
|
}
|
|
1035
1253
|
}
|
|
1036
1254
|
|
|
1255
|
+
/**
|
|
1256
|
+
* 事件订阅 → 出站通知。
|
|
1257
|
+
* · turn/end、session-error:**一条**消息 = 标题行 + 总结正文(同 turn 已去重)
|
|
1258
|
+
* · 其它(审批/提问/简报…):沿用 toMarkdownNotification(审批卡片由别处处理,不混入总结)
|
|
1259
|
+
*/
|
|
1037
1260
|
function onEventNode(node) {
|
|
1038
1261
|
if (!node || typeof node !== "object") return;
|
|
1039
1262
|
if (typeof node.sessionId === "string" && node.sessionId) lastSeenSessionId = node.sessionId;
|
|
1040
1263
|
if (stopped || !notifyAllowed || !state.authenticated) return;
|
|
1041
|
-
|
|
1042
|
-
if (
|
|
1043
|
-
const key = notifyKey(node);
|
|
1264
|
+
const key = notifyKey(node);
|
|
1265
|
+
if (node.kind === NODE_KINDS.TURN_END || node.kind === NODE_KINDS.SESSION_ERROR) {
|
|
1044
1266
|
if (key && !rememberNotifyKey(key)) return;
|
|
1045
|
-
|
|
1267
|
+
if (!notifyRateOk()) { log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)"); return; }
|
|
1046
1268
|
lastInbound = { sessionId: "", at: 0 };
|
|
1047
|
-
void
|
|
1269
|
+
void sendTurnNotice(node).catch(() => {});
|
|
1048
1270
|
return;
|
|
1049
1271
|
}
|
|
1050
1272
|
const markdown = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
|
|
1051
1273
|
if (!markdown) return;
|
|
1052
|
-
const key = notifyKey(node);
|
|
1053
1274
|
if (key && !rememberNotifyKey(key)) return;
|
|
1054
1275
|
if (!notifyRateOk()) { log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)"); return; }
|
|
1055
1276
|
void sendMarkdown(markdown).catch(() => {});
|
|
1056
1277
|
}
|
|
1057
1278
|
|
|
1279
|
+
/** 任务完成/出错通知:标题 + 总结正文(优先订阅器 lastAssistantText;取不到降级仅标题)。日志只打长度。 */
|
|
1280
|
+
async function sendTurnNotice(node) {
|
|
1281
|
+
let summary = { text: "", source: "none" };
|
|
1282
|
+
try {
|
|
1283
|
+
summary = await extractTurnSummary({ node, subscriber, sessionId: node.sessionId, maxChars: SUMMARY_MAX_CHARS });
|
|
1284
|
+
} catch { summary = { text: "", source: "none" }; }
|
|
1285
|
+
if (stopped) return;
|
|
1286
|
+
const text = buildTurnNotification({
|
|
1287
|
+
node,
|
|
1288
|
+
channel: "dingtalk",
|
|
1289
|
+
summary: summary.text,
|
|
1290
|
+
sessionTitle: "",
|
|
1291
|
+
maxChars: SUMMARY_MAX_CHARS
|
|
1292
|
+
});
|
|
1293
|
+
if (!text) return;
|
|
1294
|
+
log.info(`任务通知 kind=${node.kind} reason=${node.reason || "?"} summary=${summary.source} len=${Array.from(summary.text).length}`);
|
|
1295
|
+
await sendMarkdown(text);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1058
1298
|
const notifiedKeys = new Map();
|
|
1059
1299
|
const notifyTimes = [];
|
|
1060
1300
|
function notifyKey(node) {
|
|
@@ -1079,30 +1319,6 @@ export function createDingtalkBot(options = {}) {
|
|
|
1079
1319
|
notifyTimes.push(at);
|
|
1080
1320
|
return true;
|
|
1081
1321
|
}
|
|
1082
|
-
function shouldRelayAnswer(node) {
|
|
1083
|
-
if (node.kind !== NODE_KINDS.TURN_END) return false;
|
|
1084
|
-
if (node.fromSnapshot === true) return false;
|
|
1085
|
-
if (!lastInbound.sessionId || node.sessionId !== lastInbound.sessionId) return false;
|
|
1086
|
-
if (now() - lastInbound.at >= 10 * 60 * 1000) return false;
|
|
1087
|
-
if (Number.isFinite(node.at) && node.at > 0 && node.at < lastInbound.at - 5000) return false;
|
|
1088
|
-
return true;
|
|
1089
|
-
}
|
|
1090
|
-
async function relayAnswer(node, sessionId) {
|
|
1091
|
-
let text = "";
|
|
1092
|
-
const sub = subscriber;
|
|
1093
|
-
if (sub && typeof sub.lastAssistantText === "function") {
|
|
1094
|
-
try {
|
|
1095
|
-
const r = await sub.lastAssistantText({ sessionId });
|
|
1096
|
-
if (r && r.ok && typeof r.text === "string") text = r.text.trim();
|
|
1097
|
-
} catch { /* 忽略 */ }
|
|
1098
|
-
}
|
|
1099
|
-
const markdown = text || toMarkdownNotification(node, { sessionTitle: options.sessionTitle }) || "";
|
|
1100
|
-
if (!markdown) return;
|
|
1101
|
-
if (!notifyRateOk()) { log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)"); return; }
|
|
1102
|
-
const r = await sendMarkdown(markdown);
|
|
1103
|
-
log.info(`回答回传 chat=${chatIdHash(state.boundChatId) || "?"} len=${markdown.length}${r && r.ok ? "" : ` 发送失败=${(r && r.reason) || "?"}`}`);
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
1322
|
// ---------- 对外 API ----------
|
|
1107
1323
|
|
|
1108
1324
|
function shortSession(sessionId) {
|
|
@@ -1131,6 +1347,8 @@ export function createDingtalkBot(options = {}) {
|
|
|
1131
1347
|
credentialSource,
|
|
1132
1348
|
robotCodeConfigured: Boolean(robotCode),
|
|
1133
1349
|
capabilities: capabilitiesNow(),
|
|
1350
|
+
// 回复通道三层降级(面板显示「可用/需重新发一条消息激活」;不含 webhook 原值)
|
|
1351
|
+
replyChannel: replyChannelStatus(),
|
|
1134
1352
|
subscriptions: subscriptions.map((s) => String(s.topic || "")).filter(Boolean),
|
|
1135
1353
|
messages: state.messages,
|
|
1136
1354
|
gated: state.gated,
|
|
@@ -1192,6 +1410,8 @@ export function createDingtalkBot(options = {}) {
|
|
|
1192
1410
|
status,
|
|
1193
1411
|
replySessionWebhook,
|
|
1194
1412
|
sendMarkdown,
|
|
1413
|
+
sendViaReplyChannel,
|
|
1414
|
+
clearReplyChannel,
|
|
1195
1415
|
getAccessToken,
|
|
1196
1416
|
on(event, listener) { emitter.on(event, listener); return () => emitter.off(event, listener); },
|
|
1197
1417
|
once(event, listener) { emitter.once(event, listener); return () => emitter.off(event, listener); },
|