@mrrisega/dsh-remote 0.8.0 → 0.8.1-beta.1
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 +1 -1
- package/packages/dsh-remote-web/lib/client.js +25 -1
- package/packages/dsh-remote-web/lib/index.js +130 -4
- package/packages/dsh-remote-web/lib/live-update.js +238 -0
- package/packages/dsh-remote-web/lib/self-update.js +1204 -0
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/apply-pending-plugin.mjs +171 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dingtalk-bot.mjs +34 -31
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/feishu-bot.mjs +26 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/im-format.mjs +105 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +54 -100
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +1 -1
- package/packages/dsh-remote-web/test/live-update.test.mjs +114 -0
- package/packages/dsh-remote-web/test/self-update.test.mjs +920 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1-beta.1",
|
|
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",
|
|
@@ -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,12 @@ 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";
|
|
54
59
|
// 命令层(2026-11-08):/help /status /stop /ls /use /unbind 在进模型前拦下执行(三家共用)
|
|
55
60
|
import { executeImCommand, writeImBotsSession } from "./im-commands.mjs";
|
|
56
61
|
import {
|
|
@@ -1247,27 +1252,49 @@ export function createDingtalkBot(options = {}) {
|
|
|
1247
1252
|
}
|
|
1248
1253
|
}
|
|
1249
1254
|
|
|
1255
|
+
/**
|
|
1256
|
+
* 事件订阅 → 出站通知。
|
|
1257
|
+
* · turn/end、session-error:**一条**消息 = 标题行 + 总结正文(同 turn 已去重)
|
|
1258
|
+
* · 其它(审批/提问/简报…):沿用 toMarkdownNotification(审批卡片由别处处理,不混入总结)
|
|
1259
|
+
*/
|
|
1250
1260
|
function onEventNode(node) {
|
|
1251
1261
|
if (!node || typeof node !== "object") return;
|
|
1252
1262
|
if (typeof node.sessionId === "string" && node.sessionId) lastSeenSessionId = node.sessionId;
|
|
1253
1263
|
if (stopped || !notifyAllowed || !state.authenticated) return;
|
|
1254
|
-
|
|
1255
|
-
if (
|
|
1256
|
-
const key = notifyKey(node);
|
|
1264
|
+
const key = notifyKey(node);
|
|
1265
|
+
if (node.kind === NODE_KINDS.TURN_END || node.kind === NODE_KINDS.SESSION_ERROR) {
|
|
1257
1266
|
if (key && !rememberNotifyKey(key)) return;
|
|
1258
|
-
|
|
1267
|
+
if (!notifyRateOk()) { log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)"); return; }
|
|
1259
1268
|
lastInbound = { sessionId: "", at: 0 };
|
|
1260
|
-
void
|
|
1269
|
+
void sendTurnNotice(node).catch(() => {});
|
|
1261
1270
|
return;
|
|
1262
1271
|
}
|
|
1263
1272
|
const markdown = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
|
|
1264
1273
|
if (!markdown) return;
|
|
1265
|
-
const key = notifyKey(node);
|
|
1266
1274
|
if (key && !rememberNotifyKey(key)) return;
|
|
1267
1275
|
if (!notifyRateOk()) { log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)"); return; }
|
|
1268
1276
|
void sendMarkdown(markdown).catch(() => {});
|
|
1269
1277
|
}
|
|
1270
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
|
+
|
|
1271
1298
|
const notifiedKeys = new Map();
|
|
1272
1299
|
const notifyTimes = [];
|
|
1273
1300
|
function notifyKey(node) {
|
|
@@ -1292,30 +1319,6 @@ export function createDingtalkBot(options = {}) {
|
|
|
1292
1319
|
notifyTimes.push(at);
|
|
1293
1320
|
return true;
|
|
1294
1321
|
}
|
|
1295
|
-
function shouldRelayAnswer(node) {
|
|
1296
|
-
if (node.kind !== NODE_KINDS.TURN_END) return false;
|
|
1297
|
-
if (node.fromSnapshot === true) return false;
|
|
1298
|
-
if (!lastInbound.sessionId || node.sessionId !== lastInbound.sessionId) return false;
|
|
1299
|
-
if (now() - lastInbound.at >= 10 * 60 * 1000) return false;
|
|
1300
|
-
if (Number.isFinite(node.at) && node.at > 0 && node.at < lastInbound.at - 5000) return false;
|
|
1301
|
-
return true;
|
|
1302
|
-
}
|
|
1303
|
-
async function relayAnswer(node, sessionId) {
|
|
1304
|
-
let text = "";
|
|
1305
|
-
const sub = subscriber;
|
|
1306
|
-
if (sub && typeof sub.lastAssistantText === "function") {
|
|
1307
|
-
try {
|
|
1308
|
-
const r = await sub.lastAssistantText({ sessionId });
|
|
1309
|
-
if (r && r.ok && typeof r.text === "string") text = r.text.trim();
|
|
1310
|
-
} catch { /* 忽略 */ }
|
|
1311
|
-
}
|
|
1312
|
-
const markdown = text || toMarkdownNotification(node, { sessionTitle: options.sessionTitle }) || "";
|
|
1313
|
-
if (!markdown) return;
|
|
1314
|
-
if (!notifyRateOk()) { log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)"); return; }
|
|
1315
|
-
const r = await sendMarkdown(markdown);
|
|
1316
|
-
log.info(`回答回传 chat=${chatIdHash(state.boundChatId) || "?"} len=${markdown.length}${r && r.ok ? "" : ` 发送失败=${(r && r.reason) || "?"}`}`);
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
1322
|
// ---------- 对外 API ----------
|
|
1320
1323
|
|
|
1321
1324
|
function shortSession(sessionId) {
|
|
@@ -80,7 +80,12 @@ import {
|
|
|
80
80
|
IM_BOT_STATUS_FILE,
|
|
81
81
|
APPROVAL_CARD_TTL_MS
|
|
82
82
|
} from "./wecom-bot.mjs";
|
|
83
|
-
import {
|
|
83
|
+
import {
|
|
84
|
+
SUMMARY_MAX_CHARS,
|
|
85
|
+
buildTurnNotification,
|
|
86
|
+
extractTurnSummary,
|
|
87
|
+
formatForChannel
|
|
88
|
+
} from "./im-format.mjs";
|
|
84
89
|
// 命令层(2026-11-08):/help /status /stop /ls /use /unbind 在进模型前拦下执行(三家共用)
|
|
85
90
|
import { executeImCommand, writeImBotsSession } from "./im-commands.mjs";
|
|
86
91
|
import {
|
|
@@ -1544,6 +1549,26 @@ export function createFeishuBotController(options = {}) {
|
|
|
1544
1549
|
log.warn("通知超过每分钟上限,已丢弃(防事件风暴)");
|
|
1545
1550
|
return;
|
|
1546
1551
|
}
|
|
1552
|
+
// 任务完成/出错:**一条**消息 = 标题行 + 总结正文(同 turn 已去重;审批类不混入总结)
|
|
1553
|
+
if (node.kind === NODE_KINDS.TURN_END || node.kind === NODE_KINDS.SESSION_ERROR) {
|
|
1554
|
+
const key = notifyKey(node);
|
|
1555
|
+
if (key && !rememberNotifyKey(key)) return;
|
|
1556
|
+
let summary = { text: "", source: "none" };
|
|
1557
|
+
try {
|
|
1558
|
+
summary = await extractTurnSummary({ node, subscriber, sessionId: node.sessionId, maxChars: SUMMARY_MAX_CHARS });
|
|
1559
|
+
} catch { summary = { text: "", source: "none" }; }
|
|
1560
|
+
const text = buildTurnNotification({
|
|
1561
|
+
node,
|
|
1562
|
+
channel: "feishu",
|
|
1563
|
+
summary: summary.text,
|
|
1564
|
+
sessionTitle: String((configNow && configNow.sessionTitle) || ""),
|
|
1565
|
+
maxChars: SUMMARY_MAX_CHARS
|
|
1566
|
+
});
|
|
1567
|
+
if (!text) return;
|
|
1568
|
+
log.info(`任务通知 kind=${node.kind} reason=${node.reason || "?"} summary=${summary.source} len=${Array.from(summary.text).length}`);
|
|
1569
|
+
await bot.sendText(target, text);
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1547
1572
|
// 审批:优先卡片(可点按钮)
|
|
1548
1573
|
if (node.kind === NODE_KINDS.APPROVAL_REQUEST) {
|
|
1549
1574
|
const key = notifyKey(node);
|
|
@@ -35,6 +35,8 @@ export const CHANNEL_CAPS = Object.freeze({
|
|
|
35
35
|
|
|
36
36
|
/** 截断提示(与 wecom-bot 的 ANSWER_TRUNCATED_NOTE 同文,便于去重)。 */
|
|
37
37
|
export const TRUNCATED_NOTE = "…(已截断,完整内容看电脑)";
|
|
38
|
+
/** 完成/出错通知里「总结正文」的长度上限(2026-11-10 业主:只有标题没有总结)。 */
|
|
39
|
+
export const SUMMARY_MAX_CHARS = 1500;
|
|
38
40
|
|
|
39
41
|
/** 各通道默认字节上限(wecom 官方 4096;其它先给宽松值,留 maxBytes 覆盖)。 */
|
|
40
42
|
const DEFAULT_MAX_BYTES = Object.freeze({
|
|
@@ -370,3 +372,106 @@ export function formatForChannel(channel, options = {}) {
|
|
|
370
372
|
}
|
|
371
373
|
return fitted;
|
|
372
374
|
}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
// ===========================================================================
|
|
378
|
+
// 任务总结(turn_done / turn_error / turn_blocked 通知带正文)
|
|
379
|
+
// ===========================================================================
|
|
380
|
+
|
|
381
|
+
function charsOf(text) { return Array.from(String(text == null ? "" : text)); }
|
|
382
|
+
|
|
383
|
+
function truncateChars(text, maxChars) {
|
|
384
|
+
const chars = charsOf(text);
|
|
385
|
+
if (!Number.isFinite(maxChars) || maxChars <= 0 || chars.length <= maxChars) return String(text == null ? "" : text);
|
|
386
|
+
return `${chars.slice(0, Math.max(1, maxChars - 1)).join("")}…`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** 截断到 maxChars(含「已截断」提示;与 formatForChannel 的提示同文,避免重复)。 */
|
|
390
|
+
function truncateCharsWithNote(text, maxChars) {
|
|
391
|
+
let body = String(text == null ? "" : text);
|
|
392
|
+
const hadNote = body.endsWith(TRUNCATED_NOTE);
|
|
393
|
+
if (hadNote) body = body.slice(0, -TRUNCATED_NOTE.length).replace(/\s+$/, "");
|
|
394
|
+
const chars = charsOf(body);
|
|
395
|
+
if (!Number.isFinite(maxChars) || maxChars <= 0) return hadNote ? `${body}${TRUNCATED_NOTE}` : body;
|
|
396
|
+
const noteChars = charsOf(TRUNCATED_NOTE).length + 1; // 换行 + 提示一起算进上限
|
|
397
|
+
if (chars.length + noteChars <= maxChars) return hadNote ? `${body}${TRUNCATED_NOTE}` : body;
|
|
398
|
+
const budget = Math.max(1, maxChars - noteChars);
|
|
399
|
+
return `${chars.slice(0, budget).join("").replace(/\s+$/, "")}\n${TRUNCATED_NOTE}`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** 事件里能当"摘要"的字段(订阅器取不到正文时的降级来源)。 */
|
|
403
|
+
export function extractSummaryFromNode(node) {
|
|
404
|
+
if (!node || typeof node !== "object") return "";
|
|
405
|
+
for (const key of ["summary", "message", "detail", "text"]) {
|
|
406
|
+
const v = node[key];
|
|
407
|
+
if (typeof v === "string" && v.trim()) return v.trim();
|
|
408
|
+
}
|
|
409
|
+
return "";
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 提取一次 turn 的**总结正文**(优先级:订阅器 lastAssistantText → 事件字段 → 空)。
|
|
414
|
+
*
|
|
415
|
+
* · `subscriber.lastAssistantText({sessionId})` 是最准的(agent 最终回答原文);
|
|
416
|
+
* · turn/end 瞬间正文可能还没落库 → 支持 `retryDelays`(如 [0,400])短重试;
|
|
417
|
+
* · 都取不到 → 返回空 text(调用方降级"仅标题",不报错);
|
|
418
|
+
* · 返回值只含正文;**日志由调用方只打长度**(正文可能有代码/密钥/隐私)。
|
|
419
|
+
*
|
|
420
|
+
* @returns {Promise<{text:string, source:"assistant"|"node"|"none"}>}
|
|
421
|
+
*/
|
|
422
|
+
export async function extractTurnSummary({ node, subscriber, sessionId, maxChars = SUMMARY_MAX_CHARS, retryDelays = null } = {}) {
|
|
423
|
+
const sid = String(sessionId || (node && node.sessionId) || "");
|
|
424
|
+
const delays = Array.isArray(retryDelays) && retryDelays.length ? retryDelays : [0];
|
|
425
|
+
let lastEmpty = "";
|
|
426
|
+
if (subscriber && typeof subscriber.lastAssistantText === "function" && sid) {
|
|
427
|
+
for (const delay of delays) {
|
|
428
|
+
if (Number(delay) > 0) {
|
|
429
|
+
try { await new Promise((r) => setTimeout(r, Math.min(Number(delay) || 0, 5000))); } catch { /* 忽略 */ }
|
|
430
|
+
}
|
|
431
|
+
try {
|
|
432
|
+
const r = await subscriber.lastAssistantText({ sessionId: sid });
|
|
433
|
+
if (r && r.ok === true && typeof r.text === "string" && r.text.trim()) {
|
|
434
|
+
return { text: truncateCharsWithNote(r.text.trim(), maxChars), source: "assistant" };
|
|
435
|
+
}
|
|
436
|
+
lastEmpty = String((r && (r.code || r.message)) || "empty");
|
|
437
|
+
} catch { /* 下一次重试 */ }
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const fallback = extractSummaryFromNode(node);
|
|
441
|
+
if (fallback) return { text: truncateCharsWithNote(fallback, maxChars), source: "node" };
|
|
442
|
+
return { text: "", source: "none", reason: lastEmpty };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** 各 reason 的通知首行(与既有通知文案保持一致)。 */
|
|
446
|
+
export function turnHeadline(node) {
|
|
447
|
+
if (!node || typeof node !== "object") return "";
|
|
448
|
+
const kind = String(node.kind || "");
|
|
449
|
+
if (kind === "session-error") return "❌ 出错了";
|
|
450
|
+
if (kind !== "turn-end") return "";
|
|
451
|
+
const reason = String(node.reason || "unknown");
|
|
452
|
+
if (reason === "completed") return "✅ 任务完成";
|
|
453
|
+
if (reason === "error" || reason === "max-tokens") return "❌ 出错了";
|
|
454
|
+
if (reason === "blocked") return "⛔ 被拦下了";
|
|
455
|
+
if (reason === "aborted" || reason === "interrupted") return "⏹ 已停止";
|
|
456
|
+
return "ℹ️ 任务结束";
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* 组装「任务完成/出错」通知:`✅ 任务完成|<标题>\n\n<总结正文>`。
|
|
461
|
+
*
|
|
462
|
+
* · 正文为空 → 只返回标题行(降级,不报错);
|
|
463
|
+
* · 正文按通道渲染(表格转可读列表、按通道字节上限截断),并限制 **1500 字**;
|
|
464
|
+
* · 非 turn/session-error 事件返回 null(审批类仍走卡片,不混入总结)。
|
|
465
|
+
*
|
|
466
|
+
* @returns {string|null}
|
|
467
|
+
*/
|
|
468
|
+
export function buildTurnNotification({ node, channel = "wecom", summary = "", sessionTitle = "", maxChars = SUMMARY_MAX_CHARS } = {}) {
|
|
469
|
+
const head = turnHeadline(node);
|
|
470
|
+
if (!head) return null;
|
|
471
|
+
const title = truncateChars(String(sessionTitle || "").replace(/\s+/g, " ").trim(), 40);
|
|
472
|
+
const line = title ? `${head}|${title}` : head;
|
|
473
|
+
const raw = String(summary == null ? "" : summary).trim();
|
|
474
|
+
if (!raw) return line; // 取不到正文 → 保持"仅标题"
|
|
475
|
+
const formatted = formatForChannel(channel, { text: raw, maxChars });
|
|
476
|
+
return formatted ? `${line}\n\n${formatted}` : line;
|
|
477
|
+
}
|
|
@@ -52,7 +52,12 @@ import WebSocket from "ws";
|
|
|
52
52
|
|
|
53
53
|
import { createEventSubscriber, NODE_KINDS } from "./dsh-events.mjs";
|
|
54
54
|
// 通道感知格式化(2026-11-08:企微/钉钉 markdown 不支持表格,发前统一适配;飞书/微信各有分支)
|
|
55
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
SUMMARY_MAX_CHARS,
|
|
57
|
+
buildTurnNotification,
|
|
58
|
+
extractTurnSummary,
|
|
59
|
+
formatForChannel
|
|
60
|
+
} from "./im-format.mjs";
|
|
56
61
|
// 命令层(2026-11-08):/help /status /stop /ls /use /unbind 在**进模型前**拦下执行;
|
|
57
62
|
// 解析/文案/能力门禁三家共用(与会员升级文案同口径)。
|
|
58
63
|
import { executeImCommand, writeImBotsSession } from "./im-commands.mjs";
|
|
@@ -2053,102 +2058,42 @@ export function createWecomBot(options = {}) {
|
|
|
2053
2058
|
}
|
|
2054
2059
|
|
|
2055
2060
|
/**
|
|
2056
|
-
*
|
|
2061
|
+
* 任务通知(完成/出错/被拦/停止):**一条**消息 = 标题行 + 总结正文。
|
|
2057
2062
|
*
|
|
2058
|
-
*
|
|
2059
|
-
*
|
|
2060
|
-
*
|
|
2061
|
-
* · 距那次入站 < TTL(默认 10 分钟)—— 超时就当另一次对话,走原来的标题通知;
|
|
2062
|
-
* · 事件时间不能早于入站时间(给 5s 时钟/批处理余量)。
|
|
2063
|
+
* 正文优先订阅器 `lastAssistantText({sessionId})`(turn/end 瞬间可能还没落库 → 沿用旧短重试),
|
|
2064
|
+
* 取不到再用事件字段、再取不到降级"仅标题";正文按企业微信通道渲染并限制 1500 字。
|
|
2065
|
+
* **日志只打长度/来源,绝不打印正文**(可能有代码/密钥/隐私)。
|
|
2063
2066
|
*/
|
|
2064
|
-
function
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2067
|
+
async function sendTurnNotice(node, target) {
|
|
2068
|
+
let summary = { text: "", source: "none" };
|
|
2069
|
+
try {
|
|
2070
|
+
summary = await extractTurnSummary({
|
|
2071
|
+
node,
|
|
2072
|
+
subscriber: answerRelayEnabled ? subscriber : null,
|
|
2073
|
+
sessionId: node.sessionId,
|
|
2074
|
+
maxChars: SUMMARY_MAX_CHARS,
|
|
2075
|
+
retryDelays: answerRelayEnabled ? ANSWER_FETCH_RETRY_DELAYS_MS : null
|
|
2076
|
+
});
|
|
2077
|
+
} catch { summary = { text: "", source: "none" }; }
|
|
2078
|
+
if (stopped) return;
|
|
2079
|
+
const text = buildTurnNotification({
|
|
2080
|
+
node,
|
|
2081
|
+
channel: "wecom",
|
|
2082
|
+
summary: summary.text,
|
|
2083
|
+
sessionTitle: titleOfSession(node.sessionId, ""),
|
|
2084
|
+
maxChars: SUMMARY_MAX_CHARS
|
|
2085
|
+
});
|
|
2086
|
+
if (!text) return;
|
|
2087
|
+
log.info(`任务通知 kind=${node.kind} reason=${node.reason || "?"} summary=${summary.source} len=${Array.from(summary.text).length}`);
|
|
2088
|
+
sendMarkdown(target, text);
|
|
2073
2089
|
}
|
|
2074
2090
|
|
|
2075
2091
|
/**
|
|
2076
|
-
*
|
|
2077
|
-
*
|
|
2078
|
-
*
|
|
2079
|
-
*
|
|
2080
|
-
* 日志只打长度/会话短 id,**绝不打印正文**(回答里可能有代码/密钥/隐私)。
|
|
2092
|
+
* 事件订阅 → 出站通知。
|
|
2093
|
+
* · turn/end、session-error:**一条**消息 = 标题行 + 总结正文(sendTurnNotice;同 turn 已去重)
|
|
2094
|
+
* · 审批/提问/计划:优先可点卡片(**不混入总结**),卡片失败退回文本
|
|
2095
|
+
* · 其它(简报/额度/会员/故障):沿用 toMarkdownNotification
|
|
2081
2096
|
*/
|
|
2082
|
-
async function relayAnswer(node, sessionId) {
|
|
2083
|
-
const target = state.boundChatId || targetChatId;
|
|
2084
|
-
if (!target) {
|
|
2085
|
-
const at = now();
|
|
2086
|
-
if (at - lastNoTargetLogAt > 10 * 60 * 1000) {
|
|
2087
|
-
lastNoTargetLogAt = at;
|
|
2088
|
-
log.info("有回答要回传,但还没绑定接收方:请先在企业微信里给机器人发一句话(或在配置里填 targetChatId)");
|
|
2089
|
-
}
|
|
2090
|
-
return;
|
|
2091
|
-
}
|
|
2092
|
-
let text = "";
|
|
2093
|
-
let why = "";
|
|
2094
|
-
const sub = subscriber;
|
|
2095
|
-
if (!sub || typeof sub.lastAssistantText !== "function") {
|
|
2096
|
-
why = "no_subscriber";
|
|
2097
|
-
} else {
|
|
2098
|
-
for (const delay of ANSWER_FETCH_RETRY_DELAYS_MS) {
|
|
2099
|
-
if (delay > 0) {
|
|
2100
|
-
await sleepTimer(delay);
|
|
2101
|
-
if (stopped) return; // 等待期间桥已退出:不再发
|
|
2102
|
-
}
|
|
2103
|
-
try {
|
|
2104
|
-
const r = await sub.lastAssistantText({ sessionId });
|
|
2105
|
-
if (r && r.ok && typeof r.text === "string" && r.text.trim()) {
|
|
2106
|
-
text = r.text.trim();
|
|
2107
|
-
break;
|
|
2108
|
-
}
|
|
2109
|
-
why = String((r && (r.code || r.message)) || "empty");
|
|
2110
|
-
// 硬失败(page-failed / session-not-found)重试也没意义;只有「ok 但正文还没落库」才补一次
|
|
2111
|
-
if (!(r && r.ok)) break;
|
|
2112
|
-
} catch (e) {
|
|
2113
|
-
why = truncateText(e && e.message ? e.message : e, 60);
|
|
2114
|
-
break;
|
|
2115
|
-
}
|
|
2116
|
-
}
|
|
2117
|
-
}
|
|
2118
|
-
|
|
2119
|
-
// 降级:拿不到正文 → 退回标题通知(与改动前的行为一致)
|
|
2120
|
-
if (!text) {
|
|
2121
|
-
const fallback = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
|
|
2122
|
-
if (!fallback) return;
|
|
2123
|
-
if (!notifyRateOk()) {
|
|
2124
|
-
log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)");
|
|
2125
|
-
return;
|
|
2126
|
-
}
|
|
2127
|
-
state.notified += 1;
|
|
2128
|
-
sendMarkdown(target, fallback);
|
|
2129
|
-
log.info(`取不到回答正文(${truncateText(why || "empty", 60)}),已降级为标题通知`);
|
|
2130
|
-
return;
|
|
2131
|
-
}
|
|
2132
|
-
|
|
2133
|
-
const truncated = text.length > answerMaxChars;
|
|
2134
|
-
let body = truncated ? `${text.slice(0, answerMaxChars)}${ANSWER_TRUNCATED_NOTE}` : text;
|
|
2135
|
-
// 非正常结束(出错/被拦/中止)时保留一句状态行,避免"看到回答却不知道它不完整"
|
|
2136
|
-
const reasonKind = String(node.reason || "");
|
|
2137
|
-
if (reasonKind && reasonKind !== "completed") {
|
|
2138
|
-
const status = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
|
|
2139
|
-
if (status) body = `${status}\n\n${body}`;
|
|
2140
|
-
}
|
|
2141
|
-
if (!notifyRateOk()) {
|
|
2142
|
-
log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)");
|
|
2143
|
-
return;
|
|
2144
|
-
}
|
|
2145
|
-
state.notified += 1;
|
|
2146
|
-
state.answers += 1;
|
|
2147
|
-
const sent = sendMarkdown(target, body);
|
|
2148
|
-
// 只打长度与截断标记:正文绝不能进日志
|
|
2149
|
-
log.info(`回答回传 chat=${chatIdHash(target)} len=${body.length}${truncated ? "(已截断)" : ""}${sent && sent.ok ? "" : ` 发送失败=${(sent && sent.reason) || "?"}`}`);
|
|
2150
|
-
}
|
|
2151
|
-
|
|
2152
2097
|
function onEventNode(node) {
|
|
2153
2098
|
if (!node || typeof node !== "object") return;
|
|
2154
2099
|
if (typeof node.sessionId === "string" && node.sessionId) {
|
|
@@ -2156,19 +2101,31 @@ export function createWecomBot(options = {}) {
|
|
|
2156
2101
|
scheduleSessionRefresh();
|
|
2157
2102
|
}
|
|
2158
2103
|
if (stopped || !notifyAllowed || !state.authenticated) return;
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2104
|
+
const key = notifyKey(node);
|
|
2105
|
+
// ── 任务完成/出错:标题 + 总结正文(一次 turn 只发一条)──
|
|
2106
|
+
if (node.kind === NODE_KINDS.TURN_END || node.kind === NODE_KINDS.SESSION_ERROR) {
|
|
2162
2107
|
if (key && !rememberNotifyKey(key)) return;
|
|
2163
|
-
const
|
|
2108
|
+
const target = state.boundChatId || targetChatId;
|
|
2109
|
+
if (!target) {
|
|
2110
|
+
const at = now();
|
|
2111
|
+
if (at - lastNoTargetLogAt > 10 * 60 * 1000) {
|
|
2112
|
+
lastNoTargetLogAt = at;
|
|
2113
|
+
log.info("有任务通知要发,但还没绑定接收方:请先在企业微信里给机器人发一句话(或在配置里填 targetChatId)");
|
|
2114
|
+
}
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
if (!notifyRateOk()) {
|
|
2118
|
+
log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)");
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
state.notified += 1;
|
|
2164
2122
|
lastInbound = { sessionId: "", at: 0 }; // 同一次入站只回传一次
|
|
2165
|
-
void
|
|
2123
|
+
void sendTurnNotice(node, target);
|
|
2166
2124
|
return;
|
|
2167
2125
|
}
|
|
2168
2126
|
// ── 审批类事件:优先发**可点按钮的卡片**(允许一次/拒绝),发不出去再退回 markdown ──
|
|
2169
2127
|
if (node.kind === NODE_KINDS.APPROVAL_REQUEST) {
|
|
2170
2128
|
const markdown = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
|
|
2171
|
-
const key = notifyKey(node);
|
|
2172
2129
|
if (key && !rememberNotifyKey(key)) return;
|
|
2173
2130
|
const requestId = String(node.eventId || "");
|
|
2174
2131
|
const target = state.boundChatId || targetChatId;
|
|
@@ -2200,11 +2157,9 @@ export function createWecomBot(options = {}) {
|
|
|
2200
2157
|
}
|
|
2201
2158
|
const markdown = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
|
|
2202
2159
|
if (!markdown) return;
|
|
2203
|
-
const key = notifyKey(node);
|
|
2204
2160
|
if (key && !rememberNotifyKey(key)) return;
|
|
2205
2161
|
const target = state.boundChatId || targetChatId;
|
|
2206
2162
|
if (!target) {
|
|
2207
|
-
// 首次使用:用户还没给机器人发过话,也没有配 targetChatId → 无处可推(如实记日志,不刷屏)
|
|
2208
2163
|
const at = now();
|
|
2209
2164
|
if (at - lastNoTargetLogAt > 10 * 60 * 1000) {
|
|
2210
2165
|
lastNoTargetLogAt = at;
|
|
@@ -2232,7 +2187,6 @@ export function createWecomBot(options = {}) {
|
|
|
2232
2187
|
|
|
2233
2188
|
function rememberNotifyKey(key) {
|
|
2234
2189
|
const at = now();
|
|
2235
|
-
// 有界 + TTL:Map 保插入序,超上限丢最旧
|
|
2236
2190
|
for (const [k, t] of notifiedKeys) {
|
|
2237
2191
|
if (at - t > 10 * 60 * 1000) notifiedKeys.delete(k);
|
|
2238
2192
|
else break;
|
|
@@ -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", "im-commands.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", "apply-pending-plugin.mjs", "feishu-bot.mjs", "src/lifecycle.mjs", "src/channel-ref.mjs"];
|
|
153
153
|
|
|
154
154
|
/**
|
|
155
155
|
* 把"客户端脚本"补齐到配置目录。
|