@zhin.js/command 1.1.7 → 1.1.9

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 {
@@ -42,6 +56,11 @@ export declare class CommandIndex {
42
56
  dispatch(input: CommandMatchInput, source?: unknown, interactionFactory?: UserInteractionFactory, commandPrefix?: string, operationSnapshot?: RuntimeSnapshot): Promise<CommandDispatchResult>;
43
57
  }
44
58
  export declare function isCommandIndex(value: unknown): value is CommandIndex;
59
+ /**
60
+ * Route-shape rule for a convention command: at most one dynamic segment, and it is the final one.
61
+ * Shared by the runtime index and {@link bindCommandSource}.
62
+ */
63
+ export declare function assertParameterSegment(segments: readonly string[], parameter: CommandParameterDefinition | undefined, source: string): void;
45
64
  export declare class CommandParameterValueError extends TypeError {
46
65
  constructor(name: string, type: CommandParameterType, value: string);
47
66
  }
@@ -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, admitDirectCommandExecution } 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',
@@ -39,7 +45,15 @@ export class CommandIndex {
39
45
  for (const key of keys)
40
46
  occupancy.set(key, source);
41
47
  };
48
+ const disabledByOwner = new Map();
42
49
  for (const slot of slots) {
50
+ let disabled = disabledByOwner.get(slot.owner);
51
+ if (!disabled) {
52
+ disabled = disabledCommands(this.snapshot, slot.owner);
53
+ disabledByOwner.set(slot.owner, disabled);
54
+ }
55
+ if (isDisabledCommand(slot.localName, disabled))
56
+ continue;
43
57
  const namespace = commandNamespace(this.snapshot, slot.owner);
44
58
  const primarySegments = [...namespace, ...runtimeSegments(slot.localName)];
45
59
  const parameter = slot.definition.$parameter;
@@ -52,9 +66,13 @@ export class CommandIndex {
52
66
  const shortcutKeys = slot.definition.shortcut
53
67
  ? Object.freeze(Object.keys(slot.definition.shortcut).map((key) => key.trim()))
54
68
  : undefined;
69
+ const execution = normalizeExecutionPolicy(slot.definition.execution);
55
70
  const record = Object.freeze({
71
+ id: slot.id,
72
+ owner: slot.owner,
56
73
  name,
57
74
  description: slot.definition.description,
75
+ ...(execution ? { execution } : {}),
58
76
  ...(slot.definition.adapter ? { adapter: slot.definition.adapter } : {}),
59
77
  source: slot.source,
60
78
  usage: commandUsage(name, slot.definition),
@@ -138,18 +156,27 @@ export class CommandIndex {
138
156
  const parsed = parseCommandArguments(definition, supplied, resolveDynamicParams(match.params, undefined), {});
139
157
  if (parsed.issues.length > 0)
140
158
  throw new CommandArgumentValidationError(parsed.issues, commandHelp(match.command.name, definition));
141
- return definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner, textArgs(parsed.remaining), parsed.params, undefined, parsed.remaining, undefined, definition.adapter, parsed.options));
159
+ // No inbound source: execution policy applies per command, never as a blanket refusal.
160
+ const admission = admitDirectCommandExecution(match.command.slot.id, match.command.execution, operationSnapshot);
161
+ if (admission.limit)
162
+ throw new CommandExecutionLimitedError(admission.limit);
163
+ try {
164
+ return await definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner, textArgs(parsed.remaining), parsed.params, undefined, parsed.remaining, undefined, definition.adapter, parsed.options));
165
+ }
166
+ finally {
167
+ admission.release();
168
+ }
142
169
  }
143
170
  async dispatch(input, source = undefined, interactionFactory, commandPrefix = '', operationSnapshot = this.snapshot) {
144
171
  if (this.#menu) {
145
- const menuValue = this.#dispatchMenu(input, commandPrefix);
146
- if (menuValue !== undefined) {
172
+ const menuResult = this.#dispatchMenu(input, commandPrefix, operationSnapshot);
173
+ if (menuResult !== undefined) {
147
174
  return Object.freeze({
148
175
  matched: true,
149
176
  status: 'executed',
150
177
  command: this.#menu.keyword,
151
178
  owner: operationSnapshot.root,
152
- value: menuValue,
179
+ ...menuResult,
153
180
  });
154
181
  }
155
182
  }
@@ -186,6 +213,9 @@ export class CommandIndex {
186
213
  if (parsed.issues.length > 0)
187
214
  return Object.freeze({ matched: true, status: 'invalid', command: match.command.name,
188
215
  owner: match.command.slot.owner, issues: parsed.issues, help });
216
+ const limit = admitCommandExecution(authorSource, match.command.slot.id, match.command.slot.owner, match.command.execution, operationSnapshot);
217
+ if (limit)
218
+ return Object.freeze({ matched: true, status: 'limited', command: match.command.name, owner: match.command.slot.owner, limit });
189
219
  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
220
  return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value });
191
221
  }
