@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
|
@@ -80,7 +80,14 @@ 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";
|
|
89
|
+
// 命令层(2026-11-08):/help /status /stop /ls /use /unbind 在进模型前拦下执行(三家共用)
|
|
90
|
+
import { executeImCommand, writeImBotsSession } from "./im-commands.mjs";
|
|
84
91
|
import {
|
|
85
92
|
WECHAT_CAPABILITY_TABLE,
|
|
86
93
|
WECHAT_TIER_LIMITS,
|
|
@@ -1233,6 +1240,16 @@ export function createFeishuBot({
|
|
|
1233
1240
|
return r;
|
|
1234
1241
|
},
|
|
1235
1242
|
|
|
1243
|
+
/** 解绑接收人(/unbind 命令用):清内存绑定 + 触发状态刷新;之后收到消息会自动重新绑定。 */
|
|
1244
|
+
clearBinding() {
|
|
1245
|
+
state.boundChatId = "";
|
|
1246
|
+
state.boundChatType = "";
|
|
1247
|
+
state.boundChatName = "";
|
|
1248
|
+
state.boundSource = "";
|
|
1249
|
+
emitStatus();
|
|
1250
|
+
return { ok: true };
|
|
1251
|
+
},
|
|
1252
|
+
|
|
1236
1253
|
/** 发审批卡片(返回值带 messageId,供回执后原地更新)。 */
|
|
1237
1254
|
async sendCard(to, card, opts = {}) {
|
|
1238
1255
|
if (!sender) return { ok: false, reason: "发送器未初始化(连接尚未建立)" };
|
|
@@ -1532,6 +1549,26 @@ export function createFeishuBotController(options = {}) {
|
|
|
1532
1549
|
log.warn("通知超过每分钟上限,已丢弃(防事件风暴)");
|
|
1533
1550
|
return;
|
|
1534
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
|
+
}
|
|
1535
1572
|
// 审批:优先卡片(可点按钮)
|
|
1536
1573
|
if (node.kind === NODE_KINDS.APPROVAL_REQUEST) {
|
|
1537
1574
|
const key = notifyKey(node);
|
|
@@ -1565,9 +1602,98 @@ export function createFeishuBotController(options = {}) {
|
|
|
1565
1602
|
}
|
|
1566
1603
|
|
|
1567
1604
|
// ── 入站 ────────────────────────────────────────────────────────────────
|
|
1605
|
+
/** 入站命令层:命中返回 true(已回复,调用方直接 return)。 */
|
|
1606
|
+
async function handleInboundCommand(msg) {
|
|
1607
|
+
const text = String((msg && msg.text) || "");
|
|
1608
|
+
if (!/^\s*\//.test(text)) return false;
|
|
1609
|
+
const cmd = await executeImCommand({
|
|
1610
|
+
text,
|
|
1611
|
+
ctx: {
|
|
1612
|
+
caps: capsNow(),
|
|
1613
|
+
tier: tierNow(),
|
|
1614
|
+
appUrl,
|
|
1615
|
+
channelLabel: "飞书",
|
|
1616
|
+
sessions: async () => {
|
|
1617
|
+
const sub = subscriber;
|
|
1618
|
+
if (!sub) return [];
|
|
1619
|
+
let rows = [];
|
|
1620
|
+
try {
|
|
1621
|
+
if (typeof sub.listSessions === "function") {
|
|
1622
|
+
const r = await sub.listSessions();
|
|
1623
|
+
rows = Array.isArray(r) ? r : (r && r.ok && Array.isArray(r.sessions) ? r.sessions : []);
|
|
1624
|
+
}
|
|
1625
|
+
} catch { rows = []; }
|
|
1626
|
+
if ((!Array.isArray(rows) || rows.length === 0) && typeof sub.sessions === "function") {
|
|
1627
|
+
try { rows = (sub.sessions() || []).map((id) => ({ sessionId: String(id) })); } catch { rows = []; }
|
|
1628
|
+
}
|
|
1629
|
+
return (Array.isArray(rows) ? rows : []).map((x) => ({
|
|
1630
|
+
sessionId: String(x.sessionId || x.id || ""),
|
|
1631
|
+
title: String(x.title || ""),
|
|
1632
|
+
updatedAt: Number(x.updatedAt) || 0,
|
|
1633
|
+
running: x.running === true
|
|
1634
|
+
})).filter((x) => x.sessionId);
|
|
1635
|
+
},
|
|
1636
|
+
currentSessionId: () => {
|
|
1637
|
+
const cfgId = String((configNow && configNow.sessionId) || "");
|
|
1638
|
+
if (cfgId) return cfgId;
|
|
1639
|
+
const sub = subscriber;
|
|
1640
|
+
try {
|
|
1641
|
+
if (sub && typeof sub.sessions === "function") {
|
|
1642
|
+
const ids = sub.sessions();
|
|
1643
|
+
if (Array.isArray(ids) && ids.length) return String(ids[0]);
|
|
1644
|
+
}
|
|
1645
|
+
} catch { /* 忽略 */ }
|
|
1646
|
+
return "";
|
|
1647
|
+
},
|
|
1648
|
+
currentSessionTitle: () => String((configNow && configNow.sessionTitle) || ""),
|
|
1649
|
+
cancelSession: async ({ sessionId }) => {
|
|
1650
|
+
const sub = subscriber;
|
|
1651
|
+
if (!sub || typeof sub.cancelSession !== "function") return { ok: false, message: "订阅器未就绪" };
|
|
1652
|
+
try {
|
|
1653
|
+
const r = await sub.cancelSession({ sessionId });
|
|
1654
|
+
return r && typeof r === "object" ? r : { ok: Boolean(r) };
|
|
1655
|
+
} catch (e) { return { ok: false, message: e && e.message ? e.message : String(e) }; }
|
|
1656
|
+
},
|
|
1657
|
+
switchSession: async ({ sessionId, title }) => {
|
|
1658
|
+
const r = writeImBotsSession(relayDir, "feishu", { sessionId, sessionTitle: title });
|
|
1659
|
+
if (r.ok && configNow) configNow = { ...configNow, sessionId, sessionTitle: title || "" };
|
|
1660
|
+
return r;
|
|
1661
|
+
},
|
|
1662
|
+
unbind: async () => {
|
|
1663
|
+
try { if (bot && typeof bot.clearBinding === "function") bot.clearBinding(); } catch { /* 忽略 */ }
|
|
1664
|
+
return { ok: true };
|
|
1665
|
+
},
|
|
1666
|
+
statusSnapshot: async () => {
|
|
1667
|
+
const bs = bot && typeof bot.status === "function" ? bot.status() : null;
|
|
1668
|
+
return {
|
|
1669
|
+
connected: state.connected === true,
|
|
1670
|
+
authenticated: state.authenticated === true,
|
|
1671
|
+
state: state.state,
|
|
1672
|
+
lastError: state.lastError || "",
|
|
1673
|
+
tier: tierNow(),
|
|
1674
|
+
usage: { month: monthKey(clock()), messages: usageThisMonth() },
|
|
1675
|
+
messagesPerMonth: Number(limitsNow().messages_per_month || 0),
|
|
1676
|
+
remainingMessages: Number(limitsNow().messages_per_month || 0) > 0
|
|
1677
|
+
? Math.max(0, Number(limitsNow().messages_per_month || 0) - usageThisMonth()) : null,
|
|
1678
|
+
currentSessionId: String((configNow && configNow.sessionId) || (bs && bs.boundChatId) || ""),
|
|
1679
|
+
currentSessionTitle: String((configNow && configNow.sessionTitle) || (bs && bs.boundChatName) || "")
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
});
|
|
1684
|
+
if (!cmd || !cmd.handled) return false;
|
|
1685
|
+
try {
|
|
1686
|
+
await bot.sendText(msg.chatId, cmd.text, { receiveIdType: "chat_id" });
|
|
1687
|
+
} catch { /* 回复失败不阻塞 */ }
|
|
1688
|
+
log.info(`命令 ${cmd.command}${cmd.gated ? "(会员闸拦截)" : ""} 已回复(不转模型)`);
|
|
1689
|
+
return true;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1568
1692
|
async function onInboundMessage(msg) {
|
|
1569
1693
|
if (!msg || !msg.text) return;
|
|
1570
1694
|
const at = Number(clock()) || Date.now();
|
|
1695
|
+
// ⓪ 命令层:命中即执行并回复,**绝不进模型**,也不计入额度(/stop、/unbind 永远免费)
|
|
1696
|
+
if (await handleInboundCommand(msg)) return;
|
|
1571
1697
|
// 白名单在 bot 层已过滤;这里做会员闸:派活要 assign.continue + 月度额度
|
|
1572
1698
|
await refreshTier({ minIntervalMs: 30_000 });
|
|
1573
1699
|
if (!canCap("assign.continue")) {
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* im-commands — 三家 IM 机器人(企微 / 钉钉 / 飞书)共用的**命令层**(2026-11-08)。
|
|
3
|
+
*
|
|
4
|
+
* 业主实测:在钉钉里发 `/ls` 期望列会话,但当时没有命令层 → `/ls` 被当成 prompt 转给了模型。
|
|
5
|
+
* 命令层把这类「控制类消息」在**进模型之前**拦下、自己执行并回复;解析器/文案/能力门禁三家共用,
|
|
6
|
+
* 各通道只负责把回复发出去(企微被动回复 / 钉钉 sessionWebhook / 飞书 im/v1/messages)。
|
|
7
|
+
*
|
|
8
|
+
* 命令表(能力门禁复用 `WECHAT_CAPABILITY_TABLE`,与会员升级文案同口径):
|
|
9
|
+
* | 命令 | 能力 | 免费 | 说明 |
|
|
10
|
+
* | /help | 无 | ✅ | 列出全部命令(含会员命令标注) |
|
|
11
|
+
* | /status | 无 | ✅ | 连接状态 + 当前会话 + 本月剩余指令 |
|
|
12
|
+
* | /stop | 无 | ✅ | 急停当前任务(安全阀必须免费、立即生效)|
|
|
13
|
+
* | /ls | sessions | ❌ | 最近 ≤10 个会话(标题/索引/当前标记) |
|
|
14
|
+
* | /use <N> | sessions | ❌ | 切换当前绑定会话(写配置 sessionId) |
|
|
15
|
+
* | /unbind | 无 | ✅ | 解绑/退订本会话推送(合规:随时可退订)|
|
|
16
|
+
*
|
|
17
|
+
* 未命中的 `/xxx`:回帮助提示(含"未知命令"),**绝不转给模型**。
|
|
18
|
+
* 回复一律用纯文本列表(不用 Markdown 表格;各通道发送前还会走 im-format 兜底)。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
|
|
23
|
+
import { WECHAT_CAPABILITY_TABLE, grantsCap } from "./wechat-runtime.mjs";
|
|
24
|
+
import { IM_BOTS_FILE, atomicWriteJson, readJsonFile } from "./wecom-bot.mjs";
|
|
25
|
+
|
|
26
|
+
/** 命令表(顺序即 /help 展示顺序)。`free` 只是文档标记;真正门禁看 caps。 */
|
|
27
|
+
export const IM_COMMANDS = Object.freeze([
|
|
28
|
+
Object.freeze({ name: "/help", caps: Object.freeze([]), free: true, usage: "/help", summary: "查看命令" }),
|
|
29
|
+
Object.freeze({ name: "/status", caps: Object.freeze([]), free: true, usage: "/status", summary: "连接状态 / 当前会话 / 本月额度" }),
|
|
30
|
+
Object.freeze({ name: "/stop", caps: Object.freeze([]), free: true, usage: "/stop", summary: "急停当前任务" }),
|
|
31
|
+
Object.freeze({ name: "/ls", caps: Object.freeze(["sessions"]), free: false, usage: "/ls", summary: "列出最近会话(会员)" }),
|
|
32
|
+
Object.freeze({ name: "/use", caps: Object.freeze(["sessions"]), free: false, usage: "/use <序号>", summary: "切换当前会话(会员)" }),
|
|
33
|
+
Object.freeze({ name: "/unbind", caps: Object.freeze([]), free: true, usage: "/unbind", summary: "解绑本会话推送(随时可退订)" })
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/** 会话标题截断(按码点,避免切坏 emoji/中文)。 */
|
|
37
|
+
export function truncateTitle(title, max = 24) {
|
|
38
|
+
const s = String(title == null ? "" : title).replace(/\s+/g, " ").trim();
|
|
39
|
+
if (!s) return "";
|
|
40
|
+
const chars = Array.from(s);
|
|
41
|
+
return chars.length <= max ? s : `${chars.slice(0, Math.max(1, max - 1)).join("")}…`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function pad2(n) { return String(n).padStart(2, "0"); }
|
|
45
|
+
function fmtTime(at) {
|
|
46
|
+
const n = Number(at) || 0;
|
|
47
|
+
if (!n) return "";
|
|
48
|
+
try {
|
|
49
|
+
const d = new Date(n);
|
|
50
|
+
return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
51
|
+
} catch { return ""; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 解析一条消息是否为命令。
|
|
56
|
+
* @returns {{name:string, arg:string, unknown?:boolean, raw:string}|null} null = 普通文本(照旧转给模型)
|
|
57
|
+
*/
|
|
58
|
+
export function parseImCommand(text) {
|
|
59
|
+
const raw = String(text == null ? "" : text).trim();
|
|
60
|
+
if (!raw.startsWith("/")) return null;
|
|
61
|
+
const m = /^\/([a-zA-Z]*)(?:\s+([^\s]+))?(?:\s+([\s\S]*))?$/.exec(raw);
|
|
62
|
+
if (!m) return { name: "/", arg: "", unknown: true, raw };
|
|
63
|
+
const word = String(m[1] || "").toLowerCase();
|
|
64
|
+
return { name: word ? `/${word}` : "/", arg: String(m[2] || "").toLowerCase(), rest: m[3] || "", unknown: !word, raw };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 当前 caps 是否具备该命令要求(点号前缀规则,与会员表同源)。 */
|
|
68
|
+
export function commandAllowed(command, caps) {
|
|
69
|
+
const list = Array.isArray(caps) ? caps : [];
|
|
70
|
+
return (command.caps || []).every((c) => grantsCap(list, c));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function appPromo(ctx) {
|
|
74
|
+
const base = String((ctx && ctx.appUrl) || "").trim().replace(/\/+$/, "");
|
|
75
|
+
if (!base) return "";
|
|
76
|
+
return `${base}/promo`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** /help:列出全部命令(免费/会员标注;免费档会提示哪些需要会员)。 */
|
|
80
|
+
export function helpText(ctx = {}) {
|
|
81
|
+
const caps = Array.isArray(ctx.caps) ? ctx.caps : [];
|
|
82
|
+
const lines = ["📋 可用命令(命令不会发给模型,直接执行)", ""];
|
|
83
|
+
for (const cmd of IM_COMMANDS) {
|
|
84
|
+
const allowed = commandAllowed(cmd, caps);
|
|
85
|
+
const mark = cmd.free ? "" : allowed ? "(会员)" : "(会员·当前档不可用)";
|
|
86
|
+
lines.push(`${cmd.usage} — ${cmd.summary}${mark}`);
|
|
87
|
+
}
|
|
88
|
+
lines.push("", "免费档可用的命令:/help /status /stop /unbind(急停与退订永远免费)。");
|
|
89
|
+
if (caps.includes("sessions")) lines.push("你的档位已包含会话管理(/ls、/use)。");
|
|
90
|
+
return lines.join("\n");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 未知 /xxx:不转给模型,回"未知命令 + 帮助提示"。 */
|
|
94
|
+
export function unknownCommandText(cmd, ctx = {}) {
|
|
95
|
+
const name = cmd && cmd.name && cmd.name !== "/" ? cmd.name : cmd && cmd.raw ? String(cmd.raw).split(/\s+/)[0] : "/?";
|
|
96
|
+
return `未知命令:${name}\n\n${helpText(ctx)}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 会员功能被拦:与现有升级文案同口径(能做什么/去哪升级),只是更简短。 */
|
|
100
|
+
export function upgradeText(commandName, ctx = {}) {
|
|
101
|
+
const promo = appPromo(ctx);
|
|
102
|
+
const lines = [
|
|
103
|
+
`🔒 ${commandName} 是会员功能(列出/切换会话)`,
|
|
104
|
+
"",
|
|
105
|
+
"免费可用:/help /status /stop /unbind(急停、退订永远免费)",
|
|
106
|
+
"升级 Pro:指令不限量、可开新任务、可切换/多会话"
|
|
107
|
+
];
|
|
108
|
+
if (promo) lines.push(`👉 ${promo}`);
|
|
109
|
+
return lines.join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** /status:与状态文件同源的连接/会话/额度摘要。 */
|
|
113
|
+
export async function statusText(ctx = {}) {
|
|
114
|
+
const snap = typeof ctx.statusSnapshot === "function" ? await ctx.statusSnapshot() : (ctx.status || {});
|
|
115
|
+
const s = snap && typeof snap === "object" ? snap : {};
|
|
116
|
+
const channel = String(s.channelLabel || ctx.channelLabel || "IM");
|
|
117
|
+
const connected = s.connected === true;
|
|
118
|
+
const authed = s.authenticated === true;
|
|
119
|
+
const state = String(s.state || (connected ? "connected" : "idle"));
|
|
120
|
+
const stateLine = connected
|
|
121
|
+
? (authed ? "✅ 已连接(认证通过)" : "✅ 已连接(等待认证)")
|
|
122
|
+
: `⚠️ 未连接(${state}${s.lastError ? `:${truncateTitle(s.lastError, 40)}` : ""})`;
|
|
123
|
+
const conv = truncateTitle(s.currentSessionTitle || s.boundSessionTitle || "", 24) || "未选择";
|
|
124
|
+
const convId = String(s.currentSessionId || s.boundSessionId || "");
|
|
125
|
+
const shortId = convId ? convId.replace(/^session-/, "").slice(0, 8) : "";
|
|
126
|
+
const limit = Number(s.messagesPerMonth) || 0;
|
|
127
|
+
const used = Number(s.usage && s.usage.messages) || 0;
|
|
128
|
+
const quotaLine = limit > 0
|
|
129
|
+
? `本月指令:已用 ${used}/${limit}${s.tier ? `(${s.tier})` : ""}`
|
|
130
|
+
: `本月指令:不限${s.tier ? `(${s.tier})` : ""}`;
|
|
131
|
+
const lines = [
|
|
132
|
+
"📊 连接状态",
|
|
133
|
+
`· 通道:${channel}`,
|
|
134
|
+
`· 状态:${stateLine}`,
|
|
135
|
+
`· 当前会话:${conv}${shortId ? `(#${shortId})` : ""}`,
|
|
136
|
+
`· ${quotaLine}`
|
|
137
|
+
];
|
|
138
|
+
if (s.remainingMessages !== null && s.remainingMessages !== undefined && limit > 0) {
|
|
139
|
+
lines.push(`· 剩余:${Number(s.remainingMessages) || 0} 条`);
|
|
140
|
+
}
|
|
141
|
+
return lines.join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** ctx 的 currentSessionId/currentSessionTitle 允许函数或字符串(各通道实现风格不一)。 */
|
|
145
|
+
function resolveMaybeFn(value) {
|
|
146
|
+
try { return typeof value === "function" ? String(value() || "") : String(value || ""); } catch { return ""; }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 统一取最近会话列表(ctx.sessions 返回 [{sessionId,title,updatedAt,running}])。 */
|
|
150
|
+
export async function listSessions(ctx = {}) {
|
|
151
|
+
if (typeof ctx.sessions !== "function") return [];
|
|
152
|
+
const rows = await ctx.sessions();
|
|
153
|
+
if (!Array.isArray(rows)) return [];
|
|
154
|
+
return rows
|
|
155
|
+
.filter((r) => r && typeof r.sessionId === "string" && r.sessionId)
|
|
156
|
+
.slice(0, 10)
|
|
157
|
+
.map((r) => ({
|
|
158
|
+
sessionId: r.sessionId,
|
|
159
|
+
title: String(r.title || r.sessionTitle || "").trim() || r.sessionId,
|
|
160
|
+
updatedAt: Number(r.updatedAt) || 0,
|
|
161
|
+
running: r.running === true
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** /ls:≤10 个会话,标题截断 24 + 索引 + 当前标记 + 更新时间。 */
|
|
166
|
+
export async function lsText(ctx = {}) {
|
|
167
|
+
const rows = await listSessions(ctx);
|
|
168
|
+
const current = resolveMaybeFn(ctx.currentSessionId) || String(ctx.activeSessionId || "");
|
|
169
|
+
if (!rows.length) {
|
|
170
|
+
return "📂 最近没有可列出的会话。\n\n先在电脑的 DeepSeek Harness 里打开/运行一个会话,再发 /ls。";
|
|
171
|
+
}
|
|
172
|
+
const lines = ["📂 最近会话(最多 10 个)", ""];
|
|
173
|
+
rows.forEach((r, i) => {
|
|
174
|
+
const isCurrent = r.sessionId === current;
|
|
175
|
+
const time = fmtTime(r.updatedAt);
|
|
176
|
+
const marks = [isCurrent ? "(当前)" : "", r.running ? "· 运行中" : "", time ? `· ${time}` : ""].filter(Boolean).join(" ");
|
|
177
|
+
lines.push(`${i + 1}. ${truncateTitle(r.title, 24)}${marks ? ` ${marks}` : ""}`);
|
|
178
|
+
});
|
|
179
|
+
lines.push("", "回复 /use <序号> 切换会话(例如 /use 1)。");
|
|
180
|
+
return { text: lines.join("\n"), rows };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** /use <N>:切换当前绑定会话(写配置 sessionId/sessionTitle)。 */
|
|
184
|
+
export async function useText(ctx = {}, arg = "") {
|
|
185
|
+
const rows = await listSessions(ctx);
|
|
186
|
+
if (!rows.length) return "现在没有可切换的会话。先在电脑上打开一个会话,再发 /ls 看看。";
|
|
187
|
+
const raw = String(arg || "").trim();
|
|
188
|
+
if (!raw) return "用法:/use <序号>(先发 /ls 查看会话列表,例如 /use 1)";
|
|
189
|
+
const n = Number(raw);
|
|
190
|
+
if (!Number.isInteger(n) || n < 1 || n > rows.length) {
|
|
191
|
+
return `序号超出范围:当前列表是 1-${rows.length}。先发 /ls 查看列表,再用 /use <序号> 切换。`;
|
|
192
|
+
}
|
|
193
|
+
const pick = rows[n - 1];
|
|
194
|
+
if (typeof ctx.switchSession !== "function") return "当前通道还不支持切换会话(订阅器未就绪),请在电脑上切换。";
|
|
195
|
+
const r = await ctx.switchSession({ sessionId: pick.sessionId, title: pick.title });
|
|
196
|
+
if (!r || r.ok !== true) {
|
|
197
|
+
return `切换失败:${truncateTitle((r && (r.message || r.reason)) || "未知错误", 60)}。可以到电脑上切换,或稍后再试。`;
|
|
198
|
+
}
|
|
199
|
+
return `✅ 已切换到 #${n} ${truncateTitle(pick.title, 24) || pick.sessionId}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** /stop:急停当前任务(免费、立即生效;回执明确)。 */
|
|
203
|
+
export async function stopText(ctx = {}) {
|
|
204
|
+
const current = resolveMaybeFn(ctx.currentSessionId) || String(ctx.activeSessionId || "");
|
|
205
|
+
if (!current) return "当前没有正在运行的会话,无需急停。";
|
|
206
|
+
if (typeof ctx.cancelSession !== "function") return "急停失败:后台还没连上事件通道(订阅器未就绪)。请在电脑上停止任务。";
|
|
207
|
+
const title = truncateTitle(resolveMaybeFn(ctx.currentSessionTitle) || ctx.activeSessionTitle || current, 24);
|
|
208
|
+
let r;
|
|
209
|
+
try { r = await ctx.cancelSession({ sessionId: current }); } catch (e) {
|
|
210
|
+
r = { ok: false, message: e && e.message ? e.message : String(e) };
|
|
211
|
+
}
|
|
212
|
+
if (r && r.ok === true) return `⏹ 已急停:${title}(已发送中止,正在停止当前任务)`;
|
|
213
|
+
return `⏹ 急停请求已发出,但没收到确认:${truncateTitle((r && (r.message || r.reason)) || "未知错误", 60)}。请到电脑上确认任务是否已停。`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** /unbind:解绑本会话推送(免费;合规要求任何人可退订)。 */
|
|
217
|
+
export async function unbindText(ctx = {}) {
|
|
218
|
+
if (typeof ctx.unbind !== "function") return "解绑失败:当前通道暂不支持解绑,请在电脑的「远程访问」里关闭该通道。";
|
|
219
|
+
let r;
|
|
220
|
+
try { r = await ctx.unbind(); } catch (e) { r = { ok: false, message: e && e.message ? e.message : String(e) }; }
|
|
221
|
+
if (r && r.ok === true) return "✅ 已解绑:这台电脑不再向本会话推送通知。想重新接收,发任意消息即可重新绑定。";
|
|
222
|
+
return `解绑失败:${truncateTitle((r && (r.message || r.reason)) || "未知错误", 60)}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 执行一条命令(**命中即返回 handled:true,调用方直接回复 text 并 return,绝不进模型**)。
|
|
227
|
+
*
|
|
228
|
+
* @param {{text:string, ctx:object}} args
|
|
229
|
+
* ctx 契约(各通道按自己能力提供,缺的降级):
|
|
230
|
+
* · caps[]、tier、appUrl、channelLabel
|
|
231
|
+
* · sessions(): Promise<[{sessionId,title,updatedAt,running}]>
|
|
232
|
+
* · currentSessionId(): string、currentSessionTitle?: string、activeSessionId?
|
|
233
|
+
* · cancelSession({sessionId})、switchSession({sessionId,title})、unbind()
|
|
234
|
+
* · statusSnapshot(): Promise<{connected,authenticated,state,lastError,tier,usage,messagesPerMonth,remainingMessages,currentSessionTitle,currentSessionId}>
|
|
235
|
+
* @returns {Promise<{handled:boolean, ok?:boolean, gated?:boolean, command?:string, text?:string, rows?:Array}>}
|
|
236
|
+
*/
|
|
237
|
+
export async function executeImCommand({ text, ctx = {} } = {}) {
|
|
238
|
+
const cmd = parseImCommand(text);
|
|
239
|
+
if (!cmd) return { handled: false };
|
|
240
|
+
const caps = Array.isArray(ctx.caps) ? ctx.caps : [];
|
|
241
|
+
const known = IM_COMMANDS.find((c) => c.name === cmd.name);
|
|
242
|
+
if (!known) return { handled: true, ok: false, command: cmd.name, text: unknownCommandText(cmd, ctx) };
|
|
243
|
+
if (!commandAllowed(known, caps)) {
|
|
244
|
+
return { handled: true, ok: false, gated: true, command: known.name, text: upgradeText(known.name, ctx) };
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
switch (known.name) {
|
|
248
|
+
case "/help":
|
|
249
|
+
return { handled: true, ok: true, command: known.name, text: helpText(ctx) };
|
|
250
|
+
case "/status":
|
|
251
|
+
return { handled: true, ok: true, command: known.name, text: await statusText(ctx) };
|
|
252
|
+
case "/stop":
|
|
253
|
+
return { handled: true, ok: true, command: known.name, text: await stopText(ctx) };
|
|
254
|
+
case "/unbind":
|
|
255
|
+
return { handled: true, ok: true, command: known.name, text: await unbindText(ctx) };
|
|
256
|
+
case "/ls": {
|
|
257
|
+
const r = await lsText(ctx);
|
|
258
|
+
return { handled: true, ok: true, command: known.name, text: typeof r === "string" ? r : r.text, rows: typeof r === "string" ? [] : r.rows };
|
|
259
|
+
}
|
|
260
|
+
case "/use":
|
|
261
|
+
return { handled: true, ok: true, command: known.name, text: await useText(ctx, cmd.arg) };
|
|
262
|
+
default:
|
|
263
|
+
return { handled: true, ok: false, command: known.name, text: unknownCommandText(cmd, ctx) };
|
|
264
|
+
}
|
|
265
|
+
} catch (e) {
|
|
266
|
+
return {
|
|
267
|
+
handled: true,
|
|
268
|
+
ok: false,
|
|
269
|
+
command: known.name,
|
|
270
|
+
text: `命令执行失败:${truncateTitle(e && e.message ? e.message : e, 60)}。可以到电脑上看一眼,或稍后再试。`
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** 能力表(导出方便各通道显示"会员功能"标记;单一真相源)。 */
|
|
276
|
+
export const IM_COMMAND_CAPABILITY_TABLE = WECHAT_CAPABILITY_TABLE;
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 切换会话时写 `im-bots.json` 对应通道的 `sessionId/sessionTitle`(0600 原子写,保留其它段)。
|
|
280
|
+
* 各通道 controller 会热更新并在下一次入站用新会话。
|
|
281
|
+
*/
|
|
282
|
+
export function writeImBotsSession(relayDir, channel, { sessionId, sessionTitle } = {}) {
|
|
283
|
+
const dir = String(relayDir || "");
|
|
284
|
+
const ch = String(channel || "");
|
|
285
|
+
if (!dir || !ch) return { ok: false, reason: "no_target" };
|
|
286
|
+
const sid = String(sessionId || "").trim();
|
|
287
|
+
if (!sid) return { ok: false, reason: "no_session_id" };
|
|
288
|
+
try {
|
|
289
|
+
const file = path.join(dir, IM_BOTS_FILE);
|
|
290
|
+
const prev = readJsonFile(file);
|
|
291
|
+
const base = prev && typeof prev === "object" && !Array.isArray(prev) ? prev : {};
|
|
292
|
+
const cur = base[ch] && typeof base[ch] === "object" ? base[ch] : {};
|
|
293
|
+
atomicWriteJson(file, {
|
|
294
|
+
...base,
|
|
295
|
+
version: 1,
|
|
296
|
+
[ch]: { ...cur, sessionId: sid, sessionTitle: truncateTitle(sessionTitle || "", 60) }
|
|
297
|
+
}, { onWarn: () => { /* 静默 */ } });
|
|
298
|
+
return { ok: true };
|
|
299
|
+
} catch (e) {
|
|
300
|
+
return { ok: false, reason: e && e.message ? e.message : String(e) };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -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
|
+
}
|