@mrrisega/dsh-remote 0.7.12-beta.5 → 0.7.12-beta.6
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/dingtalk-bot.mjs +249 -32
- package/clients/dsh-remote/feishu-bot.mjs +101 -0
- package/clients/dsh-remote/im-commands.mjs +302 -0
- package/clients/dsh-remote/test/dingtalk-bot.test.mjs +218 -1
- package/clients/dsh-remote/test/feishu-bot.test.mjs +34 -2
- 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/wecom-bot.test.mjs +37 -0
- package/clients/dsh-remote/wecom-bot.mjs +84 -2
- 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/dsh-bridge.mjs +35 -0
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +1 -1
|
@@ -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
|
+
}
|
|
@@ -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,35 @@ 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
|
+
});
|
|
@@ -152,7 +152,7 @@ function makeFetch({ tokenCode = 0, sendCode = 0, sendMsg = "", patchCode = 0 }
|
|
|
152
152
|
// ── 假事件订阅器 ──────────────────────────────────────────────────────────
|
|
153
153
|
function makeSubscriber() {
|
|
154
154
|
const emitter = new EventEmitter();
|
|
155
|
-
const calls = { sessions: [], prompts: [], started: 0, stopped: 0, listenSessions: 0 };
|
|
155
|
+
const calls = { sessions: [], prompts: [], cancels: [], started: 0, stopped: 0, listenSessions: 0 };
|
|
156
156
|
return {
|
|
157
157
|
calls,
|
|
158
158
|
on: (evt, cb) => emitter.on(evt, cb),
|
|
@@ -161,7 +161,8 @@ function makeSubscriber() {
|
|
|
161
161
|
stop() { calls.stopped += 1; },
|
|
162
162
|
sessions() { return ["session-abc"]; },
|
|
163
163
|
async listSessions() { calls.listenSessions += 1; return [{ sessionId: "session-abc", title: "t" }]; },
|
|
164
|
-
async promptSession(req) { calls.prompts.push(req); return { ok: true }; }
|
|
164
|
+
async promptSession(req) { calls.prompts.push(req); return { ok: true }; },
|
|
165
|
+
async cancelSession(req) { calls.cancels.push(req); return { ok: true }; }
|
|
165
166
|
};
|
|
166
167
|
}
|
|
167
168
|
|
|
@@ -1087,3 +1088,34 @@ test("向导:主路径只有两件事(填 App ID/Secret + 发一句话自动
|
|
|
1087
1088
|
assert.match(FEISHU_SETUP_GUIDE.oneAppOnePc, /一台电脑/);
|
|
1088
1089
|
assert.match(FEISHU_SETUP_GUIDE.docs.registerApp, /open\.feishu\.cn\/app/);
|
|
1089
1090
|
});
|
|
1091
|
+
|
|
1092
|
+
// ─────────────────────── 命令层(2026-11-08:/ls 不再转给模型)───────────────────────
|
|
1093
|
+
|
|
1094
|
+
test("controller:命令层 — /help 直接回复、免费档 /ls 被拦、/stop 急停;命令绝不进模型", async () => {
|
|
1095
|
+
const relayDir = mkRelayDir("ctl-cmd");
|
|
1096
|
+
writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: APP_SECRET, receiveId: "oc_chat_1", receiveIdType: "chat_id" });
|
|
1097
|
+
const sub = makeSubscriber();
|
|
1098
|
+
const { controller, fetchCalls } = startController(relayDir, { subscriber: sub, capsProvider: async () => "free" });
|
|
1099
|
+
await controller.start();
|
|
1100
|
+
const lastText = () => {
|
|
1101
|
+
const rows = fetchCalls.filter((c) => c.url.includes("/im/v1/messages") && JSON.parse(c.body).msg_type === "text");
|
|
1102
|
+
return rows.length ? JSON.parse(rows[rows.length - 1].body).content : "";
|
|
1103
|
+
};
|
|
1104
|
+
controller._bot()._dispatch("im.message.receive_v1", inboundText("/help"));
|
|
1105
|
+
await waitFor(() => /可用命令/.test(lastText()));
|
|
1106
|
+
assert.match(lastText(), /可用命令/);
|
|
1107
|
+
assert.equal(sub.calls.prompts.length, 0, "★ 命令不得进模型");
|
|
1108
|
+
controller._bot()._dispatch("im.message.receive_v1", inboundText("/ls"));
|
|
1109
|
+
await waitFor(() => /会员功能/.test(lastText()));
|
|
1110
|
+
assert.match(lastText(), /会员功能/, "免费档 /ls 必须被拦");
|
|
1111
|
+
assert.equal(sub.calls.prompts.length, 0);
|
|
1112
|
+
controller._bot()._dispatch("im.message.receive_v1", inboundText("/stop"));
|
|
1113
|
+
await waitFor(() => /已急停|没有正在运行/.test(lastText()));
|
|
1114
|
+
assert.equal(sub.calls.cancels.length, 1, "★ /stop 调 cancelSession");
|
|
1115
|
+
assert.equal(sub.calls.prompts.length, 0, "★ 全部命令都不进模型");
|
|
1116
|
+
// 普通文本照旧转发
|
|
1117
|
+
controller._bot()._dispatch("im.message.receive_v1", inboundText("继续跑"));
|
|
1118
|
+
await waitFor(() => sub.calls.prompts.length === 1);
|
|
1119
|
+
assert.equal(sub.calls.prompts.length, 1);
|
|
1120
|
+
await controller.stop();
|
|
1121
|
+
});
|
|
@@ -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
|
+
}
|