@@ -213,9 +243,7 @@ export class CommandIndex {
213
243
  const host = this.#resolveHost(operationSnapshot);
214
244
  if (!host)
215
245
  return false;
216
- const projected = toPermissionSubject(source);
217
- const session = resolveCommandSession(source);
218
- const subject = { ...projected, ...session, ...(session.sender ? { sender: { ...projected.sender, ...session.sender } } : {}) };
246
+ const subject = toCommandPermissionSubject(source);
219
247
  if (!subject.sender?.id && !subject.scene?.id && !subject.adapter && !subject.endpoint)
220
248
  return false;
221
249
  return host.checkAll(permits, subject);
@@ -306,7 +334,7 @@ export class CommandIndex {
306
334
  // ==========================================================================
307
335
  // 内置菜单命令
308
336
  // ==========================================================================
309
- #dispatchMenu(input, commandPrefix = '') {
337
+ #dispatchMenu(input, commandPrefix, snapshot) {
310
338
  const text = typeof input === 'string' ? input.trim() : exactMessageText(input);
311
339
  if (text === undefined)
312
340
  return undefined;
@@ -315,40 +343,59 @@ export class CommandIndex {
315
343
  return undefined;
316
344
  const keyword = menu.keyword;
317
345
  if (text === keyword)
318
- return this.#buildMenu(undefined, commandPrefix, keyword);
346
+ return this.#buildMenu(undefined, commandPrefix, keyword, snapshot);
319
347
  if (text.startsWith(keyword + ' ')) {
320
348
  const key = text.slice(keyword.length + 1).trim();
349
+ const args = key.split(/\s+/u);
350
+ const flag = args.indexOf('--page');
351
+ if (flag !== -1) {
352
+ const pageText = args[flag + 1] ?? '';
353
+ if (flag > 1 || args.length !== flag + 2 || !/^[1-9]\d*$/u.test(pageText)
354
+ || !Number.isSafeInteger(Number(pageText))) {
355
+ return { value: `页码必须是正整数。用法:${commandPrefix}${keyword} [插件名] --page <页码>` };
356
+ }
357
+ return this.#buildMenu(flag === 1 ? args[0] : undefined, commandPrefix, keyword, snapshot, Number(pageText));
358
+ }
321
359
  if (key)
322
- return this.#buildMenu(key, commandPrefix, keyword);
323
- return this.#buildMenu(undefined, commandPrefix, keyword);
360
+ return this.#buildMenu(key, commandPrefix, keyword, snapshot);
361
+ return this.#buildMenu(undefined, commandPrefix, keyword, snapshot);
324
362
  }
325
363
  return undefined;
326
364
  }
327
- #buildMenu(pluginKey, commandPrefix, keyword) {
328
- const root = this.snapshot.root;
365
+ #buildMenu(pluginKey, commandPrefix, keyword, snapshot, page = 1) {
366
+ const root = snapshot.root;
329
367
  const targetId = pluginKey
330
368
  ? `${root}/${pluginKey.split('.').join('/')}`
331
369
  : root;
332
- const node = this.snapshot.tree.get(targetId);
370
+ const node = snapshot.tree.get(targetId);
333
371
  if (!node)
334
- return `未找到插件: ${pluginKey}`;
372
+ return { value: `未找到插件: ${pluginKey}` };
335
373
  const directCommands = this.#commands.filter((cmd) => cmd.slot.owner === targetId);
336
374
  const children = node.children
337
- .map((childId) => this.snapshot.tree.get(childId))
375
+ .map((childId) => snapshot.tree.get(childId))
338
376
  .filter((child) => !!child);
377
+ const pages = Math.max(1, Math.ceil((directCommands.length + children.length) / MENU_PAGE_SIZE));
378
+ const invocation = `${commandPrefix}${keyword}${pluginKey ? ` ${pluginKey}` : ''}`;
379
+ if (page > pages)
380
+ return { value: `页码超出范围:共 ${pages} 页。请使用「${invocation} --page <页码>」。` };
381
+ const offset = (page - 1) * MENU_PAGE_SIZE;
382
+ const pageCommands = directCommands.slice(offset, offset + MENU_PAGE_SIZE);
383
+ const pageChildren = children.slice(Math.max(0, offset - directCommands.length), Math.max(0, offset + MENU_PAGE_SIZE - directCommands.length));
339
384
  const displayKey = pluginKey ?? node.instanceKey;
340
385
  const lines = [`=== ${displayKey} 指令菜单 ===`];
341
- if (directCommands.length > 0) {
386
+ if (pages > 1)
387
+ lines.push(`第 ${page}/${pages} 页 · ${directCommands.length} 条指令 · ${children.length} 个子插件`);
388
+ if (pageCommands.length > 0) {
342
389
  lines.push('');
343
- for (const cmd of directCommands) {
390
+ for (const cmd of pageCommands) {
344
391
  const desc = cmd.description ? ` - ${cmd.description}` : '';
345
392
  lines.push(` ${commandPrefix}${cmd.name}${desc}`);
346
393
  }
347
394
  }
348
- if (children.length > 0) {
395
+ if (pageChildren.length > 0) {
349
396
  lines.push('');
350
397
  lines.push('子插件:');
351
- for (const child of children) {
398
+ for (const child of pageChildren) {
352
399
  const childKey = pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey;
353
400
  const label = child.metadata?.displayName ?? child.instanceKey;
354
401
  lines.push(` ${childKey} (${label})`);
@@ -358,11 +405,28 @@ export class CommandIndex {
358
405
  lines.push('');
359
406
  lines.push('(暂无指令和子插件)');
360
407
  }
361
- if (children.length > 0) {
408
+ const hints = [];
409
+ if (children.length > 0)
410
+ hints.push(`提示:使用「${commandPrefix}${keyword} <插件名>」查看子插件的指令`);
411
+ if (page > 1)
412
+ hints.push(`上一页:${invocation} --page ${page - 1}`);
413
+ if (page < pages)
414
+ hints.push(`下一页:${invocation} --page ${page + 1}`);
415
+ const hint = hints.length ? hints.join('\n') : undefined;
416
+ if (hint) {
362
417
  lines.push('');
363
- lines.push(`提示:使用「${commandPrefix}${keyword} <插件名>」查看子插件的指令`);
418
+ lines.push(hint);
364
419
  }
365
- return lines.join('\n');
420
+ const text = lines.join('\n');
421
+ const menu = Object.freeze({ keyword, ...(pluginKey ? { pluginKey } : {}), page, pages });
422
+ if (!snapshot.resources.get(root)?.has(htmlRendererToken.id))
423
+ return { value: text, menu };
424
+ const config = snapshot.config.get(root);
425
+ return { menu, value: renderCommandMenu(displayKey, pageCommands.map(cmd => ({ name: `${commandPrefix}${cmd.name}`, description: cmd.description })), pageChildren.map(child => ({
426
+ name: pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey,
427
+ description: child.metadata?.displayName ?? child.instanceKey,
428
+ })), hint, text, { page, pages, commandTotal: directCommands.length, pluginTotal: children.length,
429
+ appearance: readMenuAppearance(config?.menuStyle) }) };
366
430
  }
367
431
  }
368
432
  export function isCommandIndex(value) {
@@ -390,6 +454,27 @@ function commandNamespace(snapshot, owner) {
390
454
  }
391
455
  return Object.freeze([...segments]);
392
456
  }
457
+ /**
458
+ * 插件实例配置 disabledCommands 列出的指令不进入路由表。条目写插件本地的静态路径
459
+ * (不含 commandNamespace 与参数段,如 `fanyi`、`netease login`);写上级路径会连同
460
+ * 其子命令一起禁用。未匹配任何指令的条目被忽略。
461
+ */
462
+ function disabledCommands(snapshot, owner) {
463
+ const config = snapshot.config.get(owner);
464
+ const value = config?.disabledCommands;
465
+ if (value === undefined)
466
+ return Object.freeze([]);
467
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
468
+ throw new TypeError(`Invalid disabledCommands for ${owner}: expected an array of command names`);
469
+ }
470
+ return Object.freeze(value.map((entry) => splitCommand(entry).join(' ')).filter(Boolean));
471
+ }
472
+ function isDisabledCommand(localName, disabled) {
473
+ if (disabled.length === 0)
474
+ return false;
475
+ const path = runtimeSegments(localName).filter((segment) => !segment.startsWith('$')).join(' ');
476
+ return disabled.some((entry) => path === entry || path.startsWith(`${entry} `));
477
+ }
393
478
  function isCommandNamespaceSegment(value) {
394
479
  return /^[\p{L}\p{N}][\p{L}\p{N}_-]*$/u.test(value);
395
480
  }
@@ -516,7 +601,11 @@ function exactMessageText(input) {
516
601
  const trimmed = only.data.text.trim();
517
602
  return trimmed || undefined;
518
603
  }
519
- function assertParameterSegment(segments, parameter, source) {
604
+ /**
605
+ * Route-shape rule for a convention command: at most one dynamic segment, and it is the final one.
606
+ * Shared by the runtime index and {@link bindCommandSource}.
607
+ */
608
+ export function assertParameterSegment(segments, parameter, source) {
520
609
  const dynamicSegments = segments.filter((segment) => segment.startsWith('$'));
521
610
  if (!parameter && dynamicSegments.length === 0)
522
611
  return;
@@ -685,9 +774,6 @@ function textArgs(segments) {
685
774
  function toDescriptor({ slot: _slot, segments: _segments, parameter: _parameter, ...descriptor }) {
686
775
  return descriptor;
687
776
  }
688
- function commandAdapterMatches(record, source) {
689
- return !record.adapter || operationClientAdapter(source) === record.adapter;
690
- }
691
777
  export class CommandParameterValueError extends TypeError {
692
778
  constructor(name, type, value) {
693
779
  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 构建时拒绝。
@@ -278,7 +282,16 @@ type TypedCommandContext<TConfig, TInput extends CommandMessage, TAdapter extend
278
282
  readonly params: keyof P extends never ? Readonly<Record<string, CommandParameterValue>> : InferCommandParams<P>;
279
283
  readonly options: InferCommandOptions<O>;
280
284
  };
285
+ /**
286
+ * `defineCommand.withConfig<Config>()` 返回的定义函数:config 类型已固定,params / options / 返回值
287
+ * 仍从定义推断。
288
+ * @public
289
+ */
290
+ export type CommandDefiner<TConfig, TInput extends CommandMessage = CommandMessage> = <TResult = unknown, const P extends CommandParams = {}, const O extends CommandOptions = {}>(definition: CommandAuthoringDefinition<TConfig, TResult, TInput, P, O>) => Readonly<CommandDefinition<TConfig, TResult, TInput, string | undefined>>;
281
291
  export declare function defineCommand<TConfig = unknown, TResult = unknown, TInput extends CommandMessage = CommandMessage, const P extends CommandParams = {}, const O extends CommandOptions = {}>(definition: CommandAuthoringDefinition<TConfig, TResult, TInput, P, O>): Readonly<CommandDefinition<TConfig, TResult, TInput, string | undefined>>;
292
+ export declare namespace defineCommand {
293
+ var withConfig: <TConfig = unknown, TInput extends CommandMessage = CommandMessage>() => CommandDefiner<TConfig, TInput>;
294
+ }
282
295
  /** @internal Convention-loader assembly helper. */
283
296
  export declare function bindCommandParameter<TConfig, TResult, TInput extends CommandMessage, TAdapter extends string | undefined>(definition: CommandDefinition<TConfig, TResult, TInput, TAdapter>, parameter: CommandParameterDefinition | undefined): Readonly<CommandDefinition<TConfig, TResult, TInput, TAdapter>>;
284
297
  /** @internal Runtime validation for convention-discovered modules. */
@@ -297,4 +310,8 @@ export declare function createCommandContext(snapshot: RuntimeSnapshot, ownerId:
297
310
  * @internal Command dispatcher projection helper.
298
311
  */
299
312
  export declare function resolveCommandSession(input: unknown): CommandSession;
300
- export {};
313
+ /**
314
+ * Pure canonical sender projection shared with read-only command condition simulation.
315
+ * @internal Command dispatcher projection helper.
316
+ */
317
+ export declare function resolveCommandSender(input: Pick<CommandMessage, 'sender'>, metadata: Readonly<Record<string, unknown>> | undefined): CommandSender | undefined;