@mrrisega/dsh-remote 0.7.12-beta.5 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/clients/dsh-remote/apply-pending-plugin.mjs +171 -0
- package/clients/dsh-remote/dingtalk-bot.mjs +283 -63
- package/clients/dsh-remote/feishu-bot.mjs +127 -1
- package/clients/dsh-remote/im-commands.mjs +302 -0
- package/clients/dsh-remote/im-format.mjs +105 -0
- package/clients/dsh-remote/test/dingtalk-bot.test.mjs +297 -1
- package/clients/dsh-remote/test/feishu-bot.test.mjs +108 -3
- package/clients/dsh-remote/test/im-caps-fixture.mjs +8 -0
- package/clients/dsh-remote/test/im-commands.test.mjs +283 -0
- package/clients/dsh-remote/test/im-format.test.mjs +60 -0
- package/clients/dsh-remote/test/wecom-bot.test.mjs +104 -89
- package/clients/dsh-remote/wecom-bot.mjs +138 -102
- package/dsh-setup.mjs +1 -1
- package/package.json +2 -2
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dingtalk-auth.mjs +631 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dingtalk-bot.mjs +249 -32
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +35 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/feishu-bot.mjs +101 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/im-commands.mjs +302 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +84 -2
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +1 -1
|
@@ -49,6 +49,11 @@ const FAKE_CLIENT = "ding-fake-client-id";
|
|
|
49
49
|
|
|
50
50
|
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
51
51
|
|
|
52
|
+
/** 读 JSON 文件(不存在/坏 JSON → null)。 */
|
|
53
|
+
function readJson(file) {
|
|
54
|
+
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
|
|
55
|
+
}
|
|
56
|
+
|
|
52
57
|
async function waitFor(fn, { label = "条件", timeout = 5000, interval = 20 } = {}) {
|
|
53
58
|
const t0 = Date.now();
|
|
54
59
|
for (;;) {
|
|
@@ -162,7 +167,7 @@ async function startWebhook(payload = { errcode: 0, errmsg: "ok" }, status = 200
|
|
|
162
167
|
/** 最小 fake 订阅器(与 bridge 里 dsh-events 的对外接口同形)。 */
|
|
163
168
|
function fakeSubscriber({ sessions = [{ sessionId: "session-live", running: true, title: "活跃会话" }], answer = "这是 agent 的最终回答" } = {}) {
|
|
164
169
|
const emitter = new EventEmitter();
|
|
165
|
-
const calls = { prompt: [], listSessions: 0, lastAssistant: 0 };
|
|
170
|
+
const calls = { prompt: [], listSessions: 0, lastAssistant: 0, cancel: [] };
|
|
166
171
|
return {
|
|
167
172
|
on(evt, fn) { emitter.on(evt, fn); return this; },
|
|
168
173
|
once(evt, fn) { emitter.once(evt, fn); return this; },
|
|
@@ -172,6 +177,7 @@ function fakeSubscriber({ sessions = [{ sessionId: "session-live", running: true
|
|
|
172
177
|
sessions() { return sessions.map((s) => s.sessionId); },
|
|
173
178
|
async listSessions() { calls.listSessions += 1; return { ok: true, sessions }; },
|
|
174
179
|
async promptSession({ sessionId, text }) { calls.prompt.push({ sessionId, text }); return { ok: true }; },
|
|
180
|
+
async cancelSession({ sessionId } = {}) { calls.cancel.push({ sessionId }); return { ok: true }; },
|
|
175
181
|
async lastAssistantText() { calls.lastAssistant += 1; return { ok: true, text: answer }; },
|
|
176
182
|
calls
|
|
177
183
|
};
|
|
@@ -553,6 +559,185 @@ test("controller:DSH_IM_BOTS=0 全关(env 优先)", async () => {
|
|
|
553
559
|
|
|
554
560
|
// ─────────────────────────── 常量/默认值 ───────────────────────────
|
|
555
561
|
|
|
562
|
+
// ─────────────────── 回复通道三层降级(2026-11-08 实测 bug:回答回传发不出) ───────────────────
|
|
563
|
+
|
|
564
|
+
/** 包一层 fetch:把 openapi(accessToken / oToMessages / groupMessages)拦成本地假响应并记录。 */
|
|
565
|
+
function makeOpenapiFetch() {
|
|
566
|
+
const calls = [];
|
|
567
|
+
const fetchImpl = (url, init) => {
|
|
568
|
+
const u = String(url);
|
|
569
|
+
if (u.includes("/oauth2/accessToken")) {
|
|
570
|
+
calls.push({ kind: "token", url: u, body: init && init.body });
|
|
571
|
+
return Promise.resolve({ ok: true, status: 200, json: async () => ({ accessToken: "tok-123", expireIn: 7200 }) });
|
|
572
|
+
}
|
|
573
|
+
if (u.includes("/robot/oToMessages/batchSend") || u.includes("/robot/groupMessages/send")) {
|
|
574
|
+
let body = null;
|
|
575
|
+
try { body = JSON.parse(init && init.body ? init.body : "{}"); } catch { body = null; }
|
|
576
|
+
calls.push({ kind: "send", url: u, body, headers: init && init.headers });
|
|
577
|
+
return Promise.resolve({ ok: true, status: 200, json: async () => ({ processQueryKey: "pq-1" }) });
|
|
578
|
+
}
|
|
579
|
+
return fetch(url, init);
|
|
580
|
+
};
|
|
581
|
+
return { fetchImpl, calls };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
test("★ 回复通道①:入站缓存 sessionWebhook → 回答回传直接 POST 它(跨重启保持)", async () => {
|
|
585
|
+
const relayDir = tmpRelayDir();
|
|
586
|
+
const hook = await startWebhook();
|
|
587
|
+
const gateway = await startGateway();
|
|
588
|
+
const sub = fakeSubscriber({ answer: "重启后也要能回传答案" });
|
|
589
|
+
const botOptions = {
|
|
590
|
+
clientId: FAKE_CLIENT, clientSecret: FAKE_SECRET, robotCode: "ding-robot",
|
|
591
|
+
gatewayUrl: gateway.openUrl, relayDir, subscriber: sub,
|
|
592
|
+
logger: () => {}, reconnectBaseMs: 40, reconnectMaxMs: 160, tierDenyThrottleMs: 0
|
|
593
|
+
};
|
|
594
|
+
const bot1 = createDingtalkBot(botOptions);
|
|
595
|
+
try {
|
|
596
|
+
bot1.start();
|
|
597
|
+
await waitFor(() => bot1.status().connected, { label: "连接1" });
|
|
598
|
+
gateway.push({
|
|
599
|
+
type: "CALLBACK", headers: { messageId: "mid-c1", topic: TOPIC_ROBOT },
|
|
600
|
+
data: JSON.stringify({
|
|
601
|
+
conversationId: "cid-1", conversationType: "2", senderStaffId: "staff-9",
|
|
602
|
+
msgtype: "text", msgId: "m-c1", text: { content: "你好" },
|
|
603
|
+
sessionWebhook: hook.url, sessionWebhookExpiredTime: Date.now() + 5 * 60_000
|
|
604
|
+
})
|
|
605
|
+
});
|
|
606
|
+
await waitFor(() => hook.state.calls.length >= 1, { label: "被动回复" });
|
|
607
|
+
const st1 = bot1.status();
|
|
608
|
+
assert.equal(st1.replyChannel.kind, "sessionWebhook");
|
|
609
|
+
assert.equal(st1.replyChannel.hasWebhook, true);
|
|
610
|
+
await bot1.stop();
|
|
611
|
+
} finally {
|
|
612
|
+
await gateway.close();
|
|
613
|
+
}
|
|
614
|
+
// 重启(新进程语义):只靠磁盘缓存也要能回传
|
|
615
|
+
const gateway2 = await startGateway();
|
|
616
|
+
const bot2 = createDingtalkBot({ ...botOptions, gatewayUrl: gateway2.openUrl });
|
|
617
|
+
try {
|
|
618
|
+
bot2.start();
|
|
619
|
+
await waitFor(() => bot2.status().connected, { label: "连接2" });
|
|
620
|
+
assert.equal(bot2.status().replyChannel.kind, "sessionWebhook", "★ 重启后回复通道来自持久化缓存");
|
|
621
|
+
const before = hook.state.calls.length;
|
|
622
|
+
const r = await bot2.sendMarkdown("重启后的回答回传");
|
|
623
|
+
assert.equal(r.ok, true);
|
|
624
|
+
assert.equal(r.kind, "sessionWebhook");
|
|
625
|
+
await waitFor(() => hook.state.calls.length > before, { label: "重启后回传到 webhook" });
|
|
626
|
+
assert.match(hook.last().body.markdown.text, /重启后的回答回传/);
|
|
627
|
+
} finally {
|
|
628
|
+
await bot2.stop();
|
|
629
|
+
await gateway2.close();
|
|
630
|
+
await hook.close();
|
|
631
|
+
fs.rmSync(relayDir, { recursive: true, force: true });
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
test("★ 回复通道②:sessionWebhook 过期 → 降级 openapi(userIds=[senderStaffId])", async () => {
|
|
636
|
+
const relayDir = tmpRelayDir();
|
|
637
|
+
const gateway = await startGateway();
|
|
638
|
+
const hook = await startWebhook();
|
|
639
|
+
const api = makeOpenapiFetch();
|
|
640
|
+
const sub = fakeSubscriber({ answer: "x" });
|
|
641
|
+
const bot = createDingtalkBot({
|
|
642
|
+
clientId: FAKE_CLIENT, clientSecret: FAKE_SECRET, robotCode: "ding-robot",
|
|
643
|
+
gatewayUrl: gateway.openUrl, relayDir, subscriber: sub,
|
|
644
|
+
fetchImpl: api.fetchImpl, logger: () => {},
|
|
645
|
+
reconnectBaseMs: 40, reconnectMaxMs: 160, tierDenyThrottleMs: 0
|
|
646
|
+
});
|
|
647
|
+
try {
|
|
648
|
+
bot.start();
|
|
649
|
+
await waitFor(() => bot.status().connected, { label: "连接" });
|
|
650
|
+
gateway.push({
|
|
651
|
+
type: "CALLBACK", headers: { messageId: "mid-exp", topic: TOPIC_ROBOT },
|
|
652
|
+
data: JSON.stringify({
|
|
653
|
+
conversationId: "cid-exp", conversationType: "1", senderStaffId: "staff-exp",
|
|
654
|
+
msgtype: "text", msgId: "m-exp", text: { content: "hi" },
|
|
655
|
+
sessionWebhook: hook.url, sessionWebhookExpiredTime: Date.now() - 1000 // 已过期
|
|
656
|
+
})
|
|
657
|
+
});
|
|
658
|
+
await waitFor(() => bot.status().messages >= 1, { label: "入站" });
|
|
659
|
+
assert.equal(bot.status().replyChannel.kind, "openapi", "★ webhook 过期 → 显示 openapi 可用");
|
|
660
|
+
const hookCalls = hook.state.calls.length;
|
|
661
|
+
const r = await bot.sendMarkdown("降级 openapi 回答");
|
|
662
|
+
assert.equal(r.ok, true);
|
|
663
|
+
assert.equal(r.kind, "openapi");
|
|
664
|
+
const send = api.calls.filter((c) => c.kind === "send");
|
|
665
|
+
assert.equal(send.length, 1);
|
|
666
|
+
assert.deepEqual(send[0].body.userIds, ["staff-exp"], "★ 用缓存的 senderStaffId 发单聊");
|
|
667
|
+
assert.match(send[0].headers["x-acs-dingtalk-access-token"], /tok-123/);
|
|
668
|
+
assert.equal(hook.state.calls.length, hookCalls, "过期 webhook 不得再被调用");
|
|
669
|
+
} finally {
|
|
670
|
+
await bot.stop(); await gateway.close(); await hook.close();
|
|
671
|
+
fs.rmSync(relayDir, { recursive: true, force: true });
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test("★ 回复通道③:webhook 与 openapi 都不可用 → 明确日志 no_reply_channel + 状态人话(不静默)", async () => {
|
|
676
|
+
const relayDir = tmpRelayDir();
|
|
677
|
+
const gateway = await startGateway();
|
|
678
|
+
const logs = [];
|
|
679
|
+
const bot = createDingtalkBot({
|
|
680
|
+
clientId: FAKE_CLIENT, clientSecret: FAKE_SECRET, robotCode: "", // 无 robotCode
|
|
681
|
+
gatewayUrl: gateway.openUrl, relayDir,
|
|
682
|
+
logger: (level, line) => logs.push(line),
|
|
683
|
+
reconnectBaseMs: 40, reconnectMaxMs: 160, tierDenyThrottleMs: 0
|
|
684
|
+
});
|
|
685
|
+
try {
|
|
686
|
+
bot.start();
|
|
687
|
+
await waitFor(() => bot.status().connected, { label: "连接" });
|
|
688
|
+
const r = await bot.sendMarkdown("发不出去的");
|
|
689
|
+
assert.equal(r.ok, false);
|
|
690
|
+
assert.match(String(r.reason), /no_reply_channel|no_robot_code|no_target_user|no_webhook/);
|
|
691
|
+
await waitFor(() => logs.join("\n").match(/no_reply_channel|no_robot_code/), { label: "明确日志" });
|
|
692
|
+
const st = bot.status();
|
|
693
|
+
assert.equal(st.replyChannel.kind, "none");
|
|
694
|
+
assert.ok(String(st.replyChannel.lastError || "").length > 0, "状态要有人话原因");
|
|
695
|
+
const stFile = readJson(path.join(relayDir, IM_BOT_STATUS_FILE));
|
|
696
|
+
assert.equal(stFile.dingtalk.replyChannel.kind, "none");
|
|
697
|
+
assert.match(String(stFile.dingtalk.replyChannel.lastError || ""), /回复通道|robotCode/, "面板可显示可操作原因(补 robotCode / 再发一条消息激活)");
|
|
698
|
+
} finally {
|
|
699
|
+
await bot.stop(); await gateway.close();
|
|
700
|
+
fs.rmSync(relayDir, { recursive: true, force: true });
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
test("★ 脱敏:日志/状态只出现 webhook host 与哈希,不含整条 webhook URL 或 secret", async () => {
|
|
705
|
+
const relayDir = tmpRelayDir();
|
|
706
|
+
const gateway = await startGateway();
|
|
707
|
+
const logs = [];
|
|
708
|
+
const sub = fakeSubscriber();
|
|
709
|
+
const bot = createDingtalkBot({
|
|
710
|
+
clientId: FAKE_CLIENT, clientSecret: FAKE_SECRET, robotCode: "ding-robot",
|
|
711
|
+
gatewayUrl: gateway.openUrl, relayDir, subscriber: sub,
|
|
712
|
+
logger: (level, line) => logs.push(line),
|
|
713
|
+
reconnectBaseMs: 40, reconnectMaxMs: 160, tierDenyThrottleMs: 0
|
|
714
|
+
});
|
|
715
|
+
try {
|
|
716
|
+
bot.start();
|
|
717
|
+
await waitFor(() => bot.status().connected, { label: "连接" });
|
|
718
|
+
const webhookUrl = "https://oapi.dingtalk.test/robot/sendBySession?session=TOKEN-SECRET-AAA";
|
|
719
|
+
gateway.push({
|
|
720
|
+
type: "CALLBACK", headers: { messageId: "mid-sec", topic: TOPIC_ROBOT },
|
|
721
|
+
data: JSON.stringify({
|
|
722
|
+
conversationId: "cid-sec", conversationType: "1", senderStaffId: "staff-sec",
|
|
723
|
+
msgtype: "text", msgId: "m-sec", text: { content: "hi" },
|
|
724
|
+
sessionWebhook: webhookUrl, sessionWebhookExpiredTime: Date.now() + 60_000
|
|
725
|
+
})
|
|
726
|
+
});
|
|
727
|
+
await waitFor(() => bot.status().messages >= 1, { label: "入站" });
|
|
728
|
+
await waitFor(() => logs.join("\n").includes("oapi.dingtalk.test"), { label: "host 日志" });
|
|
729
|
+
const joined = logs.join("\n");
|
|
730
|
+
assert.ok(!joined.includes("TOKEN-SECRET-AAA"), "★ 日志不得含 webhook 会话令牌");
|
|
731
|
+
assert.ok(!joined.includes(FAKE_SECRET), "★ 日志不得含 clientSecret");
|
|
732
|
+
const raw = fs.readFileSync(path.join(relayDir, IM_BOT_STATUS_FILE), "utf8");
|
|
733
|
+
assert.ok(!raw.includes("TOKEN-SECRET-AAA"), "★ 状态文件不得含 webhook 原值");
|
|
734
|
+
assert.ok(!raw.includes(FAKE_SECRET));
|
|
735
|
+
} finally {
|
|
736
|
+
await bot.stop(); await gateway.close();
|
|
737
|
+
fs.rmSync(relayDir, { recursive: true, force: true });
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
|
|
556
741
|
// ─────────────────── robotCode 可选(扫码一键创建机器人,2026-11-08) ───────────────────
|
|
557
742
|
|
|
558
743
|
test("★ robotCode 可选:只给 clientId/clientSecret 也能启动、收/回消息;状态 capabilities 如实降级", async () => {
|
|
@@ -628,3 +813,114 @@ test("常量:默认 open 端点 / ticket 90s / 退避 1s→60s", () => {
|
|
|
628
813
|
assert.equal(TICKET_TTL_MS, 90_000);
|
|
629
814
|
assert.equal(TOPIC_ROBOT, "/v1.0/im/bot/messages/get");
|
|
630
815
|
});
|
|
816
|
+
|
|
817
|
+
// ─────────────────────── 命令层(2026-11-08:/ls 不再转给模型)───────────────────────
|
|
818
|
+
|
|
819
|
+
test("★ 命令层:/help 回复、免费档 /ls 被拦(会员文案)、/stop 免费急停;命令不进 promptSession", async () => {
|
|
820
|
+
const rt = await setupBot();
|
|
821
|
+
try {
|
|
822
|
+
await rt.start();
|
|
823
|
+
const pushCmd = async (msgId, text) => {
|
|
824
|
+
rt.gateway.push({
|
|
825
|
+
type: "CALLBACK", headers: { messageId: `mid-${msgId}`, topic: TOPIC_ROBOT },
|
|
826
|
+
data: JSON.stringify({
|
|
827
|
+
conversationId: "cid-cmd", conversationType: "2", senderStaffId: "staff-cmd",
|
|
828
|
+
msgtype: "text", msgId: `m-${msgId}`, text: { content: text },
|
|
829
|
+
sessionWebhook: rt.hook.url, sessionWebhookExpiredTime: Date.now() + 60_000
|
|
830
|
+
})
|
|
831
|
+
});
|
|
832
|
+
};
|
|
833
|
+
pushCmd("help", "/help");
|
|
834
|
+
await waitFor(() => rt.hook.state.calls.find((c) => /可用命令/.test(c.body.markdown.text)), { label: "/help 回复" });
|
|
835
|
+
assert.equal(rt.sub.calls.prompt.length, 0, "★ 命令不得进模型");
|
|
836
|
+
pushCmd("ls", "/ls");
|
|
837
|
+
await waitFor(() => rt.hook.state.calls.find((c) => /会员功能/.test(c.body.markdown.text)), { label: "/ls 会员拦截" });
|
|
838
|
+
assert.equal(rt.sub.calls.prompt.length, 0);
|
|
839
|
+
pushCmd("stop", "/stop");
|
|
840
|
+
await waitFor(() => rt.hook.state.calls.find((c) => /已急停|没有正在运行/.test(c.body.markdown.text)), { label: "/stop 回复" });
|
|
841
|
+
assert.equal(rt.sub.calls.cancel.length, 1, "★ /stop 调 cancelSession");
|
|
842
|
+
assert.equal(rt.sub.calls.prompt.length, 0);
|
|
843
|
+
// 普通文本照旧转发
|
|
844
|
+
await rt.inbound({ text: "帮我看看日志", msgId: "m-normal-cmd" });
|
|
845
|
+
await waitFor(() => rt.sub.calls.prompt.length === 1, { label: "普通文本转发" });
|
|
846
|
+
} finally { await rt.close(); }
|
|
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: [], 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),
|
|
@@ -161,7 +162,8 @@ function makeSubscriber() {
|
|
|
161
162
|
stop() { calls.stopped += 1; },
|
|
162
163
|
sessions() { return ["session-abc"]; },
|
|
163
164
|
async listSessions() { calls.listenSessions += 1; return [{ sessionId: "session-abc", title: "t" }]; },
|
|
164
|
-
async promptSession(req) { calls.prompts.push(req); return { ok: true }; }
|
|
165
|
+
async promptSession(req) { calls.prompts.push(req); return { ok: true }; },
|
|
166
|
+
async cancelSession(req) { calls.cancels.push(req); return { ok: true }; }
|
|
165
167
|
};
|
|
166
168
|
}
|
|
167
169
|
|
|
@@ -1087,3 +1089,106 @@ test("向导:主路径只有两件事(填 App ID/Secret + 发一句话自动
|
|
|
1087
1089
|
assert.match(FEISHU_SETUP_GUIDE.oneAppOnePc, /一台电脑/);
|
|
1088
1090
|
assert.match(FEISHU_SETUP_GUIDE.docs.registerApp, /open\.feishu\.cn\/app/);
|
|
1089
1091
|
});
|
|
1092
|
+
|
|
1093
|
+
// ─────────────────────── 命令层(2026-11-08:/ls 不再转给模型)───────────────────────
|
|
1094
|
+
|
|
1095
|
+
test("controller:命令层 — /help 直接回复、免费档 /ls 被拦、/stop 急停;命令绝不进模型", async () => {
|
|
1096
|
+
const relayDir = mkRelayDir("ctl-cmd");
|
|
1097
|
+
writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: APP_SECRET, receiveId: "oc_chat_1", receiveIdType: "chat_id" });
|
|
1098
|
+
const sub = makeSubscriber();
|
|
1099
|
+
const { controller, fetchCalls } = startController(relayDir, { subscriber: sub, capsProvider: async () => "free" });
|
|
1100
|
+
await controller.start();
|
|
1101
|
+
const lastText = () => {
|
|
1102
|
+
const rows = fetchCalls.filter((c) => c.url.includes("/im/v1/messages") && JSON.parse(c.body).msg_type === "text");
|
|
1103
|
+
return rows.length ? JSON.parse(rows[rows.length - 1].body).content : "";
|
|
1104
|
+
};
|
|
1105
|
+
controller._bot()._dispatch("im.message.receive_v1", inboundText("/help"));
|
|
1106
|
+
await waitFor(() => /可用命令/.test(lastText()));
|
|
1107
|
+
assert.match(lastText(), /可用命令/);
|
|
1108
|
+
assert.equal(sub.calls.prompts.length, 0, "★ 命令不得进模型");
|
|
1109
|
+
controller._bot()._dispatch("im.message.receive_v1", inboundText("/ls"));
|
|
1110
|
+
await waitFor(() => /会员功能/.test(lastText()));
|
|
1111
|
+
assert.match(lastText(), /会员功能/, "免费档 /ls 必须被拦");
|
|
1112
|
+
assert.equal(sub.calls.prompts.length, 0);
|
|
1113
|
+
controller._bot()._dispatch("im.message.receive_v1", inboundText("/stop"));
|
|
1114
|
+
await waitFor(() => /已急停|没有正在运行/.test(lastText()));
|
|
1115
|
+
assert.equal(sub.calls.cancels.length, 1, "★ /stop 调 cancelSession");
|
|
1116
|
+
assert.equal(sub.calls.prompts.length, 0, "★ 全部命令都不进模型");
|
|
1117
|
+
// 普通文本照旧转发
|
|
1118
|
+
controller._bot()._dispatch("im.message.receive_v1", inboundText("继续跑"));
|
|
1119
|
+
await waitFor(() => sub.calls.prompts.length === 1);
|
|
1120
|
+
assert.equal(sub.calls.prompts.length, 1);
|
|
1121
|
+
await controller.stop();
|
|
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
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** 测试共用的能力集夹具(与 wechat-runtime 的 WECHAT_CAPABILITY_TABLE 对齐,避免各测试各抄一份)。 */
|
|
2
|
+
export function freeCaps() {
|
|
3
|
+
return ["notify", "approve", "status", "stop", "assign.continue"];
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function paidCaps() {
|
|
7
|
+
return ["notify", "approve", "status", "stop", "assign", "sessions", "summary", "steer", "attach", "multi"];
|
|
8
|
+
}
|