@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 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`。后三者 `matched: true`,只有 `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 阶段完成
@@ -1,11 +1,15 @@
1
- import type { CapabilitySlot, PluginId, RuntimeSnapshot } from '@zhin.js/plugin-runtime';
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 {
@@ -1,7 +1,13 @@
1
1
  import { SegmentMatcher, TypeMatcherRegistry, } from 'segment-matcher';
2
- import { bindOperationInput, operationClientAdapter } from '@zhin.js/feature-kit';
3
- import { permissionHostToken, toPermissionSubject, } from '@zhin.js/permission';
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 menuValue = this.#dispatchMenu(input, commandPrefix);
146
- if (menuValue !== undefined) {
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
- value: menuValue,
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 projected = toPermissionSubject(source);
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 = this.snapshot.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 = this.snapshot.tree.get(targetId);
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) => this.snapshot.tree.get(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 (directCommands.length > 0) {
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 directCommands) {
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 (children.length > 0) {
381
+ if (pageChildren.length > 0) {
349
382
  lines.push('');
350
383
  lines.push('子插件:');
351
- for (const child of children) {
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
- if (children.length > 0) {
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(`提示:使用「${commandPrefix}${keyword} <插件名>」查看子插件的指令`);
404
+ lines.push(hint);
364
405
  }
365
- return lines.join('\n');
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
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
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
+ }
@@ -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
- export {};
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
- return Object.freeze({ $feature: commandBrand, ...definition });
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 = resolveSender(input, metadata);
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
- function resolveSender(input, metadata) {
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 {};