@zhin.js/command 1.1.6 → 1.1.8
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/README.md +54 -1
- package/lib/command-index.d.ts +16 -2
- package/lib/command-index.js +74 -27
- package/lib/command-menu.d.ts +31 -0
- package/lib/command-menu.js +81 -0
- package/lib/definition.d.ts +9 -1
- package/lib/definition.js +12 -5
- package/lib/execution-policy.d.ts +18 -0
- package/lib/execution-policy.js +27 -0
- package/lib/execution-runtime.d.ts +50 -0
- package/lib/execution-runtime.js +134 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/permission-explanation.d.ts +22 -0
- package/lib/permission-explanation.js +54 -0
- package/package.json +10 -7
- package/src/command-index.ts +92 -33
- package/src/command-menu.ts +112 -0
- package/src/definition.ts +17 -6
- package/src/execution-policy.ts +39 -0
- package/src/execution-runtime.ts +165 -0
- package/src/index.ts +1 -0
- package/src/permission-explanation.ts +58 -0
package/README.md
CHANGED
|
@@ -40,6 +40,7 @@ commands/search/[...kw]/index.ts -> search <...kw> (params: { kw: { type:
|
|
|
40
40
|
- `args`:匹配后剩余文本按空白切分的兼容视图。
|
|
41
41
|
- `segments`:匹配后剩余的结构化段,媒体和 mention 不会丢失。
|
|
42
42
|
- `config` / `use()` / `owner` / `generation`:owner-scoped Runtime 上下文。
|
|
43
|
+
- `signal`:真实 IM 入站操作的运行代退休时同步取消。补问、确认及其他需要随代结束的等待应观察此信号;它不会强制取消已经开始的发送,也不禁止持有旧 lease 的普通命令继续排空。没有携带运行代生命周期的输入(如普通 Host 调用)不会凭空获得 IM 生命周期。
|
|
43
44
|
- `input`:派发来源;IM 中为满足 `CommandMessage` 的 Runtime `Message`。
|
|
44
45
|
- `adapter` / `endpoint`:适配器实例 id 与 endpoint 名。
|
|
45
46
|
- `scene`:`{ id, type, name? }` 场景对象。
|
|
@@ -57,9 +58,32 @@ commands/search/[...kw]/index.ts -> search <...kw> (params: { kw: { type:
|
|
|
57
58
|
`addCommand('hello', defineCommand(...))`。它与目录发现共用 CommandIndex;拆成文件后
|
|
58
59
|
可获得单命令 HMR。
|
|
59
60
|
|
|
61
|
+
## 只读权限解释
|
|
62
|
+
|
|
63
|
+
`CommandIndex.list()` 的命令描述包含稳定的 capability `id` 与声明 `owner`。
|
|
64
|
+
`toCommandPermissionSubject(source)` 与真实命令 permit gate 共用投影;
|
|
65
|
+
`explainCommandPermissions(command, { source?, rolesKnown?, permissionHost })` 只做声明条件的假设检查,
|
|
66
|
+
不执行命令、参数默认函数或平台与自定义 checker。`permissionHost` 表示读取端确认的
|
|
67
|
+
`present` / `missing` / `unverified` 状态,不会通过调用它的方法来试探。
|
|
68
|
+
|
|
69
|
+
Console 的权限解释入口使用完整会话目标及可选发送者、角色假设。角色未提供时为
|
|
70
|
+
`unknown`;显式提供 canonical `sender.roles: []` 时,按实际命令投影默认 `user`,并说明
|
|
71
|
+
该角色未经平台核验。命令 `adapter` 是原生适配器名称的精确约束,独立于
|
|
72
|
+
permit 的 `adapter(...)` 匹配。保留 Endpoint、父容器和话题只保证模拟输入不丢身份,
|
|
73
|
+
不会使 `group(...)` 等内置规则自动约束这些维度。
|
|
74
|
+
|
|
75
|
+
返回的 `match` 仅表示已解释条件与假设输入匹配。空 permit、未执行 checker、缺少
|
|
76
|
+
PermissionHost 或缺少身份依据均不能被展示为“已授权”或“可以执行”。
|
|
77
|
+
|
|
60
78
|
|
|
61
79
|
## 集中声明参数、选项与帮助
|
|
62
80
|
|
|
81
|
+
**列出指令**:在 Bot 会话中发送 `菜单`,列出根插件的指令和直接子插件;`菜单 <插件实例键>` 查看子插件,嵌套实例用 `菜单 foo.bar`。菜单逐层浏览,不一次展开全树。账号设有命令前缀时同样要加前缀,如 `/菜单`。项目根配置的顶层 `menuKeyword` 可修改关键词,`false` 或空字符串关闭;默认关键词为 `菜单`,不是 `help`。详见[指令菜单](../../../docs/authoring/commands.md#查看已加载的指令菜单)。
|
|
82
|
+
|
|
83
|
+
安装 `@zhin.js/html-renderer` 后,菜单发出带原始文本备用内容的 HTML 段,复用统一发送链的图片渲染和平台策略。渲染失败或平台无法消费图片时保留原文本;无渲染器时直接返回文本。Command Feature 不调用渲染器,也不新增 Core 或样式组件依赖。
|
|
84
|
+
|
|
85
|
+
菜单每页最多 30 项(指令与子插件合计),`菜单 --page 2` 或 `菜单 rss --page 2` 翻页,图片与文本返回同一页。三列分区、内置背景与图标不需要外部素材;根配置 `menuStyle` 可设置 `title`、`subtitle`、六位 hex `accentColor` 以及 HTTP(S)/PNG/JPEG/WebP data URL 的 `backgroundImage`。同时支持原生 Markdown 与原生按钮时附加翻页控件:首页禁用上一页、末页禁用下一页,单页省略控件;不支持时保留文本导航。回调复用原会话、当前 generation 的 JSX 按钮机制。完整配置和边界见上述指令菜单文档。
|
|
86
|
+
|
|
63
87
|
目录提供静态命令路径。静态 `commands/remind/index.ts` 可集中声明任意数量的位置参数;
|
|
64
88
|
现有 `commands/foo/[id]/index.ts` 仍绑定同名 `params.id`,后面的参数无需额外动态目录。
|
|
65
89
|
|
|
@@ -90,7 +114,7 @@ export default defineCommand({
|
|
|
90
114
|
Endpoint 前缀及显式插件命名空间,不执行命令;同一权限声明仍约束帮助读取。
|
|
91
115
|
|
|
92
116
|
`dispatch()` 返回 `status`:`unmatched`(继续普通消息处理)、`invalid`(`issues` +
|
|
93
|
-
`help`)、`denied`(权限拒绝)、`executed
|
|
117
|
+
`help`)、`denied`(权限拒绝)、`limited`(执行预算拒绝)、`executed`。后四者 `matched: true`,只有 `executed`
|
|
94
118
|
包含业务结果。可靠静态路径匹配后才提示错误;纯动态路由的模糊聊天保持未匹配。
|
|
95
119
|
受限命令没有可鉴权主体时拒绝,Host 可通过 `dispatch` 提供真实 `PermissionSubject`;
|
|
96
120
|
`execute(name)` 无主体不能绕过权限。审批与命令准入独立。
|
|
@@ -102,6 +126,35 @@ Endpoint 前缀及显式插件命名空间,不执行命令;同一权限声
|
|
|
102
126
|
`params.id.optional: true`;使用 `[...items]` 时声明 `params.items.rest: true`,
|
|
103
127
|
让执行参数的可选性、数组类型与运行时一致。目录绑定的参数应放在 `params` 第一项。
|
|
104
128
|
|
|
129
|
+
## 可选执行预算
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
defineCommand({
|
|
133
|
+
execution: { cooldownMs: 5000, maxConcurrent: 1, progressAfterMs: 1500 },
|
|
134
|
+
execute: async () => buildReport(),
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
未声明 `execution` 的命令保持原行为。三个字段均可省略,但配置至少一个:
|
|
139
|
+
`cooldownMs` 为 1–86400000 毫秒,`maxConcurrent` 为 1–1000,
|
|
140
|
+
`progressAfterMs` 为 1–3600000 毫秒,均只接受正整数,未知字段拒绝。
|
|
141
|
+
|
|
142
|
+
同一能力的普通路由、别名、shortcut 共用预算;不同真实 sender、Endpoint 账号、会话、
|
|
143
|
+
parent 和 thread 独立计算。没有完整可信身份或真实 IM operation 的请求明确拒绝;
|
|
144
|
+
`execute(name)` 不能执行启用了预算的命令。参数错误、帮助、权限拒绝不占额度。
|
|
145
|
+
`limited` 返回 `limit.reason`,冷却时附精确 `retryAfterMs`;正在执行没有预测等待时间。
|
|
146
|
+
|
|
147
|
+
冷却从接纳时开始,失败或取消仍消耗本次冷却;并发占用直到业务、渲染、出站中间件、
|
|
148
|
+
最终回复及该 operation 中已经启动的发送全部结束。不要依赖 operation 结束后才启动的
|
|
149
|
+
异步发送。热更新只取消尚未开始的进度提示,旧执行仍排空并继续占用同一 Root 的预算;
|
|
150
|
+
新策略的并发数用于新准入,原来已开始的冷却保留到原到期时间。移除策略即停止新请求的预算检查。
|
|
151
|
+
状态只保留在当前进程,进程重启后重置;容量满时拒绝新身份,不驱逐活跃或仍在冷却的记录。
|
|
152
|
+
|
|
153
|
+
进度默认关闭,只在业务等待阶段发送一次“正在处理中,请稍候。”,走当前平台统一发送链路。
|
|
154
|
+
开始自动最终回复前停止尚未开始的提示,并等待已开始的提示完成;已开始的 I/O 随原 lease 排空。
|
|
155
|
+
执行内显式 `$reply()` 不会停止进度计时。提示失败不重试,也不表示任务取消或远端已经展示。
|
|
156
|
+
`@zhin.js/command/internal/execution` 是 Core 装配使用的内部入口,不是插件创作 API。
|
|
157
|
+
|
|
105
158
|
## Runtime
|
|
106
159
|
|
|
107
160
|
definition 在 import 时不注册全局状态。Feature provider 在 generation prepare 阶段完成
|
package/lib/command-index.d.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type CapabilityId, type CapabilitySlot, type PluginId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
|
|
2
2
|
import type { UserInteractionFactory } from '@zhin.js/interaction';
|
|
3
3
|
import { type CommandValidationIssue } from './arguments.js';
|
|
4
|
+
import { type CommandExecutionLimit } from './execution-policy.js';
|
|
5
|
+
export { CommandExecutionLimitedError } from './execution-policy.js';
|
|
4
6
|
import { type CommandDefinition, type CommandParameterDefinition, type CommandParameterType, type CommandSegment } from './definition.js';
|
|
5
7
|
export interface CommandParameterDescriptor extends CommandParameterDefinition {
|
|
6
8
|
readonly required: boolean;
|
|
7
9
|
}
|
|
8
10
|
export interface CommandDescriptor {
|
|
11
|
+
readonly id: CapabilityId;
|
|
12
|
+
readonly owner: PluginId;
|
|
9
13
|
readonly name: string;
|
|
10
14
|
readonly description?: string;
|
|
11
15
|
readonly adapter?: string;
|
|
@@ -15,17 +19,27 @@ export interface CommandDescriptor {
|
|
|
15
19
|
readonly options: NonNullable<CommandDefinition['options']>;
|
|
16
20
|
readonly alias?: readonly string[];
|
|
17
21
|
readonly permit?: readonly string[];
|
|
22
|
+
readonly execution?: CommandDefinition['execution'];
|
|
18
23
|
/** shortcut 触发键列表(不含预填 params)。 */
|
|
19
24
|
readonly shortcut?: readonly string[];
|
|
20
25
|
}
|
|
21
26
|
export interface CommandDispatchResult {
|
|
22
27
|
readonly matched: boolean;
|
|
23
|
-
readonly status: 'unmatched' | 'invalid' | 'denied' | 'executed';
|
|
28
|
+
readonly status: 'unmatched' | 'invalid' | 'denied' | 'limited' | 'executed';
|
|
29
|
+
readonly limit?: CommandExecutionLimit;
|
|
24
30
|
readonly issues?: readonly CommandValidationIssue[];
|
|
25
31
|
readonly help?: string;
|
|
26
32
|
readonly command?: string;
|
|
27
33
|
readonly owner?: PluginId;
|
|
28
34
|
readonly value?: unknown;
|
|
35
|
+
readonly menu?: CommandMenuNavigation;
|
|
36
|
+
}
|
|
37
|
+
/** Semantic menu navigation; the IM composition layer supplies platform controls. */
|
|
38
|
+
export interface CommandMenuNavigation {
|
|
39
|
+
readonly keyword: string;
|
|
40
|
+
readonly pluginKey?: string;
|
|
41
|
+
readonly page: number;
|
|
42
|
+
readonly pages: number;
|
|
29
43
|
}
|
|
30
44
|
export type CommandMatchInput = string | readonly Readonly<CommandSegment>[];
|
|
31
45
|
export interface CommandMenuConfig {
|
package/lib/command-index.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { SegmentMatcher, TypeMatcherRegistry, } from 'segment-matcher';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { htmlRendererToken, } from '@zhin.js/plugin-runtime';
|
|
3
|
+
import { bindOperationInput } from '@zhin.js/feature-kit';
|
|
4
|
+
import { permissionHostToken, } from '@zhin.js/permission';
|
|
5
|
+
import { commandAdapterMatches, toCommandPermissionSubject } from './permission-explanation.js';
|
|
4
6
|
import { commandUsage, commandHelp, parseCommandArguments, requestsCommandHelp } from './arguments.js';
|
|
7
|
+
import { MENU_PAGE_SIZE, readMenuAppearance, renderCommandMenu } from './command-menu.js';
|
|
8
|
+
import { admitCommandExecution } from './execution-runtime.js';
|
|
9
|
+
import { CommandExecutionLimitedError, normalizeExecutionPolicy } from './execution-policy.js';
|
|
10
|
+
export { CommandExecutionLimitedError } from './execution-policy.js';
|
|
5
11
|
import { createCommandContext, resolveCommandSession, resolveDynamicParams, } from './definition.js';
|
|
6
12
|
const segmentFields = {
|
|
7
13
|
text: 'text',
|
|
@@ -52,9 +58,13 @@ export class CommandIndex {
|
|
|
52
58
|
const shortcutKeys = slot.definition.shortcut
|
|
53
59
|
? Object.freeze(Object.keys(slot.definition.shortcut).map((key) => key.trim()))
|
|
54
60
|
: undefined;
|
|
61
|
+
const execution = normalizeExecutionPolicy(slot.definition.execution);
|
|
55
62
|
const record = Object.freeze({
|
|
63
|
+
id: slot.id,
|
|
64
|
+
owner: slot.owner,
|
|
56
65
|
name,
|
|
57
66
|
description: slot.definition.description,
|
|
67
|
+
...(execution ? { execution } : {}),
|
|
58
68
|
...(slot.definition.adapter ? { adapter: slot.definition.adapter } : {}),
|
|
59
69
|
source: slot.source,
|
|
60
70
|
usage: commandUsage(name, slot.definition),
|
|
@@ -138,18 +148,21 @@ export class CommandIndex {
|
|
|
138
148
|
const parsed = parseCommandArguments(definition, supplied, resolveDynamicParams(match.params, undefined), {});
|
|
139
149
|
if (parsed.issues.length > 0)
|
|
140
150
|
throw new CommandArgumentValidationError(parsed.issues, commandHelp(match.command.name, definition));
|
|
151
|
+
const limit = admitCommandExecution(undefined, match.command.slot.id, match.command.slot.owner, match.command.execution, operationSnapshot);
|
|
152
|
+
if (limit)
|
|
153
|
+
throw new CommandExecutionLimitedError(limit);
|
|
141
154
|
return definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner, textArgs(parsed.remaining), parsed.params, undefined, parsed.remaining, undefined, definition.adapter, parsed.options));
|
|
142
155
|
}
|
|
143
156
|
async dispatch(input, source = undefined, interactionFactory, commandPrefix = '', operationSnapshot = this.snapshot) {
|
|
144
157
|
if (this.#menu) {
|
|
145
|
-
const
|
|
146
|
-
if (
|
|
158
|
+
const menuResult = this.#dispatchMenu(input, commandPrefix, operationSnapshot);
|
|
159
|
+
if (menuResult !== undefined) {
|
|
147
160
|
return Object.freeze({
|
|
148
161
|
matched: true,
|
|
149
162
|
status: 'executed',
|
|
150
163
|
command: this.#menu.keyword,
|
|
151
164
|
owner: operationSnapshot.root,
|
|
152
|
-
|
|
165
|
+
...menuResult,
|
|
153
166
|
});
|
|
154
167
|
}
|
|
155
168
|
}
|
|
@@ -186,6 +199,9 @@ export class CommandIndex {
|
|
|
186
199
|
if (parsed.issues.length > 0)
|
|
187
200
|
return Object.freeze({ matched: true, status: 'invalid', command: match.command.name,
|
|
188
201
|
owner: match.command.slot.owner, issues: parsed.issues, help });
|
|
202
|
+
const limit = admitCommandExecution(authorSource, match.command.slot.id, match.command.slot.owner, match.command.execution, operationSnapshot);
|
|
203
|
+
if (limit)
|
|
204
|
+
return Object.freeze({ matched: true, status: 'limited', command: match.command.name, owner: match.command.slot.owner, limit });
|
|
189
205
|
const value = await definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner, textArgs(parsed.remaining), parsed.params, authorSource, parsed.remaining, interactionFactory?.(authorSource), definition.adapter, parsed.options));
|
|
190
206
|
return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value });
|
|
191
207
|
}
|
|
@@ -213,9 +229,7 @@ export class CommandIndex {
|
|
|
213
229
|
const host = this.#resolveHost(operationSnapshot);
|
|
214
230
|
if (!host)
|
|
215
231
|
return false;
|
|
216
|
-
const
|
|
217
|
-
const session = resolveCommandSession(source);
|
|
218
|
-
const subject = { ...projected, ...session, ...(session.sender ? { sender: { ...projected.sender, ...session.sender } } : {}) };
|
|
232
|
+
const subject = toCommandPermissionSubject(source);
|
|
219
233
|
if (!subject.sender?.id && !subject.scene?.id && !subject.adapter && !subject.endpoint)
|
|
220
234
|
return false;
|
|
221
235
|
return host.checkAll(permits, subject);
|
|
@@ -306,7 +320,7 @@ export class CommandIndex {
|
|
|
306
320
|
// ==========================================================================
|
|
307
321
|
// 内置菜单命令
|
|
308
322
|
// ==========================================================================
|
|
309
|
-
#dispatchMenu(input, commandPrefix
|
|
323
|
+
#dispatchMenu(input, commandPrefix, snapshot) {
|
|
310
324
|
const text = typeof input === 'string' ? input.trim() : exactMessageText(input);
|
|
311
325
|
if (text === undefined)
|
|
312
326
|
return undefined;
|
|
@@ -315,40 +329,59 @@ export class CommandIndex {
|
|
|
315
329
|
return undefined;
|
|
316
330
|
const keyword = menu.keyword;
|
|
317
331
|
if (text === keyword)
|
|
318
|
-
return this.#buildMenu(undefined, commandPrefix, keyword);
|
|
332
|
+
return this.#buildMenu(undefined, commandPrefix, keyword, snapshot);
|
|
319
333
|
if (text.startsWith(keyword + ' ')) {
|
|
320
334
|
const key = text.slice(keyword.length + 1).trim();
|
|
335
|
+
const args = key.split(/\s+/u);
|
|
336
|
+
const flag = args.indexOf('--page');
|
|
337
|
+
if (flag !== -1) {
|
|
338
|
+
const pageText = args[flag + 1] ?? '';
|
|
339
|
+
if (flag > 1 || args.length !== flag + 2 || !/^[1-9]\d*$/u.test(pageText)
|
|
340
|
+
|| !Number.isSafeInteger(Number(pageText))) {
|
|
341
|
+
return { value: `页码必须是正整数。用法:${commandPrefix}${keyword} [插件名] --page <页码>` };
|
|
342
|
+
}
|
|
343
|
+
return this.#buildMenu(flag === 1 ? args[0] : undefined, commandPrefix, keyword, snapshot, Number(pageText));
|
|
344
|
+
}
|
|
321
345
|
if (key)
|
|
322
|
-
return this.#buildMenu(key, commandPrefix, keyword);
|
|
323
|
-
return this.#buildMenu(undefined, commandPrefix, keyword);
|
|
346
|
+
return this.#buildMenu(key, commandPrefix, keyword, snapshot);
|
|
347
|
+
return this.#buildMenu(undefined, commandPrefix, keyword, snapshot);
|
|
324
348
|
}
|
|
325
349
|
return undefined;
|
|
326
350
|
}
|
|
327
|
-
#buildMenu(pluginKey, commandPrefix, keyword) {
|
|
328
|
-
const root =
|
|
351
|
+
#buildMenu(pluginKey, commandPrefix, keyword, snapshot, page = 1) {
|
|
352
|
+
const root = snapshot.root;
|
|
329
353
|
const targetId = pluginKey
|
|
330
354
|
? `${root}/${pluginKey.split('.').join('/')}`
|
|
331
355
|
: root;
|
|
332
|
-
const node =
|
|
356
|
+
const node = snapshot.tree.get(targetId);
|
|
333
357
|
if (!node)
|
|
334
|
-
return `未找到插件: ${pluginKey}
|
|
358
|
+
return { value: `未找到插件: ${pluginKey}` };
|
|
335
359
|
const directCommands = this.#commands.filter((cmd) => cmd.slot.owner === targetId);
|
|
336
360
|
const children = node.children
|
|
337
|
-
.map((childId) =>
|
|
361
|
+
.map((childId) => snapshot.tree.get(childId))
|
|
338
362
|
.filter((child) => !!child);
|
|
363
|
+
const pages = Math.max(1, Math.ceil((directCommands.length + children.length) / MENU_PAGE_SIZE));
|
|
364
|
+
const invocation = `${commandPrefix}${keyword}${pluginKey ? ` ${pluginKey}` : ''}`;
|
|
365
|
+
if (page > pages)
|
|
366
|
+
return { value: `页码超出范围:共 ${pages} 页。请使用「${invocation} --page <页码>」。` };
|
|
367
|
+
const offset = (page - 1) * MENU_PAGE_SIZE;
|
|
368
|
+
const pageCommands = directCommands.slice(offset, offset + MENU_PAGE_SIZE);
|
|
369
|
+
const pageChildren = children.slice(Math.max(0, offset - directCommands.length), Math.max(0, offset + MENU_PAGE_SIZE - directCommands.length));
|
|
339
370
|
const displayKey = pluginKey ?? node.instanceKey;
|
|
340
371
|
const lines = [`=== ${displayKey} 指令菜单 ===`];
|
|
341
|
-
if (
|
|
372
|
+
if (pages > 1)
|
|
373
|
+
lines.push(`第 ${page}/${pages} 页 · ${directCommands.length} 条指令 · ${children.length} 个子插件`);
|
|
374
|
+
if (pageCommands.length > 0) {
|
|
342
375
|
lines.push('');
|
|
343
|
-
for (const cmd of
|
|
376
|
+
for (const cmd of pageCommands) {
|
|
344
377
|
const desc = cmd.description ? ` - ${cmd.description}` : '';
|
|
345
378
|
lines.push(` ${commandPrefix}${cmd.name}${desc}`);
|
|
346
379
|
}
|
|
347
380
|
}
|
|
348
|
-
if (
|
|
381
|
+
if (pageChildren.length > 0) {
|
|
349
382
|
lines.push('');
|
|
350
383
|
lines.push('子插件:');
|
|
351
|
-
for (const child of
|
|
384
|
+
for (const child of pageChildren) {
|
|
352
385
|
const childKey = pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey;
|
|
353
386
|
const label = child.metadata?.displayName ?? child.instanceKey;
|
|
354
387
|
lines.push(` ${childKey} (${label})`);
|
|
@@ -358,11 +391,28 @@ export class CommandIndex {
|
|
|
358
391
|
lines.push('');
|
|
359
392
|
lines.push('(暂无指令和子插件)');
|
|
360
393
|
}
|
|
361
|
-
|
|
394
|
+
const hints = [];
|
|
395
|
+
if (children.length > 0)
|
|
396
|
+
hints.push(`提示:使用「${commandPrefix}${keyword} <插件名>」查看子插件的指令`);
|
|
397
|
+
if (page > 1)
|
|
398
|
+
hints.push(`上一页:${invocation} --page ${page - 1}`);
|
|
399
|
+
if (page < pages)
|
|
400
|
+
hints.push(`下一页:${invocation} --page ${page + 1}`);
|
|
401
|
+
const hint = hints.length ? hints.join('\n') : undefined;
|
|
402
|
+
if (hint) {
|
|
362
403
|
lines.push('');
|
|
363
|
-
lines.push(
|
|
404
|
+
lines.push(hint);
|
|
364
405
|
}
|
|
365
|
-
|
|
406
|
+
const text = lines.join('\n');
|
|
407
|
+
const menu = Object.freeze({ keyword, ...(pluginKey ? { pluginKey } : {}), page, pages });
|
|
408
|
+
if (!snapshot.resources.get(root)?.has(htmlRendererToken.id))
|
|
409
|
+
return { value: text, menu };
|
|
410
|
+
const config = snapshot.config.get(root);
|
|
411
|
+
return { menu, value: renderCommandMenu(displayKey, pageCommands.map(cmd => ({ name: `${commandPrefix}${cmd.name}`, description: cmd.description })), pageChildren.map(child => ({
|
|
412
|
+
name: pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey,
|
|
413
|
+
description: child.metadata?.displayName ?? child.instanceKey,
|
|
414
|
+
})), hint, text, { page, pages, commandTotal: directCommands.length, pluginTotal: children.length,
|
|
415
|
+
appearance: readMenuAppearance(config?.menuStyle) }) };
|
|
366
416
|
}
|
|
367
417
|
}
|
|
368
418
|
export function isCommandIndex(value) {
|
|
@@ -685,9 +735,6 @@ function textArgs(segments) {
|
|
|
685
735
|
function toDescriptor({ slot: _slot, segments: _segments, parameter: _parameter, ...descriptor }) {
|
|
686
736
|
return descriptor;
|
|
687
737
|
}
|
|
688
|
-
function commandAdapterMatches(record, source) {
|
|
689
|
-
return !record.adapter || operationClientAdapter(source) === record.adapter;
|
|
690
|
-
}
|
|
691
738
|
export class CommandParameterValueError extends TypeError {
|
|
692
739
|
constructor(name, type, value) {
|
|
693
740
|
super(`Invalid value for Command parameter ${name}:${type}: ${value}`);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
interface MenuEntry {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
readonly description?: string;
|
|
4
|
+
}
|
|
5
|
+
interface MenuAppearance {
|
|
6
|
+
readonly title?: string;
|
|
7
|
+
readonly subtitle?: string;
|
|
8
|
+
readonly accentColor?: string;
|
|
9
|
+
readonly backgroundImage?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface CommandMenuSegment {
|
|
12
|
+
readonly type: 'html';
|
|
13
|
+
readonly data: {
|
|
14
|
+
readonly html: string;
|
|
15
|
+
readonly text: string;
|
|
16
|
+
readonly width: number;
|
|
17
|
+
readonly fileName: string;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export declare const MENU_PAGE_SIZE = 30;
|
|
21
|
+
/** Configuration is text/data, never an interpolated CSS or HTML fragment. */
|
|
22
|
+
export declare function readMenuAppearance(value: unknown): MenuAppearance;
|
|
23
|
+
/** Presentation only: the unified outbound pipeline owns image rendering and fallback. */
|
|
24
|
+
export declare function renderCommandMenu(title: string, commands: readonly MenuEntry[], children: readonly MenuEntry[], hint: string | undefined, text: string, options?: {
|
|
25
|
+
readonly page?: number;
|
|
26
|
+
readonly pages?: number;
|
|
27
|
+
readonly commandTotal?: number;
|
|
28
|
+
readonly pluginTotal?: number;
|
|
29
|
+
readonly appearance?: MenuAppearance;
|
|
30
|
+
}): CommandMenuSegment;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export const MENU_PAGE_SIZE = 30;
|
|
2
|
+
const escape = (value) => value.replace(/[&<>"']/gu, char => ({
|
|
3
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
4
|
+
})[char] ?? char);
|
|
5
|
+
/** Configuration is text/data, never an interpolated CSS or HTML fragment. */
|
|
6
|
+
export function readMenuAppearance(value) {
|
|
7
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
8
|
+
return {};
|
|
9
|
+
const input = value;
|
|
10
|
+
const result = {};
|
|
11
|
+
if (typeof input.title === 'string')
|
|
12
|
+
result.title = input.title.slice(0, 100);
|
|
13
|
+
if (typeof input.subtitle === 'string')
|
|
14
|
+
result.subtitle = input.subtitle.slice(0, 160);
|
|
15
|
+
if (typeof input.accentColor === 'string' && /^#[\da-f]{6}$/iu.test(input.accentColor))
|
|
16
|
+
result.accentColor = input.accentColor;
|
|
17
|
+
if (typeof input.backgroundImage === 'string') {
|
|
18
|
+
try {
|
|
19
|
+
const url = new URL(input.backgroundImage);
|
|
20
|
+
if (url.protocol === 'https:' || url.protocol === 'http:'
|
|
21
|
+
|| /^data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/u.test(url.href))
|
|
22
|
+
result.backgroundImage = url.href;
|
|
23
|
+
}
|
|
24
|
+
catch { /* Invalid optional artwork falls back to the bundled illustration. */ }
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
// Self-contained artwork: no network requests or extra asset/package dependency.
|
|
29
|
+
const landscape = `<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1400" viewBox="0 0 1080 1400">
|
|
30
|
+
<defs><linearGradient id="sky" x2=".8" y2="1"><stop stop-color="#162635"/><stop offset=".48" stop-color="#344b57"/><stop offset="1" stop-color="#70837c"/></linearGradient><radialGradient id="glow"><stop stop-color="#eddbc0" stop-opacity=".3"/><stop offset="1" stop-color="#d6d7c3" stop-opacity="0"/></radialGradient><linearGradient id="hill" x2="0" y2="1"><stop stop-color="#b2c2b5" stop-opacity=".34"/><stop offset="1" stop-color="#213e45" stop-opacity=".1"/></linearGradient></defs>
|
|
31
|
+
<path fill="url(#sky)" d="M0 0h1080v1400H0z"/><circle cx="820" cy="184" r="290" fill="url(#glow)"/><circle cx="823" cy="167" r="92" fill="#e3dfc9"/><circle cx="802" cy="146" r="10" fill="#cbcdbb" opacity=".3"/><circle cx="854" cy="194" r="19" fill="#cbcdbb" opacity=".3"/>
|
|
32
|
+
<g fill="#e7d5ac" opacity=".7"><circle cx="113" cy="72" r="1.5"/><circle cx="360" cy="104" r="1"/><circle cx="624" cy="55" r="2"/><circle cx="713" cy="265" r="1.2"/><circle cx="982" cy="332" r="2"/><circle cx="463" cy="43" r="1.4"/><circle cx="540" cy="202" r="1.4"/><circle cx="96" cy="410" r="2"/></g>
|
|
33
|
+
<g fill="none" stroke="#abac8a" stroke-width="2" opacity=".6"><path d="M1080 0Q959 77 902 267M1012 45Q916 92 866 106M984 78Q1016 164 1004 229M946 146Q866 156 838 204M917 217Q947 257 945 311"/></g>
|
|
34
|
+
<g fill="#d1bc93" opacity=".7"><ellipse cx="916" cy="84" rx="8" ry="21" transform="rotate(60 916 84)"/><ellipse cx="975" cy="115" rx="8" ry="20" transform="rotate(-25 975 115)"/><ellipse cx="876" cy="172" rx="8" ry="20" transform="rotate(58 876 172)"/><ellipse cx="1006" cy="208" rx="8" ry="20"/><ellipse cx="932" cy="270" rx="8" ry="20" transform="rotate(-25 932 270)"/></g>
|
|
35
|
+
<path d="M0 615 171 375 293 517 441 315 619 564 796 417 1080 651V1400H0Z" fill="url(#hill)"/><path d="m0 867 263-305 194 263 173-167 216 142 234-183v783H0Z" fill="#243e48" opacity=".46"/><path d="M0 1155q260-270 540-160t540-125v530H0Z" fill="#17343d" opacity=".65"/>
|
|
36
|
+
<g fill="none" stroke="#b1c1b2" stroke-opacity=".14"><path d="M0 455q210-85 424-12t656-4M0 474q210-85 424-12t656-4M0 1095q310-83 600-9t480-30M0 1124q310-83 600-9t480-30"/></g></svg>`;
|
|
37
|
+
const symbols = [
|
|
38
|
+
'<path d="m16 3 4 9 9 4-9 4-4 9-4-9-9-4 9-4Z"/><path d="m16 10 2 6-2 6-2-6Z"/>',
|
|
39
|
+
'<circle cx="16" cy="16" r="11"/><path d="m10 19 6-12 6 12-6-3Z"/>',
|
|
40
|
+
'<path d="m16 3 12 7v12l-12 7-12-7V10Z"/><path d="m4 10 12 7 12-7M16 17v12"/>',
|
|
41
|
+
'<circle cx="16" cy="16" r="5"/><path d="m16 2 3 7 8-3-3 8 7 2-7 3 3 8-8-3-3 7-3-7-8 3 3-8-7-3 7-2-3-8 8 3Z"/>',
|
|
42
|
+
];
|
|
43
|
+
function icon(name, plugin = false) {
|
|
44
|
+
const hash = [...name].reduce((value, char) => (value + (char.codePointAt(0) ?? 0)) % symbols.length, 0);
|
|
45
|
+
return `<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.15">${plugin ? symbols[2] : symbols[hash]}</svg>`;
|
|
46
|
+
}
|
|
47
|
+
/** Presentation only: the unified outbound pipeline owns image rendering and fallback. */
|
|
48
|
+
export function renderCommandMenu(title, commands, children, hint, text, options = {}) {
|
|
49
|
+
const appearance = options.appearance ?? {};
|
|
50
|
+
const background = appearance.backgroundImage ?? `data:image/svg+xml,${encodeURIComponent(landscape)}`;
|
|
51
|
+
// CSS string escaping additionally prevents an artwork URL from closing <style>.
|
|
52
|
+
const cssUrl = JSON.stringify(background).replace(/</gu, '\\3c ').replace(/>/gu, '\\3e ');
|
|
53
|
+
const entries = (items, plugin) => items.map(item => `<div class="entry"><div class="icon">${icon(item.name, plugin)}</div><div class="copy"><div class="name">${escape(item.name)}</div>${item.description
|
|
54
|
+
? `<div class="description">${escape(item.description)}</div>` : ''}</div></div>`).join('');
|
|
55
|
+
const section = (label, items, total, plugin = false) => items.length === 0 ? ''
|
|
56
|
+
: `<section><div class="section-head"><h2>${label}</h2><span>${items.length} / ${total}</span></div><div class="grid">${entries(items, plugin)}</div></section>`;
|
|
57
|
+
const page = options.page ?? 1;
|
|
58
|
+
const pages = options.pages ?? 1;
|
|
59
|
+
return {
|
|
60
|
+
type: 'html',
|
|
61
|
+
data: {
|
|
62
|
+
width: 1080, fileName: 'command-menu.png', text,
|
|
63
|
+
html: `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><style>
|
|
64
|
+
*{box-sizing:border-box}body{margin:0;background:#253b46;color:#f4f0e6;font-family:"PingFang SC","Microsoft YaHei",sans-serif;font-size:18px;line-height:1.5;--accent:${appearance.accentColor ?? '#e7c993'}}
|
|
65
|
+
main{padding:42px 28px 24px;background-image:linear-gradient(180deg,#0c202a0a,#0c202a55),url(${cssUrl});background-size:cover;background-position:center top}
|
|
66
|
+
header{min-height:210px;padding:10px 22px 30px;position:relative}.eyebrow{font-size:12px;letter-spacing:5px;color:#f4e4c4;margin:0 0 17px}
|
|
67
|
+
h1{margin:0 0 10px;font-family:"Songti SC","Noto Serif CJK SC",serif;font-size:55px;font-weight:800;letter-spacing:3px;text-shadow:0 2px 16px #0c202a66;max-width:760px;line-height:1.2}
|
|
68
|
+
.subtitle{font-size:17px;color:#e0e6e0;letter-spacing:2px;max-width:700px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.summary{margin:18px 0 0;display:flex;align-items:center;gap:16px;font-size:13px;letter-spacing:1px;color:#e0dfcf}.summary i{width:22px;height:1px;background:var(--accent)}
|
|
69
|
+
section{margin:0 0 20px;padding:5px;background:#dbe9e516;border:1px solid #f0e2c722;border-radius:19px;box-shadow:0 8px 25px #08192312}.section-head{padding:14px 18px;display:flex;justify-content:space-between;align-items:center;background:#142a34db;border-radius:13px 13px 0 0}
|
|
70
|
+
h2{margin:0;font-size:20px;font-weight:700;letter-spacing:1px;color:var(--accent)}.section-head span{font-size:12px;letter-spacing:2px;color:#b7c7c5}
|
|
71
|
+
.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));background:#20363fdb;border-radius:0 0 13px 13px;overflow:hidden}
|
|
72
|
+
.entry{display:flex;align-items:center;gap:12px;padding:19px 17px;min-width:0;min-height:106px;border-top:1px solid #e7e6d614;border-right:1px solid #e7e6d614;background:linear-gradient(120deg,#ffffff08,#ffffff00)}.entry:nth-child(3n){border-right:0}
|
|
73
|
+
.icon{width:35px;height:35px;flex:0 0 35px;color:var(--accent);filter:drop-shadow(0 1px 6px #d9ba7c22)}.icon svg{display:block;width:100%;height:100%}.copy{min-width:0}.name{font-size:18px;font-weight:700;color:var(--accent)}.description{margin:4px 0 0;color:#e1e8e5;font-size:15px;line-height:1.45}
|
|
74
|
+
.name,.description,h1{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere;white-space:pre-wrap}
|
|
75
|
+
footer{margin:6px 5px 0;padding:15px 18px;display:flex;align-items:center;justify-content:space-between;gap:22px;background:#142a34da;border:1px solid #f0e2c71c;border-radius:13px}.hint{font-size:14px;color:#cfdbd7;white-space:pre-wrap;overflow-wrap:anywhere}.page{font-size:20px;color:var(--accent);white-space:nowrap;font-family:Georgia,serif}.signature{margin:16px 0 0;text-align:center;font-size:10px;letter-spacing:4px;color:#c4d0c899}.empty{margin:0 0 20px;padding:28px;background:#142a34da;border-radius:16px;color:#dbe4de}
|
|
76
|
+
</style></head><body><main><header><div class="eyebrow">ZHIN / COMMAND ATLAS</div><h1>${escape(appearance.title || `${title} · 指令指南`)}</h1><div class="subtitle">${escape(appearance.subtitle ?? '每一个灵感,都有回应。')}</div><div class="summary"><i></i>${options.commandTotal ?? commands.length} 条指令<span>·</span>${options.pluginTotal ?? children.length} 个子插件<span>·</span>${escape(title)}</div></header>
|
|
77
|
+
${section('指令索引', commands, options.commandTotal ?? commands.length)}${section('插件导航', children, options.pluginTotal ?? children.length, true)}${commands.length || children.length ? '' : '<p class="empty">暂无指令和子插件</p>'}
|
|
78
|
+
<footer><div class="hint">${escape(hint ?? '查看参数与用法:指令名 --help')}</div><div class="page">${String(page).padStart(2, '0')} / ${String(pages).padStart(2, '0')}</div></footer><div class="signature">ZHIN · MAKE EVERY MESSAGE MATTER</div></main></body></html>`,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
package/lib/definition.d.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import type { PluginId, RuntimeSnapshot } from '@zhin.js/plugin-runtime';
|
|
6
6
|
import { type AdapterClient, type RegisteredAdapterName, type CapabilityContext } from '@zhin.js/feature-kit';
|
|
7
7
|
import type { UserInteraction } from '@zhin.js/interaction';
|
|
8
|
+
import { type CommandExecutionPolicy } from './execution-policy.js';
|
|
9
|
+
export type { CommandExecutionPolicy, CommandExecutionLimit } from './execution-policy.js';
|
|
8
10
|
declare const commandBrand: "zhin.command/1";
|
|
9
11
|
export type CommandParameterType = 'string' | 'number' | 'integer' | 'float' | 'boolean' | 'word' | 'text' | 'mention' | 'image' | 'face' | 'reply' | 'forward' | 'dice' | 'rps';
|
|
10
12
|
export type CommandParameterValue = string | number | boolean | ReadonlyArray<string | number | boolean | Readonly<Record<string, unknown>>> | Readonly<Record<string, unknown>> | null;
|
|
@@ -246,6 +248,8 @@ export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput
|
|
|
246
248
|
* 例:`adapter(icqq)`、`role(master)`、`group(123,456)`。
|
|
247
249
|
*/
|
|
248
250
|
readonly permit?: readonly string[];
|
|
251
|
+
/** Optional full-operation cooldown, concurrency and one-shot progress feedback. */
|
|
252
|
+
readonly execution?: Readonly<CommandExecutionPolicy>;
|
|
249
253
|
/**
|
|
250
254
|
* 全局整句快捷方式:触发串(trim 后全文相等)→ 预填 params。
|
|
251
255
|
* 与普通路由一样是全局用户输入,冲突会在 generation 构建时拒绝。
|
|
@@ -297,4 +301,8 @@ export declare function createCommandContext(snapshot: RuntimeSnapshot, ownerId:
|
|
|
297
301
|
* @internal Command dispatcher projection helper.
|
|
298
302
|
*/
|
|
299
303
|
export declare function resolveCommandSession(input: unknown): CommandSession;
|
|
300
|
-
|
|
304
|
+
/**
|
|
305
|
+
* Pure canonical sender projection shared with read-only command condition simulation.
|
|
306
|
+
* @internal Command dispatcher projection helper.
|
|
307
|
+
*/
|
|
308
|
+
export declare function resolveCommandSender(input: Pick<CommandMessage, 'sender'>, metadata: Readonly<Record<string, unknown>> | undefined): CommandSender | undefined;
|
package/lib/definition.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { createCapabilityContext, readOperationClient, } from '@zhin.js/feature-kit';
|
|
1
|
+
import { createCapabilityContext, readOperationClient, readOperationInputSignal, } from '@zhin.js/feature-kit';
|
|
2
2
|
import { assertPermitSyntax } from '@zhin.js/permission';
|
|
3
3
|
import { validateArgumentSchemas } from './arguments.js';
|
|
4
|
+
import { normalizeExecutionPolicy } from './execution-policy.js';
|
|
4
5
|
const commandBrand = 'zhin.command/1';
|
|
5
6
|
/** @internal Runtime validator lookup. */
|
|
6
7
|
export const commandParameterTypes = new Set([
|
|
@@ -39,7 +40,8 @@ export function defineCommand(definition) {
|
|
|
39
40
|
validateCommandAlias(definition.alias);
|
|
40
41
|
validateCommandPermit(definition.permit);
|
|
41
42
|
validateCommandShortcutShape(definition.shortcut);
|
|
42
|
-
|
|
43
|
+
const execution = normalizeExecutionPolicy(definition.execution);
|
|
44
|
+
return Object.freeze({ $feature: commandBrand, ...definition, ...(execution ? { execution } : {}) });
|
|
43
45
|
}
|
|
44
46
|
function validateAdapterName(adapter) {
|
|
45
47
|
if (adapter !== undefined && (typeof adapter !== 'string' || adapter.trim() === '')) {
|
|
@@ -106,6 +108,7 @@ export function parseCommandDefinition(value) {
|
|
|
106
108
|
throw new TypeError('Command module must default-export defineCommand(...)');
|
|
107
109
|
}
|
|
108
110
|
validateAdapterName(definition.adapter);
|
|
111
|
+
normalizeExecutionPolicy(definition.execution);
|
|
109
112
|
return definition;
|
|
110
113
|
}
|
|
111
114
|
/**
|
|
@@ -123,7 +126,7 @@ export function resolveDynamicParams(params, source) {
|
|
|
123
126
|
}
|
|
124
127
|
/** @internal Command dispatcher assembly helper. */
|
|
125
128
|
export function createCommandContext(snapshot, ownerId, args, params = Object.freeze({}), input = undefined, segments = Object.freeze([]), interaction, adapter, options = Object.freeze({})) {
|
|
126
|
-
const context = createCapabilityContext(snapshot, ownerId);
|
|
129
|
+
const context = createCapabilityContext(snapshot, ownerId, undefined, readOperationInputSignal(input));
|
|
127
130
|
const session = resolveCommandSession(input);
|
|
128
131
|
const parameterValues = { __proto__: null, ...params };
|
|
129
132
|
const optionValues = { __proto__: null, ...options };
|
|
@@ -158,7 +161,7 @@ export function resolveCommandSession(input) {
|
|
|
158
161
|
const endpoint = input.endpointId
|
|
159
162
|
|| (typeof metadata?.endpoint === 'string' && metadata.endpoint ? metadata.endpoint : undefined);
|
|
160
163
|
const scene = resolveScene(input, metadata);
|
|
161
|
-
const sender =
|
|
164
|
+
const sender = resolveCommandSender(input, metadata);
|
|
162
165
|
return Object.freeze({
|
|
163
166
|
...(adapter ? { adapter } : {}),
|
|
164
167
|
...(endpoint !== undefined ? { endpoint } : {}),
|
|
@@ -194,7 +197,11 @@ function resolveScene(input, metadata) {
|
|
|
194
197
|
...(name !== undefined ? { name } : {}),
|
|
195
198
|
});
|
|
196
199
|
}
|
|
197
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Pure canonical sender projection shared with read-only command condition simulation.
|
|
202
|
+
* @internal Command dispatcher projection helper.
|
|
203
|
+
*/
|
|
204
|
+
export function resolveCommandSender(input, metadata) {
|
|
198
205
|
const structured = input.from;
|
|
199
206
|
if (isCommandSender(structured)) {
|
|
200
207
|
return freezeSender(structured);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Optional per-capability budget, isolated by sender and the complete conversation identity. */
|
|
2
|
+
export interface CommandExecutionPolicy {
|
|
3
|
+
readonly cooldownMs?: number;
|
|
4
|
+
readonly maxConcurrent?: number;
|
|
5
|
+
/** One best-effort progress message while business logic is waiting; disabled by default. */
|
|
6
|
+
readonly progressAfterMs?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface CommandExecutionLimit {
|
|
9
|
+
readonly reason: 'cooldown' | 'busy' | 'capacity' | 'operation_unavailable' | 'identity_unavailable' | 'service_unavailable';
|
|
10
|
+
/** Exact remaining cooldown; a busy operation has no predictable completion time. */
|
|
11
|
+
readonly retryAfterMs?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare class CommandExecutionLimitedError extends Error {
|
|
14
|
+
readonly limit: CommandExecutionLimit;
|
|
15
|
+
constructor(limit: CommandExecutionLimit);
|
|
16
|
+
}
|
|
17
|
+
/** @internal Validate at declaration and projection, without coercion or silent clamping. */
|
|
18
|
+
export declare function normalizeExecutionPolicy(value: unknown): Readonly<CommandExecutionPolicy> | undefined;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export class CommandExecutionLimitedError extends Error {
|
|
2
|
+
limit;
|
|
3
|
+
constructor(limit) {
|
|
4
|
+
super(`Command execution limited: ${limit.reason}`);
|
|
5
|
+
this.name = 'CommandExecutionLimitedError';
|
|
6
|
+
this.limit = limit;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/** @internal Validate at declaration and projection, without coercion or silent clamping. */
|
|
10
|
+
export function normalizeExecutionPolicy(value) {
|
|
11
|
+
if (value === undefined)
|
|
12
|
+
return undefined;
|
|
13
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
14
|
+
throw new TypeError('Command execution must be an object');
|
|
15
|
+
const limits = { cooldownMs: 86_400_000, maxConcurrent: 1000, progressAfterMs: 3_600_000 };
|
|
16
|
+
const result = {};
|
|
17
|
+
for (const [key, item] of Object.entries(value)) {
|
|
18
|
+
if (!Object.hasOwn(limits, key) || !Number.isSafeInteger(item) || item <= 0
|
|
19
|
+
|| item > limits[key]) {
|
|
20
|
+
throw new TypeError(`Command execution.${key} must be a positive bounded integer`);
|
|
21
|
+
}
|
|
22
|
+
result[key] = item;
|
|
23
|
+
}
|
|
24
|
+
if (Object.keys(result).length === 0)
|
|
25
|
+
throw new TypeError('Command execution requires at least one policy');
|
|
26
|
+
return Object.freeze(result);
|
|
27
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Internal composition contract. Not part of the command authoring facade. */
|
|
2
|
+
import { type PluginId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
|
|
3
|
+
import type { CommandExecutionLimit, CommandExecutionPolicy } from './execution-policy.js';
|
|
4
|
+
interface ConversationIdentity {
|
|
5
|
+
readonly endpoint: {
|
|
6
|
+
readonly adapter: string;
|
|
7
|
+
readonly id: string;
|
|
8
|
+
};
|
|
9
|
+
readonly kind: string;
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly parent?: {
|
|
12
|
+
readonly kind: string;
|
|
13
|
+
readonly id: string;
|
|
14
|
+
};
|
|
15
|
+
readonly threadId?: string;
|
|
16
|
+
}
|
|
17
|
+
interface AdmissionReader {
|
|
18
|
+
admit(source: unknown, capability: string, owner: PluginId, policy: CommandExecutionPolicy, snapshot: RuntimeSnapshot): CommandExecutionLimit | undefined;
|
|
19
|
+
}
|
|
20
|
+
/** @internal Admission-only Root resource; no bind or finish authority is published. */
|
|
21
|
+
export declare const commandExecutionAdmissionToken: import("@zhin.js/plugin-runtime").Token<AdmissionReader>;
|
|
22
|
+
/** @internal Held only by Core's actual leased operation. */
|
|
23
|
+
export interface CommandOperationController {
|
|
24
|
+
bind(view: object, owner: PluginId): void;
|
|
25
|
+
/** Core registers actual started delivery promises, never a caller-supplied completion callback. */
|
|
26
|
+
trackSend(send: Promise<unknown>): void;
|
|
27
|
+
/** Stop pending feedback and drain already-started feedback before the final reply. */
|
|
28
|
+
stopProgress(): Promise<void>;
|
|
29
|
+
/** Called only by the real outer operation's finally, after all final sends settle. */
|
|
30
|
+
finish(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
/** @internal Root-instance-owned state shared by projections and draining retired generations. */
|
|
33
|
+
export declare class CommandExecutionBudget {
|
|
34
|
+
#private;
|
|
35
|
+
readonly admission: AdmissionReader;
|
|
36
|
+
constructor(options?: {
|
|
37
|
+
readonly clock?: () => number;
|
|
38
|
+
readonly capacity?: number;
|
|
39
|
+
});
|
|
40
|
+
openOperation(input: {
|
|
41
|
+
readonly snapshot: RuntimeSnapshot;
|
|
42
|
+
readonly conversation: ConversationIdentity;
|
|
43
|
+
readonly senderId?: string;
|
|
44
|
+
readonly retirementSignal: AbortSignal;
|
|
45
|
+
readonly reply: (owner: PluginId, text: string) => Promise<unknown>;
|
|
46
|
+
}): CommandOperationController;
|
|
47
|
+
}
|
|
48
|
+
/** @internal Projection reads the Root resource; ordinary authoring values cannot bind an operation. */
|
|
49
|
+
export declare function admitCommandExecution(source: unknown, capability: string, owner: PluginId, policy: CommandExecutionPolicy | undefined, snapshot: RuntimeSnapshot): CommandExecutionLimit | undefined;
|
|
50
|
+
export {};
|