@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
|
@@ -11,7 +11,10 @@ import test from "node:test";
|
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
13
|
CHANNEL_CAPS,
|
|
14
|
+
SUMMARY_MAX_CHARS,
|
|
14
15
|
TRUNCATED_NOTE,
|
|
16
|
+
buildTurnNotification,
|
|
17
|
+
extractTurnSummary,
|
|
15
18
|
convertMarkdownTable,
|
|
16
19
|
displayWidth,
|
|
17
20
|
formatForChannel,
|
|
@@ -227,3 +230,60 @@ test("wechat 分支:走现有特殊格式(本模块只做 plain 兜底、不
|
|
|
227
230
|
assert.ok(!unknown.includes("**"));
|
|
228
231
|
assert.equal(unknown, "标题\n\n粗");
|
|
229
232
|
});
|
|
233
|
+
|
|
234
|
+
// ─────────────────────────── 任务总结(turn_done/turn_error 通知带正文)───────────────────────────
|
|
235
|
+
|
|
236
|
+
test("★ extractTurnSummary:订阅器正文优先 → 事件字段降级 → 取不到为空(不报错)", async () => {
|
|
237
|
+
// ① 订阅器有正文
|
|
238
|
+
const sub = { lastAssistantText: async () => ({ ok: true, text: "订阅器里的总结" }) };
|
|
239
|
+
const a = await extractTurnSummary({ node: { kind: "turn-end", sessionId: "session-x" }, subscriber: sub, sessionId: "session-x" });
|
|
240
|
+
assert.deepEqual(a, { text: "订阅器里的总结", source: "assistant" });
|
|
241
|
+
// ② 订阅器取不到 → 事件字段(summary/message/detail/text)
|
|
242
|
+
const b = await extractTurnSummary({ node: { kind: "turn-end", sessionId: "session-x", message: "事件里的错误摘要" }, subscriber: { lastAssistantText: async () => ({ ok: false, code: "page-failed" }) }, sessionId: "session-x" });
|
|
243
|
+
assert.deepEqual(b, { text: "事件里的错误摘要", source: "node" });
|
|
244
|
+
// ③ 都没有 → 空(调用方降级仅标题)
|
|
245
|
+
const c = await extractTurnSummary({ node: { kind: "turn-end", sessionId: "session-x" }, subscriber: null });
|
|
246
|
+
assert.equal(c.text, "");
|
|
247
|
+
assert.equal(c.source, "none");
|
|
248
|
+
// ④ 订阅器抛错也不崩,继续降级
|
|
249
|
+
const d = await extractTurnSummary({ node: { kind: "turn-end", summary: "兜底" }, subscriber: { lastAssistantText: async () => { throw new Error("boom"); } }, sessionId: "s" });
|
|
250
|
+
assert.deepEqual(d, { text: "兜底", source: "node" });
|
|
251
|
+
// ⑤ 重试:ok 但空 → 第二次拿到(turn/end 瞬间正文可能还没落库)
|
|
252
|
+
let n = 0;
|
|
253
|
+
const retried = await extractTurnSummary({
|
|
254
|
+
node: { kind: "turn-end", sessionId: "s" },
|
|
255
|
+
subscriber: { lastAssistantText: async () => { n += 1; return n === 1 ? { ok: true, text: "" } : { ok: true, text: "重试后的总结" }; } },
|
|
256
|
+
sessionId: "s",
|
|
257
|
+
retryDelays: [0, 5]
|
|
258
|
+
});
|
|
259
|
+
assert.equal(retried.text, "重试后的总结");
|
|
260
|
+
assert.equal(n, 2);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("★ buildTurnNotification:标题行 + 正文(1500 字上限/通道格式化/缺正文降级);审批类返回 null", () => {
|
|
264
|
+
// completed:✅ 任务完成|标题 + 正文
|
|
265
|
+
const done = buildTurnNotification({ node: { kind: "turn-end", reason: "completed" }, channel: "wecom", summary: "改了三个文件", sessionTitle: "修端口" });
|
|
266
|
+
assert.equal(done, "✅ 任务完成|修端口\n\n改了三个文件");
|
|
267
|
+
// error / blocked / aborted
|
|
268
|
+
assert.match(buildTurnNotification({ node: { kind: "turn-end", reason: "error" }, channel: "wecom", summary: "报错摘要" }), /^❌ 出错了\n\n报错摘要$/);
|
|
269
|
+
assert.match(buildTurnNotification({ node: { kind: "turn-end", reason: "blocked" }, channel: "wecom", summary: "被拦原因" }), /^⛔ 被拦下了\n\n被拦原因$/);
|
|
270
|
+
assert.match(buildTurnNotification({ node: { kind: "turn-end", reason: "aborted" }, channel: "wecom" }), /^⏹ 已停止$/);
|
|
271
|
+
// session-error 也用 ❌ 出错了
|
|
272
|
+
assert.match(buildTurnNotification({ node: { kind: "session-error", message: "x" }, channel: "wecom" }), /^❌ 出错了/);
|
|
273
|
+
// 缺正文 → 仅标题(不留多余空段)
|
|
274
|
+
assert.equal(buildTurnNotification({ node: { kind: "turn-end", reason: "completed" }, channel: "wecom", summary: "" }), "✅ 任务完成");
|
|
275
|
+
// 审批类不混入总结(返回 null,仍走卡片)
|
|
276
|
+
assert.equal(buildTurnNotification({ node: { kind: "approval-request", toolName: "Bash" }, channel: "wecom", summary: "不该出现" }), null);
|
|
277
|
+
// 表格按通道转换(企微不支持表格 → 可读列表)
|
|
278
|
+
const table = "| 文件 | 改动 |\n|---|---|\n| a.mjs | 修端口 |";
|
|
279
|
+
const withTable = buildTurnNotification({ node: { kind: "turn-end", reason: "completed" }, channel: "wecom", summary: table });
|
|
280
|
+
assert.ok(!/\|\s*-{2,}/.test(withTable), "★ 通知里不得有裸表格分隔行");
|
|
281
|
+
assert.match(withTable, /• 文件:a\.mjs · 改动:修端口/);
|
|
282
|
+
// 1500 字上限(长正文截断 + 注明)
|
|
283
|
+
const long = "开头" + "长".repeat(3000);
|
|
284
|
+
const longNotice = buildTurnNotification({ node: { kind: "turn-end", reason: "completed" }, channel: "feishu", summary: long });
|
|
285
|
+
const body = longNotice.split("\n\n").slice(1).join("\n\n");
|
|
286
|
+
assert.ok(body.endsWith(TRUNCATED_NOTE), "超长正文要带截断提示");
|
|
287
|
+
assert.ok(Array.from(body).length <= SUMMARY_MAX_CHARS + Array.from(TRUNCATED_NOTE).length + 2, "正文不超过 1500 字(+提示)");
|
|
288
|
+
assert.ok(!body.includes("长".repeat(2000)), "明显截断");
|
|
289
|
+
});
|
|
@@ -690,62 +690,56 @@ async function setupRelayBot(over = {}) {
|
|
|
690
690
|
return { server, sub, bot, lines, sends, inbound, turnEnd, close: async () => { await bot.stop(); await server.close(); } };
|
|
691
691
|
}
|
|
692
692
|
|
|
693
|
-
test("★
|
|
694
|
-
const rt = await setupRelayBot({ assistantText: { ok: true, text: "这是 agent
|
|
693
|
+
test("★ 任务通知带总结(企微):turn_done → 一条「✅ 任务完成|标题 + 正文」;同 turn 不重发;缺正文降级标题;正文不进日志", async () => {
|
|
694
|
+
const rt = await setupRelayBot({ assistantText: { ok: true, text: "这是 agent 的最终总结" } });
|
|
695
695
|
try {
|
|
696
696
|
rt.bot.start();
|
|
697
697
|
await waitFor(() => rt.bot.status().authenticated, { label: "认证完成" });
|
|
698
698
|
await rt.inbound("relay-1", "你好");
|
|
699
|
-
|
|
700
|
-
// ① 入站后同会话 turn/end → 只发正文,不发标题
|
|
701
699
|
rt.turnEnd({ turn: 1, seq: 11 });
|
|
702
|
-
const first = await waitFor(() => rt.sends()[0], { label: "
|
|
703
|
-
assert.equal(first.body.chatid, "user-a"
|
|
700
|
+
const first = await waitFor(() => rt.sends()[0], { label: "任务通知" });
|
|
701
|
+
assert.equal(first.body.chatid, "user-a");
|
|
704
702
|
assert.equal(first.body.msgtype, "markdown");
|
|
705
|
-
|
|
706
|
-
assert.
|
|
707
|
-
assert.
|
|
703
|
+
const content = first.body.markdown.content;
|
|
704
|
+
assert.match(content, /^✅ 任务完成/, "★ 通知必须带「任务完成」标题行");
|
|
705
|
+
assert.match(content, /\n\n这是 agent 的最终总结$/, "★ 同一条消息里带总结正文(不是另发一条)");
|
|
706
|
+
assert.deepEqual(rt.sub.lastAssistantCalls[0], { sessionId: "session-abc" }, "正文通过 lastAssistantText 取");
|
|
707
|
+
await sleep(120);
|
|
708
|
+
assert.equal(rt.sends().length, 1, "★ 同一次 turn 只发一条(不得标题一条 + 正文一条)");
|
|
709
|
+
|
|
710
|
+
// 同 turn 重复事件 → 去重,不重发
|
|
711
|
+
rt.turnEnd({ turn: 1, seq: 11 });
|
|
708
712
|
await sleep(120);
|
|
709
|
-
assert.equal(rt.sends().length, 1, "
|
|
713
|
+
assert.equal(rt.sends().length, 1, "★ 同 turn 重复事件去重");
|
|
710
714
|
|
|
711
|
-
//
|
|
715
|
+
// 其它会话的 turn/end 也带总结(订阅器能给到该会话正文)
|
|
712
716
|
rt.sub.emit("event", { kind: NODE_KINDS.TURN_END, sessionId: "session-other", reason: "completed", turn: 1, seq: 12, at: Date.now() });
|
|
713
|
-
const other = await waitFor(() => rt.sends().find((f) =>
|
|
717
|
+
const other = await waitFor(() => rt.sends().find((f) => f.body.markdown.content.includes("这是 agent 的最终总结") && f !== first), { label: "其它会话通知" });
|
|
714
718
|
assert.match(other.body.markdown.content, /^✅ 任务完成/);
|
|
715
719
|
|
|
716
|
-
//
|
|
717
|
-
rt.turnEnd({ turn: 2, seq: 13 });
|
|
718
|
-
const again = await waitFor(() => rt.sends().filter((f) => /任务完成/.test(f.body.markdown.content)).length >= 2, { label: "消费一次后的标题通知" });
|
|
719
|
-
assert.ok(again, "同一次入站只回传一次正文,之后回落到标题通知");
|
|
720
|
-
|
|
721
|
-
// ④ 取不到正文 → 降级标题通知(不丢消息、也不假装有正文)
|
|
720
|
+
// 取不到正文 → 降级仅标题(不丢消息、不报错)
|
|
722
721
|
await rt.inbound("relay-2", "再问一句");
|
|
723
722
|
rt.sub.assistantReply = { ok: false, code: "page-failed", text: "" };
|
|
724
723
|
rt.turnEnd({ turn: 3, seq: 14 });
|
|
725
|
-
await waitFor(() => rt.
|
|
726
|
-
const fallback = rt.sends().
|
|
727
|
-
assert.match(fallback.body.markdown.content, /^✅ 任务完成/, "
|
|
728
|
-
|
|
729
|
-
//
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
rt.turnEnd({ turn: 4, seq: 15, fromSnapshot: true });
|
|
734
|
-
await sleep(150);
|
|
735
|
-
assert.equal(rt.sends().length, beforeSnapshot + 1, "fromSnapshot 只走标题通知(一条),不触发正文回传");
|
|
736
|
-
assert.ok(!rt.sends().some((f) => f.body.markdown.content === "来自快照的旧回答"));
|
|
724
|
+
await waitFor(() => rt.sends().length >= 3, { label: "降级通知" });
|
|
725
|
+
const fallback = rt.sends()[rt.sends().length - 1];
|
|
726
|
+
assert.match(fallback.body.markdown.content, /^✅ 任务完成/, "★ 取不到正文时仅标题");
|
|
727
|
+
assert.ok(!fallback.body.markdown.content.includes("\n\n"), "无正文时不要多余空段");
|
|
728
|
+
// 正文绝不进日志(只打长度)
|
|
729
|
+
const allLogs = rt.lines.join("\n");
|
|
730
|
+
assert.ok(!allLogs.includes("这是 agent 的最终总结"), "★ 总结正文绝不能进日志");
|
|
731
|
+
assert.ok(allLogs.split("\n").some((l) => /任务通知 .*len=\d+/.test(l)), "日志只记长度");
|
|
737
732
|
} finally {
|
|
738
733
|
await rt.close();
|
|
739
734
|
}
|
|
740
735
|
});
|
|
741
736
|
|
|
742
|
-
test("★
|
|
737
|
+
test("★ 任务通知:turn/end 瞬间正文还没落库 → 短重试后仍带总结(不是直接降级标题)", async () => {
|
|
743
738
|
let call = 0;
|
|
744
739
|
const rt = await setupRelayBot({
|
|
745
740
|
assistantText: () => {
|
|
746
741
|
call += 1;
|
|
747
|
-
|
|
748
|
-
return call === 1 ? { ok: true, text: "" } : { ok: true, text: "重试后拿到的回答" };
|
|
742
|
+
return call === 1 ? { ok: true, text: "" } : { ok: true, text: "重试后拿到的总结" };
|
|
749
743
|
}
|
|
750
744
|
});
|
|
751
745
|
try {
|
|
@@ -753,68 +747,52 @@ test("★ 回答回传:turn/end 瞬间正文还没落库 → 短重试后仍
|
|
|
753
747
|
await waitFor(() => rt.bot.status().authenticated, { label: "认证完成" });
|
|
754
748
|
await rt.inbound("retry-1", "你好");
|
|
755
749
|
rt.turnEnd({ turn: 1, seq: 31 });
|
|
756
|
-
const send = await waitFor(() => rt.sends()[0], { label: "
|
|
757
|
-
|
|
750
|
+
const send = await waitFor(() => rt.sends()[0], { label: "重试后的通知" });
|
|
751
|
+
const content = send.body.markdown.content;
|
|
752
|
+
assert.match(content, /^✅ 任务完成/);
|
|
753
|
+
assert.match(content, /\n\n重试后拿到的总结$/);
|
|
758
754
|
assert.equal(rt.sub.lastAssistantCalls.length, 2, "应恰好重试一次");
|
|
759
|
-
assert.equal(rt.bot.status().answers, 1);
|
|
760
755
|
} finally {
|
|
761
756
|
await rt.close();
|
|
762
757
|
}
|
|
763
758
|
});
|
|
764
759
|
|
|
765
|
-
test("★
|
|
766
|
-
|
|
767
|
-
{
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
});
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
// 日志只打长度,不打正文
|
|
802
|
-
const allLogs = rt.lines.join("\n");
|
|
803
|
-
assert.ok(!allLogs.includes("SECRET-ANSWER-TAIL"), "★ 回答正文绝不能进日志");
|
|
804
|
-
assert.ok(!allLogs.includes("长".repeat(40)), "★ 回答正文片段也不能进日志");
|
|
805
|
-
assert.ok(allLogs.split("\n").some((l) => /回答回传 .* len=\d+/.test(l)), "日志只记长度");
|
|
806
|
-
assert.ok(allLogs.split("\n").some((l) => /已截断/.test(l)), "日志要标出截断");
|
|
807
|
-
|
|
808
|
-
// ③ 非 completed:带一句状态行 + 正文(仍是一条消息)
|
|
809
|
-
rt.sub.assistantReply = { ok: true, text: "只跑了一半的结果" };
|
|
810
|
-
await rt.inbound("err-1", "再试");
|
|
811
|
-
rt.turnEnd({ turn: 2, seq: 23, reason: "error" });
|
|
812
|
-
const errSend = await waitFor(() => rt.sends().find((f) => f.body.markdown.content.includes("只跑了一半的结果")), { label: "出错时回传" });
|
|
813
|
-
assert.match(errSend.body.markdown.content, /^❌ 出错了/);
|
|
814
|
-
assert.ok(errSend.body.markdown.content.includes("只跑了一半的结果"), "同一条消息里既给状态也给正文");
|
|
815
|
-
} finally {
|
|
816
|
-
await rt.close();
|
|
817
|
-
}
|
|
760
|
+
test("★ 任务通知边界:超长正文按 1500 字截断+注明;出错带错误摘要;正文不进日志", async () => {
|
|
761
|
+
const longSummary = "开头" + "长".repeat(3000) + "SECRET-SUMMARY-TAIL";
|
|
762
|
+
const rt = await setupRelayBot({ assistantText: { ok: true, text: longSummary } });
|
|
763
|
+
try {
|
|
764
|
+
rt.bot.start();
|
|
765
|
+
await waitFor(() => rt.bot.status().authenticated, { label: "认证完成" });
|
|
766
|
+
await rt.inbound("trunc-1", "来点长的");
|
|
767
|
+
rt.turnEnd({ turn: 1, seq: 22 });
|
|
768
|
+
const send = await waitFor(() => rt.sends()[0], { label: "截断后的通知" });
|
|
769
|
+
const content = send.body.markdown.content;
|
|
770
|
+
assert.match(content, /^✅ 任务完成/);
|
|
771
|
+
assert.ok(content.endsWith(ANSWER_TRUNCATED_NOTE), `超长正文要以「${ANSWER_TRUNCATED_NOTE}」结尾`);
|
|
772
|
+
const bodyPart = content.split("\n\n").slice(1).join("\n\n");
|
|
773
|
+
assert.ok(Array.from(bodyPart).length <= 1500 + Array.from(ANSWER_TRUNCATED_NOTE).length + 2, "正文长度不得超过 1500 字(+截断提示)");
|
|
774
|
+
assert.ok(!content.includes("SECRET-SUMMARY-TAIL"));
|
|
775
|
+
const allLogs = rt.lines.join("\n");
|
|
776
|
+
assert.ok(!allLogs.includes("SECRET-SUMMARY-TAIL"), "★ 总结正文绝不能进日志");
|
|
777
|
+
assert.ok(!allLogs.includes("长".repeat(40)), "★ 正文片段也不能进日志");
|
|
778
|
+
assert.ok(allLogs.split("\n").some((l) => /任务通知 .* len=\d+/.test(l)), "日志只记长度");
|
|
779
|
+
|
|
780
|
+
// 出错:❌ 标题 + 错误摘要(同一条消息)
|
|
781
|
+
rt.sub.assistantReply = { ok: true, text: "只跑了一半的结果" };
|
|
782
|
+
await rt.inbound("err-1", "再试");
|
|
783
|
+
rt.turnEnd({ turn: 2, seq: 23, reason: "error" });
|
|
784
|
+
const errSend = await waitFor(() => rt.sends().find((f) => f.body.markdown.content.includes("只跑了一半的结果")), { label: "出错通知" });
|
|
785
|
+
assert.match(errSend.body.markdown.content, /^❌ 出错了/);
|
|
786
|
+
assert.match(errSend.body.markdown.content, /\n\n只跑了一半的结果$/);
|
|
787
|
+
|
|
788
|
+
// 被拦下(blocked):状态行 + 总结
|
|
789
|
+
rt.sub.assistantReply = { ok: true, text: "被安全策略拦截的说明" };
|
|
790
|
+
await rt.inbound("block-1", "危险操作");
|
|
791
|
+
rt.turnEnd({ turn: 3, seq: 24, reason: "blocked" });
|
|
792
|
+
const blocked = await waitFor(() => rt.sends().find((f) => f.body.markdown.content.includes("被安全策略拦截的说明")), { label: "被拦通知" });
|
|
793
|
+
assert.match(blocked.body.markdown.content, /^⛔ 被拦下了/);
|
|
794
|
+
} finally {
|
|
795
|
+
await rt.close();
|
|
818
796
|
}
|
|
819
797
|
});
|
|
820
798
|
|
|
@@ -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;
|
package/dsh-setup.mjs
CHANGED
|
@@ -149,7 +149,7 @@ function findDepWs(startDir) {
|
|
|
149
149
|
* 漏同步就会出现「入口脚本在、依赖不在」的半装运行时(插件半据此判定「运行环境已就绪」,
|
|
150
150
|
* 却永远起不来 bridge —— 2026-09-23 用户反馈的现场)。`src/channel-ref.mjs` 同理(本安装器也 import)。
|
|
151
151
|
*/
|
|
152
|
-
const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "wecom-bot.mjs", "im-format.mjs", "dingtalk-bot.mjs", "dingtalk-auth.mjs", "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
|
* 把"客户端脚本"补齐到配置目录。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"test:plugin": "DSH_PROFILE_DIR= DSH_REMOTE_TELEMETRY=0 DSH_RELAY_SKIP_SERVICE=1 DSH_RELAY_DEFAULT_API=http://127.0.0.1:1 DSH_RELAY_BUNDLED_RUNTIME=/nonexistent-dsh-bundled NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
|
|
57
57
|
"_comment:test:bridge": "⚠️ 必须 --test-concurrency=1(与 test:plugin 对齐):tunnel-auth 有一条「bridge 5 秒内必须重连」的硬期限,并行跑时新增用例抢 CPU 会把它撞破 → 偶发假红(实测单独跑 2/2 通过、并发跑偶发 0/2)。假红比慢更贵。",
|
|
58
58
|
"test:bridge": "DSH_PROFILE_DIR= DSH_RELAY_DEFAULT_API=http://127.0.0.1:1 NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 clients/dsh-remote/test/*.test.mjs",
|
|
59
|
-
"check": "node --check scripts/check-profile-activation.mjs && node --check clients/dsh-remote/dsh-bridge.mjs && node --check clients/dsh-remote/wechat-channel.mjs && node --check clients/dsh-remote/wechat-runtime.mjs && node --check clients/dsh-remote/dsh-events.mjs && node --check clients/dsh-remote/agent/index.mjs && node --check clients/dsh-remote/agent/version.mjs && node --check clients/dsh-remote/agent/detect.mjs && node --check clients/dsh-remote/agent/permission-bridge.mjs && node --check clients/dsh-remote/agent/proc.mjs && node --check clients/dsh-remote/agent/events.mjs && node --check clients/dsh-remote/agent/codex.mjs && node --check clients/dsh-remote/agent/gemini.mjs && node --check clients/dsh-remote/agent/opencode.mjs && node --check clients/dsh-remote/agent/agent-e2e-codex.mjs && node --check clients/dsh-remote/agent/agent-e2e-real.mjs && node --check clients/dsh-remote/push-reporter.mjs && node --check clients/dsh-remote/decision-poller.mjs && node --check clients/dsh-remote/approval-coordinator.mjs && node --check clients/dsh-remote/wecom-bot.mjs && node --check clients/dsh-remote/im-format.mjs && node --check clients/dsh-remote/dingtalk-bot.mjs && node --check clients/dsh-remote/feishu-bot.mjs && node --check clients/dsh-remote/dingtalk-auth.mjs && node --check clients/dsh-remote/im-commands.mjs && node --check clients/dsh-remote/e2ee-shim.mjs && node --check dsh-setup.mjs && node --check packages/dsh-remote-web/lib/index.js && node --check packages/dsh-remote-web/lib/client.js && node --check packages/relay-router/src/index.mjs && node --check packages/relay-router/src/logger.mjs && bash -n deploy/install-open.sh && grep -q 'command -v setsid' deploy/install-open.sh && grep -q 'command -v ss' deploy/install-open.sh",
|
|
59
|
+
"check": "node --check scripts/check-profile-activation.mjs && node --check clients/dsh-remote/dsh-bridge.mjs && node --check clients/dsh-remote/wechat-channel.mjs && node --check clients/dsh-remote/wechat-runtime.mjs && node --check clients/dsh-remote/dsh-events.mjs && node --check clients/dsh-remote/agent/index.mjs && node --check clients/dsh-remote/agent/version.mjs && node --check clients/dsh-remote/agent/detect.mjs && node --check clients/dsh-remote/agent/permission-bridge.mjs && node --check clients/dsh-remote/agent/proc.mjs && node --check clients/dsh-remote/agent/events.mjs && node --check clients/dsh-remote/agent/codex.mjs && node --check clients/dsh-remote/agent/gemini.mjs && node --check clients/dsh-remote/agent/opencode.mjs && node --check clients/dsh-remote/agent/agent-e2e-codex.mjs && node --check clients/dsh-remote/agent/agent-e2e-real.mjs && node --check clients/dsh-remote/push-reporter.mjs && node --check clients/dsh-remote/decision-poller.mjs && node --check clients/dsh-remote/approval-coordinator.mjs && node --check clients/dsh-remote/wecom-bot.mjs && node --check clients/dsh-remote/im-format.mjs && node --check clients/dsh-remote/dingtalk-bot.mjs && node --check clients/dsh-remote/feishu-bot.mjs && node --check clients/dsh-remote/dingtalk-auth.mjs && node --check clients/dsh-remote/im-commands.mjs && node --check clients/dsh-remote/apply-pending-plugin.mjs && node --check clients/dsh-remote/e2ee-shim.mjs && node --check dsh-setup.mjs && node --check packages/dsh-remote-web/lib/index.js && node --check packages/dsh-remote-web/lib/client.js && node --check packages/relay-router/src/index.mjs && node --check packages/relay-router/src/logger.mjs && bash -n deploy/install-open.sh && grep -q 'command -v setsid' deploy/install-open.sh && grep -q 'command -v ss' deploy/install-open.sh",
|
|
60
60
|
"release:tarballs": "bash scripts/release-tarballs.sh",
|
|
61
61
|
"sync:local": "node scripts/sync-local.mjs"
|
|
62
62
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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",
|