@mrrisega/dsh-remote 0.7.12-beta.6 → 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 +34 -31
- package/clients/dsh-remote/feishu-bot.mjs +26 -1
- package/clients/dsh-remote/im-format.mjs +105 -0
- package/clients/dsh-remote/test/dingtalk-bot.test.mjs +79 -0
- package/clients/dsh-remote/test/feishu-bot.test.mjs +75 -2
- package/clients/dsh-remote/test/im-format.test.mjs +60 -0
- package/clients/dsh-remote/test/wecom-bot.test.mjs +67 -89
- package/clients/dsh-remote/wecom-bot.mjs +54 -100
- 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-bot.mjs +249 -32
- 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,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
|
+
}
|
|
@@ -845,3 +845,82 @@ test("★ 命令层:/help 回复、免费档 /ls 被拦(会员文案)、/s
|
|
|
845
845
|
await waitFor(() => rt.sub.calls.prompt.length === 1, { label: "普通文本转发" });
|
|
846
846
|
} finally { await rt.close(); }
|
|
847
847
|
});
|
|
848
|
+
|
|
849
|
+
// ─────────────────────── 任务通知带总结(2026-11-10:只有"已完成"没有总结)───────────────────────
|
|
850
|
+
|
|
851
|
+
test("★ 任务通知带总结(钉钉):turn_done → 一条「✅ 任务完成 + 正文」(表格转列表);同 turn 去重;正文不进日志", async () => {
|
|
852
|
+
const answer = "修复完成:\n\n| 文件 | 改动 |\n|---|---|\n| a.mjs | 修端口 |\n| b.mjs | 加缓存 |";
|
|
853
|
+
const rt = await setupBot({ subOptions: { answer } });
|
|
854
|
+
try {
|
|
855
|
+
await rt.start();
|
|
856
|
+
await rt.inbound({ text: "给我看改动", msgId: "sum-in-1" });
|
|
857
|
+
await waitFor(() => rt.sub.calls.prompt.length === 1, { label: "入站转发" });
|
|
858
|
+
const turn = { kind: "turn-end", sessionId: "session-live", turn: 1, seq: 41, reason: "completed", at: Date.now() };
|
|
859
|
+
rt.sub.emit("event", { ...turn });
|
|
860
|
+
const call = await waitFor(() => rt.hook.state.calls.find((c) => c.body.markdown.text.includes("任务完成")), { label: "任务通知" });
|
|
861
|
+
const text = call.body.markdown.text;
|
|
862
|
+
assert.match(text, /^✅ 任务完成/, "★ 通知带「任务完成」标题行");
|
|
863
|
+
assert.match(text, /\n\n修复完成:/, "★ 同一条消息带总结正文");
|
|
864
|
+
assert.match(text, /• 文件:a\.mjs · 改动:修端口/, "★ 表格转可读列表");
|
|
865
|
+
assert.ok(!/\|\s*-{2,}/.test(text), "★ 不得有裸表格");
|
|
866
|
+
// 同 turn 重复事件 → 去重
|
|
867
|
+
const before = rt.hook.state.calls.filter((c) => c.body.markdown.text.includes("任务完成")).length;
|
|
868
|
+
rt.sub.emit("event", { ...turn });
|
|
869
|
+
await sleep(60);
|
|
870
|
+
assert.equal(rt.hook.state.calls.filter((c) => c.body.markdown.text.includes("任务完成")).length, before, "★ 同 turn 只发一条");
|
|
871
|
+
// 正文不进日志(只打 len)
|
|
872
|
+
const logs = rt.logs.join("\n");
|
|
873
|
+
assert.ok(!logs.includes("修复完成"), "★ 总结正文绝不能进日志");
|
|
874
|
+
assert.ok(logs.split("\n").some((l) => /任务通知 .*len=\d+/.test(l)), "日志只记长度");
|
|
875
|
+
} finally { await rt.close(); }
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
test("★ 任务通知边界(钉钉):缺正文降级仅标题;超长按 1500 字截断+注明;出错带错误摘要;审批不混入总结", async () => {
|
|
879
|
+
// ① 缺正文 → 仅标题
|
|
880
|
+
{
|
|
881
|
+
const rt = await setupBot({ subOptions: { answer: "" } });
|
|
882
|
+
try {
|
|
883
|
+
await rt.start();
|
|
884
|
+
await rt.inbound({ text: "hi", msgId: "sum-2" });
|
|
885
|
+
await waitFor(() => rt.sub.calls.prompt.length === 1, { label: "入站" });
|
|
886
|
+
rt.sub.emit("event", { kind: "turn-end", sessionId: "session-live", turn: 1, seq: 51, reason: "completed", at: Date.now() });
|
|
887
|
+
const call = await waitFor(() => rt.hook.state.calls.find((c) => c.body.markdown.text.includes("任务完成")), { label: "降级标题" });
|
|
888
|
+
assert.equal(call.body.markdown.text, "✅ 任务完成", "★ 取不到正文 → 仅标题(不多空段)");
|
|
889
|
+
} finally { await rt.close(); }
|
|
890
|
+
}
|
|
891
|
+
// ② 超长 → 截断 + 注明;出错 → ❌ + 摘要
|
|
892
|
+
{
|
|
893
|
+
const rt = await setupBot({ subOptions: { answer: "开头" + "长".repeat(3000) + "SECRET-TAIL" } });
|
|
894
|
+
try {
|
|
895
|
+
await rt.start();
|
|
896
|
+
await rt.inbound({ text: "hi", msgId: "sum-3" });
|
|
897
|
+
await waitFor(() => rt.sub.calls.prompt.length === 1, { label: "入站" });
|
|
898
|
+
rt.sub.emit("event", { kind: "turn-end", sessionId: "session-live", turn: 1, seq: 61, reason: "completed", at: Date.now() });
|
|
899
|
+
const call = await waitFor(() => rt.hook.state.calls.find((c) => c.body.markdown.text.includes("任务完成")), { label: "截断通知" });
|
|
900
|
+
const text = call.body.markdown.text;
|
|
901
|
+
assert.ok(text.endsWith("…(已截断,完整内容看电脑)"), "超长正文要带截断提示");
|
|
902
|
+
const body = text.split("\n\n").slice(1).join("\n\n");
|
|
903
|
+
assert.ok(Array.from(body).length <= 1500 + 20, "正文不超过 1500 字(+提示)");
|
|
904
|
+
assert.ok(!text.includes("SECRET-TAIL"));
|
|
905
|
+
assert.ok(!rt.logs.join("\n").includes("SECRET-TAIL"), "★ 正文不进日志");
|
|
906
|
+
// 出错:❌ 标题 + 摘要
|
|
907
|
+
rt.sub.emit("event", { kind: "turn-end", sessionId: "session-live", turn: 2, seq: 62, reason: "error", at: Date.now() });
|
|
908
|
+
const err = await waitFor(() => rt.hook.state.calls.find((c) => c.body.markdown.text.startsWith("❌ 出错了")), { label: "出错通知" });
|
|
909
|
+
assert.match(err.body.markdown.text, /\n\n开头/, "出错也要带错误摘要");
|
|
910
|
+
} finally { await rt.close(); }
|
|
911
|
+
}
|
|
912
|
+
// ③ 审批事件不混入总结(走普通通知,不是 ✅ 任务完成)——先入站绑定回复通道
|
|
913
|
+
{
|
|
914
|
+
const rt = await setupBot({ subOptions: { answer: "审批不该出现的总结" } });
|
|
915
|
+
try {
|
|
916
|
+
await rt.start();
|
|
917
|
+
await rt.inbound({ text: "准备审批", msgId: "sum-apr-bind" });
|
|
918
|
+
await waitFor(() => rt.sub.calls.prompt.length === 1, { label: "入站绑定" });
|
|
919
|
+
const before = rt.hook.state.calls.length;
|
|
920
|
+
rt.sub.emit("event", { kind: "approval-request", eventId: "apr_sum_1", toolName: "Bash", sessionId: "session-live" });
|
|
921
|
+
const call = await waitFor(() => rt.hook.state.calls.slice(before).find((c) => c.body.markdown.text.includes("拍板") || c.body.markdown.text.includes("审批")), { label: "审批通知" });
|
|
922
|
+
assert.ok(!/^✅ 任务完成/.test(call.body.markdown.text), "★ 审批类不得混入任务总结");
|
|
923
|
+
assert.ok(!call.body.markdown.text.includes("审批不该出现的总结"), "★ 审批通知不带总结正文");
|
|
924
|
+
} finally { await rt.close(); }
|
|
925
|
+
}
|
|
926
|
+
});
|
|
@@ -150,10 +150,11 @@ function makeFetch({ tokenCode = 0, sendCode = 0, sendMsg = "", patchCode = 0 }
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
// ── 假事件订阅器 ──────────────────────────────────────────────────────────
|
|
153
|
-
function makeSubscriber() {
|
|
153
|
+
function makeSubscriber({ answer = { ok: true, text: "" } } = {}) {
|
|
154
154
|
const emitter = new EventEmitter();
|
|
155
|
-
const calls = { sessions: [], prompts: [], cancels: [], started: 0, stopped: 0, listenSessions: 0 };
|
|
155
|
+
const calls = { sessions: [], prompts: [], cancels: [], assistant: [], started: 0, stopped: 0, listenSessions: 0 };
|
|
156
156
|
return {
|
|
157
|
+
lastAssistantText: async ({ sessionId } = {}) => { calls.assistant.push({ sessionId }); return answer; },
|
|
157
158
|
calls,
|
|
158
159
|
on: (evt, cb) => emitter.on(evt, cb),
|
|
159
160
|
emit: (evt, arg) => emitter.emit(evt, arg),
|
|
@@ -1119,3 +1120,75 @@ test("controller:命令层 — /help 直接回复、免费档 /ls 被拦、/st
|
|
|
1119
1120
|
assert.equal(sub.calls.prompts.length, 1);
|
|
1120
1121
|
await controller.stop();
|
|
1121
1122
|
});
|
|
1123
|
+
|
|
1124
|
+
// ─────────────────────── 任务通知带总结(2026-11-10:只有"已完成"没有总结)───────────────────────
|
|
1125
|
+
|
|
1126
|
+
test("controller:任务通知带总结 — turn_end → 一条「✅ 任务完成 + 正文」;同 turn 去重;正文不进日志;缺正文降级", async () => {
|
|
1127
|
+
const relayDir = mkRelayDir("ctl-summary");
|
|
1128
|
+
writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: APP_SECRET, receiveId: "ou_owner", receiveIdType: "open_id" });
|
|
1129
|
+
const sub = makeSubscriber({ answer: { ok: true, text: "飞书总结正文" } });
|
|
1130
|
+
const { controller, fetchCalls, logs } = startController(relayDir, { subscriber: sub });
|
|
1131
|
+
await controller.start();
|
|
1132
|
+
const texts = () => fetchCalls.filter((c) => c.url.includes("/im/v1/messages") && JSON.parse(c.body).msg_type === "text");
|
|
1133
|
+
const contentOf = (row) => JSON.parse(JSON.parse(row.body).content).text;
|
|
1134
|
+
const ev = { kind: "turn-end", sessionId: "session-abc", turn: 1, seq: 7, reason: "completed", at: NOW };
|
|
1135
|
+
sub.emit("event", ev);
|
|
1136
|
+
await waitFor(() => texts().some((t) => contentOf(t).includes("任务完成")));
|
|
1137
|
+
const row = texts().find((t) => contentOf(t).includes("任务完成"));
|
|
1138
|
+
const text = contentOf(row);
|
|
1139
|
+
assert.match(text, /^✅ 任务完成/, "★ 通知带「任务完成」标题行");
|
|
1140
|
+
assert.match(text, /\n\n飞书总结正文$/, "★ 同一条消息带总结正文");
|
|
1141
|
+
const count = texts().filter((t) => contentOf(t).includes("任务完成")).length;
|
|
1142
|
+
assert.equal(count, 1, "★ 同一次 turn 只发一条(不拆标题+正文两条)");
|
|
1143
|
+
// 重复 turn/end → 去重
|
|
1144
|
+
sub.emit("event", ev);
|
|
1145
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
1146
|
+
assert.equal(texts().filter((t) => contentOf(t).includes("任务完成")).length, 1, "★ 同 turn 重复事件去重");
|
|
1147
|
+
// 正文绝不进日志
|
|
1148
|
+
assert.ok(!logs.join("\n").includes("飞书总结正文"), "★ 总结正文绝不能进日志");
|
|
1149
|
+
assert.ok(logs.some((l) => /任务通知 .*len=\d+/.test(l)), "日志只记长度");
|
|
1150
|
+
await controller.stop();
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
test("controller:任务通知 — 缺正文降级仅标题;超长按 1500 字截断;出错带错误摘要;审批仍走卡片不混入总结", async () => {
|
|
1154
|
+
// ① 缺正文 → 仅标题
|
|
1155
|
+
{
|
|
1156
|
+
const relayDir = mkRelayDir("ctl-summary-none");
|
|
1157
|
+
writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: APP_SECRET, receiveId: "ou_owner", receiveIdType: "open_id" });
|
|
1158
|
+
const sub = makeSubscriber(); // 默认 answer={ok:true,text:""}
|
|
1159
|
+
const { controller, fetchCalls } = startController(relayDir, { subscriber: sub });
|
|
1160
|
+
await controller.start();
|
|
1161
|
+
const texts = () => fetchCalls.filter((c) => c.url.includes("/im/v1/messages") && JSON.parse(c.body).msg_type === "text");
|
|
1162
|
+
const contentOf = (row) => JSON.parse(JSON.parse(row.body).content).text;
|
|
1163
|
+
sub.emit("event", { kind: "turn-end", sessionId: "session-abc", turn: 1, seq: 8, reason: "completed", at: NOW });
|
|
1164
|
+
await waitFor(() => texts().some((t) => contentOf(t).includes("任务完成")));
|
|
1165
|
+
assert.equal(contentOf(texts().find((t) => contentOf(t).includes("任务完成"))), "✅ 任务完成", "★ 缺正文 → 仅标题");
|
|
1166
|
+
await controller.stop();
|
|
1167
|
+
}
|
|
1168
|
+
// ② 超长 → 截断 + 注明;出错 → ❌ + 摘要
|
|
1169
|
+
{
|
|
1170
|
+
const relayDir = mkRelayDir("ctl-summary-long");
|
|
1171
|
+
writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: APP_SECRET, receiveId: "ou_owner", receiveIdType: "open_id" });
|
|
1172
|
+
const sub = makeSubscriber({ answer: { ok: true, text: "开头" + "长".repeat(3000) + "SECRET-TAIL" } });
|
|
1173
|
+
const { controller, fetchCalls, logs } = startController(relayDir, { subscriber: sub });
|
|
1174
|
+
await controller.start();
|
|
1175
|
+
const texts = () => fetchCalls.filter((c) => c.url.includes("/im/v1/messages") && JSON.parse(c.body).msg_type === "text");
|
|
1176
|
+
const contentOf = (row) => JSON.parse(JSON.parse(row.body).content).text;
|
|
1177
|
+
sub.emit("event", { kind: "turn-end", sessionId: "session-abc", turn: 1, seq: 9, reason: "completed", at: NOW });
|
|
1178
|
+
await waitFor(() => texts().some((t) => contentOf(t).includes("任务完成")));
|
|
1179
|
+
const text = contentOf(texts().find((t) => contentOf(t).includes("任务完成")));
|
|
1180
|
+
assert.ok(text.endsWith("…(已截断,完整内容看电脑)"), "超长正文带截断提示");
|
|
1181
|
+
assert.ok(!text.includes("SECRET-TAIL"));
|
|
1182
|
+
assert.ok(!logs.join("\n").includes("SECRET-TAIL"), "★ 正文不进日志");
|
|
1183
|
+
sub.emit("event", { kind: "turn-end", sessionId: "session-abc", turn: 2, seq: 10, reason: "error", at: NOW });
|
|
1184
|
+
await waitFor(() => texts().some((t) => contentOf(t).startsWith("❌ 出错了")));
|
|
1185
|
+
assert.match(contentOf(texts().find((t) => contentOf(t).startsWith("❌ 出错了"))), /^❌ 出错了\n\n开头/);
|
|
1186
|
+
// ③ 审批事件:仍走 interactive 卡片(不混入任务总结)
|
|
1187
|
+
sub.emit("event", { kind: "approval-request", eventId: "apr_sum_feishu", toolName: "Bash", sessionId: "session-abc" });
|
|
1188
|
+
await waitFor(() => fetchCalls.some((c) => c.url.includes("/im/v1/messages") && JSON.parse(c.body).msg_type === "interactive"));
|
|
1189
|
+
const card = fetchCalls.filter((c) => c.url.includes("/im/v1/messages") && JSON.parse(c.body).msg_type === "interactive").pop();
|
|
1190
|
+
assert.ok(card, "★ 审批仍走卡片");
|
|
1191
|
+
assert.ok(!JSON.parse(card.body).content.includes("任务完成"), "★ 卡片不混入总结");
|
|
1192
|
+
await controller.stop();
|
|
1193
|
+
}
|
|
1194
|
+
});
|