@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/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "@zhin.js/command",
3
- "version": "1.1.7",
3
+ "version": "1.1.9",
4
4
  "description": "Convention-based Command Feature for Zhin Plugin Runtime",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
7
7
  "types": "./lib/index.d.ts",
8
8
  "exports": {
9
+ "./internal/execution": {
10
+ "types": "./lib/execution-runtime.d.ts",
11
+ "import": "./lib/execution-runtime.js"
12
+ },
9
13
  ".": {
10
14
  "types": "./lib/index.d.ts",
11
15
  "import": "./lib/index.js"
@@ -17,10 +21,10 @@
17
21
  ],
18
22
  "dependencies": {
19
23
  "segment-matcher": "^1.0.5",
20
- "@zhin.js/feature-kit": "1.1.5",
21
- "@zhin.js/plugin-runtime": "1.1.14",
22
24
  "@zhin.js/interaction": "1.1.2",
23
- "@zhin.js/permission": "1.1.6"
25
+ "@zhin.js/feature-kit": "1.1.7",
26
+ "@zhin.js/permission": "1.1.7",
27
+ "@zhin.js/plugin-runtime": "1.1.15"
24
28
  },
25
29
  "devDependencies": {
26
30
  "@types/node": "^26.1.2",
@@ -3,19 +3,25 @@ import {
3
3
  TypeMatcherRegistry,
4
4
  type MessageSegment,
5
5
  } from 'segment-matcher';
6
- import type {
7
- CapabilitySlot,
8
- PluginId,
9
- RuntimeSnapshot,
6
+ import {
7
+ htmlRendererToken,
8
+ type CapabilityId,
9
+ type CapabilitySlot,
10
+ type PluginId,
11
+ type RuntimeSnapshot,
10
12
  } from '@zhin.js/plugin-runtime';
11
13
  import type { UserInteractionFactory } from '@zhin.js/interaction';
12
- import { bindOperationInput, operationClientAdapter } from '@zhin.js/feature-kit';
14
+ import { bindOperationInput } from '@zhin.js/feature-kit';
13
15
  import {
14
16
  permissionHostToken,
15
- toPermissionSubject,
16
17
  type PermissionHost,
17
18
  } from '@zhin.js/permission';
19
+ import { commandAdapterMatches, toCommandPermissionSubject } from './permission-explanation.js';
18
20
  import { commandUsage, commandHelp, parseCommandArguments, requestsCommandHelp, type CommandValidationIssue } from './arguments.js';
21
+ import { MENU_PAGE_SIZE, readMenuAppearance, renderCommandMenu, type CommandMenuSegment } from './command-menu.js';
22
+ import { admitCommandExecution, admitDirectCommandExecution } from './execution-runtime.js';
23
+ import { CommandExecutionLimitedError, normalizeExecutionPolicy, type CommandExecutionLimit } from './execution-policy.js';
24
+ export { CommandExecutionLimitedError } from './execution-policy.js';
19
25
  import {
20
26
  createCommandContext,
21
27
  resolveCommandSession,
@@ -32,6 +38,8 @@ export interface CommandParameterDescriptor extends CommandParameterDefinition {
32
38
  }
33
39
 
34
40
  export interface CommandDescriptor {
41
+ readonly id: CapabilityId;
42
+ readonly owner: PluginId;
35
43
  readonly name: string;
36
44
  readonly description?: string;
37
45
  readonly adapter?: string;
@@ -41,18 +49,34 @@ export interface CommandDescriptor {
41
49
  readonly options: NonNullable<CommandDefinition['options']>;
42
50
  readonly alias?: readonly string[];
43
51
  readonly permit?: readonly string[];
52
+ readonly execution?: CommandDefinition['execution'];
44
53
  /** shortcut 触发键列表(不含预填 params)。 */
45
54
  readonly shortcut?: readonly string[];
46
55
  }
47
56
 
48
57
  export interface CommandDispatchResult {
49
58
  readonly matched: boolean;
50
- readonly status: 'unmatched' | 'invalid' | 'denied' | 'executed';
59
+ readonly status: 'unmatched' | 'invalid' | 'denied' | 'limited' | 'executed';
60
+ readonly limit?: CommandExecutionLimit;
51
61
  readonly issues?: readonly CommandValidationIssue[];
52
62
  readonly help?: string;
53
63
  readonly command?: string;
54
64
  readonly owner?: PluginId;
55
65
  readonly value?: unknown;
66
+ readonly menu?: CommandMenuNavigation;
67
+ }
68
+
69
+ /** Semantic menu navigation; the IM composition layer supplies platform controls. */
70
+ export interface CommandMenuNavigation {
71
+ readonly keyword: string;
72
+ readonly pluginKey?: string;
73
+ readonly page: number;
74
+ readonly pages: number;
75
+ }
76
+
77
+ interface CommandMenuResult {
78
+ readonly value: string | CommandMenuSegment;
79
+ readonly menu?: CommandMenuNavigation;
56
80
  }
57
81
 
58
82
  interface CommandRecord extends CommandDescriptor {
@@ -129,7 +153,14 @@ export class CommandIndex {
129
153
  for (const key of keys) occupancy.set(key, source);
130
154
  };
131
155
 
156
+ const disabledByOwner = new Map<PluginId, readonly string[]>();
132
157
  for (const slot of slots) {
158
+ let disabled = disabledByOwner.get(slot.owner);
159
+ if (!disabled) {
160
+ disabled = disabledCommands(this.snapshot, slot.owner);
161
+ disabledByOwner.set(slot.owner, disabled);
162
+ }
163
+ if (isDisabledCommand(slot.localName, disabled)) continue;
133
164
  const namespace = commandNamespace(this.snapshot, slot.owner);
134
165
  const primarySegments = [...namespace, ...runtimeSegments(slot.localName)];
135
166
  const parameter = slot.definition.$parameter;
@@ -143,9 +174,13 @@ export class CommandIndex {
143
174
  ? Object.freeze(Object.keys(slot.definition.shortcut).map((key) => key.trim()))
144
175
  : undefined;
145
176
 
177
+ const execution = normalizeExecutionPolicy(slot.definition.execution);
146
178
  const record: CommandRecord = Object.freeze({
179
+ id: slot.id,
180
+ owner: slot.owner,
147
181
  name,
148
182
  description: slot.definition.description,
183
+ ...(execution ? { execution } : {}),
149
184
  ...(slot.definition.adapter ? { adapter: slot.definition.adapter } : {}),
150
185
  source: slot.source,
151
186
  usage: commandUsage(name, slot.definition),
@@ -240,8 +275,15 @@ export class CommandIndex {
240
275
  const supplied = [...match.remaining, ...(args.length > 0 ? [{ type: 'text', data: { text: args.join(' ') } }] : [])];
241
276
  const parsed = parseCommandArguments(definition, supplied, resolveDynamicParams(match.params, undefined), {});
242
277
  if (parsed.issues.length > 0) throw new CommandArgumentValidationError(parsed.issues, commandHelp(match.command.name, definition));
243
- return definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner,
244
- textArgs(parsed.remaining), parsed.params, undefined, parsed.remaining, undefined, definition.adapter, parsed.options));
278
+ // No inbound source: execution policy applies per command, never as a blanket refusal.
279
+ const admission = admitDirectCommandExecution(match.command.slot.id, match.command.execution, operationSnapshot);
280
+ if (admission.limit) throw new CommandExecutionLimitedError(admission.limit);
281
+ try {
282
+ return await definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner,
283
+ textArgs(parsed.remaining), parsed.params, undefined, parsed.remaining, undefined, definition.adapter, parsed.options));
284
+ } finally {
285
+ admission.release();
286
+ }
245
287
  }
246
288
 
247
289
  async dispatch(
@@ -252,14 +294,14 @@ export class CommandIndex {
252
294
  operationSnapshot: RuntimeSnapshot = this.snapshot,
253
295
  ): Promise<CommandDispatchResult> {
254
296
  if (this.#menu) {
255
- const menuValue = this.#dispatchMenu(input, commandPrefix);
256
- if (menuValue !== undefined) {
297
+ const menuResult = this.#dispatchMenu(input, commandPrefix, operationSnapshot);
298
+ if (menuResult !== undefined) {
257
299
  return Object.freeze({
258
300
  matched: true,
259
301
  status: 'executed',
260
302
  command: this.#menu.keyword,
261
303
  owner: operationSnapshot.root,
262
- value: menuValue,
304
+ ...menuResult,
263
305
  });
264
306
  }
265
307
  }
@@ -289,6 +331,8 @@ export class CommandIndex {
289
331
  const parsed = parseCommandArguments(definition, match.remaining, resolveDynamicParams(match.params, authorSource), resolveCommandSession(authorSource));
290
332
  if (parsed.issues.length > 0) return Object.freeze({ matched: true, status: 'invalid', command: match.command.name,
291
333
  owner: match.command.slot.owner, issues: parsed.issues, help });
334
+ const limit = admitCommandExecution(authorSource, match.command.slot.id, match.command.slot.owner, match.command.execution, operationSnapshot);
335
+ if (limit) return Object.freeze({ matched: true, status: 'limited', command: match.command.name, owner: match.command.slot.owner, limit });
292
336
  const value = await definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner,
293
337
  textArgs(parsed.remaining), parsed.params, authorSource, parsed.remaining, interactionFactory?.(authorSource), definition.adapter, parsed.options));
294
338
  return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value });
@@ -315,9 +359,7 @@ export class CommandIndex {
315
359
  if (!source || typeof source !== 'object') return false;
316
360
  const host = this.#resolveHost(operationSnapshot);
317
361
  if (!host) return false;
318
- const projected = toPermissionSubject(source);
319
- const session = resolveCommandSession(source);
320
- const subject = { ...projected, ...session, ...(session.sender ? { sender: { ...projected.sender, ...session.sender } } : {}) };
362
+ const subject = toCommandPermissionSubject(source);
321
363
  if (!subject.sender?.id && !subject.scene?.id && !subject.adapter && !subject.endpoint) return false;
322
364
  return host.checkAll(permits, subject);
323
365
  }
@@ -404,50 +446,68 @@ export class CommandIndex {
404
446
  // 内置菜单命令
405
447
  // ==========================================================================
406
448
 
407
- #dispatchMenu(input: CommandMatchInput, commandPrefix = ''): string | undefined {
449
+ #dispatchMenu(input: CommandMatchInput, commandPrefix: string, snapshot: RuntimeSnapshot): CommandMenuResult | undefined {
408
450
  const text = typeof input === 'string' ? input.trim() : exactMessageText(input);
409
451
  if (text === undefined) return undefined;
410
452
  const menu = this.#menu;
411
453
  if (!menu) return undefined;
412
454
  const keyword = menu.keyword;
413
- if (text === keyword) return this.#buildMenu(undefined, commandPrefix, keyword);
455
+ if (text === keyword) return this.#buildMenu(undefined, commandPrefix, keyword, snapshot);
414
456
  if (text.startsWith(keyword + ' ')) {
415
457
  const key = text.slice(keyword.length + 1).trim();
416
- if (key) return this.#buildMenu(key, commandPrefix, keyword);
417
- return this.#buildMenu(undefined, commandPrefix, keyword);
458
+ const args = key.split(/\s+/u);
459
+ const flag = args.indexOf('--page');
460
+ if (flag !== -1) {
461
+ const pageText = args[flag + 1] ?? '';
462
+ if (flag > 1 || args.length !== flag + 2 || !/^[1-9]\d*$/u.test(pageText)
463
+ || !Number.isSafeInteger(Number(pageText))) {
464
+ return { value: `页码必须是正整数。用法:${commandPrefix}${keyword} [插件名] --page <页码>` };
465
+ }
466
+ return this.#buildMenu(flag === 1 ? args[0] : undefined, commandPrefix, keyword, snapshot, Number(pageText));
467
+ }
468
+ if (key) return this.#buildMenu(key, commandPrefix, keyword, snapshot);
469
+ return this.#buildMenu(undefined, commandPrefix, keyword, snapshot);
418
470
  }
419
471
  return undefined;
420
472
  }
421
473
 
422
- #buildMenu(pluginKey: string | undefined, commandPrefix: string, keyword: string): string {
423
- const root = this.snapshot.root;
474
+ #buildMenu(pluginKey: string | undefined, commandPrefix: string, keyword: string, snapshot: RuntimeSnapshot, page = 1): CommandMenuResult {
475
+ const root = snapshot.root;
424
476
  const targetId = pluginKey
425
477
  ? `${root}/${pluginKey.split('.').join('/')}` as PluginId
426
478
  : root;
427
479
 
428
- const node = this.snapshot.tree.get(targetId);
429
- if (!node) return `未找到插件: ${pluginKey}`;
480
+ const node = snapshot.tree.get(targetId);
481
+ if (!node) return { value: `未找到插件: ${pluginKey}` };
430
482
 
431
483
  const directCommands = this.#commands.filter((cmd) => cmd.slot.owner === targetId);
432
484
  const children = node.children
433
- .map((childId) => this.snapshot.tree.get(childId))
485
+ .map((childId) => snapshot.tree.get(childId))
434
486
  .filter((child): child is NonNullable<typeof child> => !!child);
435
487
 
488
+ const pages = Math.max(1, Math.ceil((directCommands.length + children.length) / MENU_PAGE_SIZE));
489
+ const invocation = `${commandPrefix}${keyword}${pluginKey ? ` ${pluginKey}` : ''}`;
490
+ if (page > pages) return { value: `页码超出范围:共 ${pages} 页。请使用「${invocation} --page <页码>」。` };
491
+ const offset = (page - 1) * MENU_PAGE_SIZE;
492
+ const pageCommands = directCommands.slice(offset, offset + MENU_PAGE_SIZE);
493
+ const pageChildren = children.slice(Math.max(0, offset - directCommands.length), Math.max(0, offset + MENU_PAGE_SIZE - directCommands.length));
494
+
436
495
  const displayKey = pluginKey ?? node.instanceKey;
437
496
  const lines: string[] = [`=== ${displayKey} 指令菜单 ===`];
497
+ if (pages > 1) lines.push(`第 ${page}/${pages} 页 · ${directCommands.length} 条指令 · ${children.length} 个子插件`);
438
498
 
439
- if (directCommands.length > 0) {
499
+ if (pageCommands.length > 0) {
440
500
  lines.push('');
441
- for (const cmd of directCommands) {
501
+ for (const cmd of pageCommands) {
442
502
  const desc = cmd.description ? ` - ${cmd.description}` : '';
443
503
  lines.push(` ${commandPrefix}${cmd.name}${desc}`);
444
504
  }
445
505
  }
446
506
 
447
- if (children.length > 0) {
507
+ if (pageChildren.length > 0) {
448
508
  lines.push('');
449
509
  lines.push('子插件:');
450
- for (const child of children) {
510
+ for (const child of pageChildren) {
451
511
  const childKey = pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey;
452
512
  const label = child.metadata?.displayName ?? child.instanceKey;
453
513
  lines.push(` ${childKey} (${label})`);
@@ -459,12 +519,27 @@ export class CommandIndex {
459
519
  lines.push('(暂无指令和子插件)');
460
520
  }
461
521
 
462
- if (children.length > 0) {
522
+ const hints: string[] = [];
523
+ if (children.length > 0) hints.push(`提示:使用「${commandPrefix}${keyword} <插件名>」查看子插件的指令`);
524
+ if (page > 1) hints.push(`上一页:${invocation} --page ${page - 1}`);
525
+ if (page < pages) hints.push(`下一页:${invocation} --page ${page + 1}`);
526
+ const hint = hints.length ? hints.join('\n') : undefined;
527
+ if (hint) {
463
528
  lines.push('');
464
- lines.push(`提示:使用「${commandPrefix}${keyword} <插件名>」查看子插件的指令`);
529
+ lines.push(hint);
465
530
  }
466
531
 
467
- return lines.join('\n');
532
+ const text = lines.join('\n');
533
+ const menu = Object.freeze({ keyword, ...(pluginKey ? { pluginKey } : {}), page, pages });
534
+ if (!snapshot.resources.get(root)?.has(htmlRendererToken.id)) return { value: text, menu };
535
+ const config = snapshot.config.get(root) as Record<string, unknown> | undefined;
536
+ return { menu, value: renderCommandMenu(displayKey,
537
+ pageCommands.map(cmd => ({ name: `${commandPrefix}${cmd.name}`, description: cmd.description })),
538
+ pageChildren.map(child => ({
539
+ name: pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey,
540
+ description: child.metadata?.displayName ?? child.instanceKey,
541
+ })), hint, text, { page, pages, commandTotal: directCommands.length, pluginTotal: children.length,
542
+ appearance: readMenuAppearance(config?.menuStyle) }) };
468
543
  }
469
544
  }
470
545
 
@@ -497,6 +572,27 @@ function commandNamespace(snapshot: RuntimeSnapshot, owner: PluginId): readonly
497
572
  return Object.freeze([...segments]);
498
573
  }
499
574
 
575
+ /**
576
+ * 插件实例配置 disabledCommands 列出的指令不进入路由表。条目写插件本地的静态路径
577
+ * (不含 commandNamespace 与参数段,如 `fanyi`、`netease login`);写上级路径会连同
578
+ * 其子命令一起禁用。未匹配任何指令的条目被忽略。
579
+ */
580
+ function disabledCommands(snapshot: RuntimeSnapshot, owner: PluginId): readonly string[] {
581
+ const config = snapshot.config.get(owner) as Readonly<Record<string, unknown>> | undefined;
582
+ const value = config?.disabledCommands;
583
+ if (value === undefined) return Object.freeze([]);
584
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
585
+ throw new TypeError(`Invalid disabledCommands for ${owner}: expected an array of command names`);
586
+ }
587
+ return Object.freeze(value.map((entry: string) => splitCommand(entry).join(' ')).filter(Boolean));
588
+ }
589
+
590
+ function isDisabledCommand(localName: string, disabled: readonly string[]): boolean {
591
+ if (disabled.length === 0) return false;
592
+ const path = runtimeSegments(localName).filter((segment) => !segment.startsWith('$')).join(' ');
593
+ return disabled.some((entry) => path === entry || path.startsWith(`${entry} `));
594
+ }
595
+
500
596
  function isCommandNamespaceSegment(value: string): boolean {
501
597
  return /^[\p{L}\p{N}][\p{L}\p{N}_-]*$/u.test(value);
502
598
  }
@@ -636,7 +732,11 @@ function exactMessageText(input: CommandMatchInput): string | undefined {
636
732
  return trimmed || undefined;
637
733
  }
638
734
 
639
- function assertParameterSegment(
735
+ /**
736
+ * Route-shape rule for a convention command: at most one dynamic segment, and it is the final one.
737
+ * Shared by the runtime index and {@link bindCommandSource}.
738
+ */
739
+ export function assertParameterSegment(
640
740
  segments: readonly string[],
641
741
  parameter: CommandParameterDefinition | undefined,
642
742
  source: string,
@@ -839,10 +939,6 @@ function toDescriptor({
839
939
  return descriptor;
840
940
  }
841
941
 
842
- function commandAdapterMatches(record: CommandRecord, source: unknown): boolean {
843
- return !record.adapter || operationClientAdapter(source) === record.adapter;
844
- }
845
-
846
942
  export class CommandParameterValueError extends TypeError {
847
943
  constructor(name: string, type: CommandParameterType, value: string) {
848
944
  super(`Invalid value for Command parameter ${name}:${type}: ${value}`);
@@ -0,0 +1,112 @@
1
+ interface MenuEntry {
2
+ readonly name: string;
3
+ readonly description?: string;
4
+ }
5
+
6
+ interface MenuAppearance {
7
+ readonly title?: string;
8
+ readonly subtitle?: string;
9
+ readonly accentColor?: string;
10
+ readonly backgroundImage?: string;
11
+ }
12
+
13
+ export interface CommandMenuSegment {
14
+ readonly type: 'html';
15
+ readonly data: {
16
+ readonly html: string;
17
+ readonly text: string;
18
+ readonly width: number;
19
+ readonly fileName: string;
20
+ };
21
+ }
22
+
23
+ export const MENU_PAGE_SIZE = 30;
24
+
25
+ const escape = (value: string) => value.replace(/[&<>"']/gu, char => ({
26
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
27
+ })[char] ?? char);
28
+
29
+ /** Configuration is text/data, never an interpolated CSS or HTML fragment. */
30
+ export function readMenuAppearance(value: unknown): MenuAppearance {
31
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
32
+ const input = value as Record<string, unknown>;
33
+ const result: { title?: string; subtitle?: string; accentColor?: string; backgroundImage?: string } = {};
34
+ if (typeof input.title === 'string') result.title = input.title.slice(0, 100);
35
+ if (typeof input.subtitle === 'string') result.subtitle = input.subtitle.slice(0, 160);
36
+ if (typeof input.accentColor === 'string' && /^#[\da-f]{6}$/iu.test(input.accentColor)) result.accentColor = input.accentColor;
37
+ if (typeof input.backgroundImage === 'string') {
38
+ try {
39
+ const url = new URL(input.backgroundImage);
40
+ if (url.protocol === 'https:' || url.protocol === 'http:'
41
+ || /^data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/u.test(url.href)) result.backgroundImage = url.href;
42
+ } catch { /* Invalid optional artwork falls back to the bundled illustration. */ }
43
+ }
44
+ return result;
45
+ }
46
+
47
+ // Self-contained artwork: no network requests or extra asset/package dependency.
48
+ const landscape = `<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1400" viewBox="0 0 1080 1400">
49
+ <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>
50
+ <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"/>
51
+ <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>
52
+ <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>
53
+ <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>
54
+ <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"/>
55
+ <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>`;
56
+
57
+ const symbols = [
58
+ '<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"/>',
59
+ '<circle cx="16" cy="16" r="11"/><path d="m10 19 6-12 6 12-6-3Z"/>',
60
+ '<path d="m16 3 12 7v12l-12 7-12-7V10Z"/><path d="m4 10 12 7 12-7M16 17v12"/>',
61
+ '<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"/>',
62
+ ];
63
+
64
+ function icon(name: string, plugin = false): string {
65
+ const hash = [...name].reduce((value, char) => (value + (char.codePointAt(0) ?? 0)) % symbols.length, 0);
66
+ return `<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.15">${plugin ? symbols[2] : symbols[hash]}</svg>`;
67
+ }
68
+
69
+ /** Presentation only: the unified outbound pipeline owns image rendering and fallback. */
70
+ export function renderCommandMenu(
71
+ title: string,
72
+ commands: readonly MenuEntry[],
73
+ children: readonly MenuEntry[],
74
+ hint: string | undefined,
75
+ text: string,
76
+ options: { readonly page?: number; readonly pages?: number; readonly commandTotal?: number; readonly pluginTotal?: number; readonly appearance?: MenuAppearance } = {},
77
+ ): CommandMenuSegment {
78
+ const appearance = options.appearance ?? {};
79
+ const background = appearance.backgroundImage ?? `data:image/svg+xml,${encodeURIComponent(landscape)}`;
80
+ // CSS string escaping additionally prevents an artwork URL from closing <style>.
81
+ const cssUrl = JSON.stringify(background).replace(/</gu, '\\3c ').replace(/>/gu, '\\3e ');
82
+ const entries = (items: readonly MenuEntry[], plugin: boolean) => items.map(item =>
83
+ `<div class="entry"><div class="icon">${icon(item.name, plugin)}</div><div class="copy"><div class="name">${escape(item.name)}</div>${item.description
84
+ ? `<div class="description">${escape(item.description)}</div>` : ''}</div></div>`).join('');
85
+ const section = (label: string, items: readonly MenuEntry[], total: number, plugin = false) => items.length === 0 ? ''
86
+ : `<section><div class="section-head"><h2>${label}</h2><span>${items.length} / ${total}</span></div><div class="grid">${entries(items, plugin)}</div></section>`;
87
+ const page = options.page ?? 1;
88
+ const pages = options.pages ?? 1;
89
+
90
+ return {
91
+ type: 'html',
92
+ data: {
93
+ width: 1080, fileName: 'command-menu.png', text,
94
+ html: `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><style>
95
+ *{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'}}
96
+ main{padding:42px 28px 24px;background-image:linear-gradient(180deg,#0c202a0a,#0c202a55),url(${cssUrl});background-size:cover;background-position:center top}
97
+ header{min-height:210px;padding:10px 22px 30px;position:relative}.eyebrow{font-size:12px;letter-spacing:5px;color:#f4e4c4;margin:0 0 17px}
98
+ 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}
99
+ .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)}
100
+ 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}
101
+ 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}
102
+ .grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));background:#20363fdb;border-radius:0 0 13px 13px;overflow:hidden}
103
+ .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}
104
+ .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}
105
+ .name,.description,h1{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere;white-space:pre-wrap}
106
+ 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}
107
+ </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>
108
+ ${section('指令索引', commands, options.commandTotal ?? commands.length)}${section('插件导航', children, options.pluginTotal ?? children.length, true)}${commands.length || children.length ? '' : '<p class="empty">暂无指令和子插件</p>'}
109
+ <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>`,
110
+ },
111
+ };
112
+ }
package/src/definition.ts CHANGED
@@ -6,6 +6,7 @@ import type { PluginId, RuntimeSnapshot } from '@zhin.js/plugin-runtime';
6
6
  import {
7
7
  createCapabilityContext,
8
8
  readOperationClient,
9
+ readOperationInputSignal,
9
10
  type AdapterClient,
10
11
  type RegisteredAdapterName,
11
12
  type CapabilityContext,
@@ -13,6 +14,8 @@ import {
13
14
  import { assertPermitSyntax } from '@zhin.js/permission';
14
15
  import { validateArgumentSchemas } from './arguments.js';
15
16
  import type { UserInteraction } from '@zhin.js/interaction';
17
+ import { normalizeExecutionPolicy, type CommandExecutionPolicy } from './execution-policy.js';
18
+ export type { CommandExecutionPolicy, CommandExecutionLimit } from './execution-policy.js';
16
19
 
17
20
  const commandBrand = 'zhin.command/1' as const;
18
21
 
@@ -309,6 +312,8 @@ export interface CommandDefinition<
309
312
  * 例:`adapter(icqq)`、`role(master)`、`group(123,456)`。
310
313
  */
311
314
  readonly permit?: readonly string[];
315
+ /** Optional full-operation cooldown, concurrency and one-shot progress feedback. */
316
+ readonly execution?: Readonly<CommandExecutionPolicy>;
312
317
  /**
313
318
  * 全局整句快捷方式:触发串(trim 后全文相等)→ 预填 params。
314
319
  * 与普通路由一样是全局用户输入,冲突会在 generation 构建时拒绝。
@@ -355,6 +360,19 @@ type TypedCommandContext<TConfig, TInput extends CommandMessage, TAdapter extend
355
360
  readonly options: InferCommandOptions<O>;
356
361
  };
357
362
 
363
+ /**
364
+ * `defineCommand.withConfig<Config>()` 返回的定义函数:config 类型已固定,params / options / 返回值
365
+ * 仍从定义推断。
366
+ * @public
367
+ */
368
+ export type CommandDefiner<TConfig, TInput extends CommandMessage = CommandMessage> = <
369
+ TResult = unknown,
370
+ const P extends CommandParams = {},
371
+ const O extends CommandOptions = {},
372
+ >(
373
+ definition: CommandAuthoringDefinition<TConfig, TResult, TInput, P, O>,
374
+ ) => Readonly<CommandDefinition<TConfig, TResult, TInput, string | undefined>>;
375
+
358
376
  export function defineCommand<
359
377
  TConfig = unknown,
360
378
  TResult = unknown,
@@ -383,11 +401,34 @@ export function defineCommand<
383
401
  validateCommandAlias(definition.alias);
384
402
  validateCommandPermit(definition.permit);
385
403
  validateCommandShortcutShape(definition.shortcut);
386
- return Object.freeze({ $feature: commandBrand, ...definition }) as Readonly<
404
+ const execution = normalizeExecutionPolicy(definition.execution);
405
+ return Object.freeze({ $feature: commandBrand, ...definition, ...(execution ? { execution } : {}) }) as Readonly<
387
406
  CommandDefinition<TConfig, TResult, TInput, string | undefined>
388
407
  >;
389
408
  }
390
409
 
410
+ /**
411
+ * 先固定插件 config 类型,再定义命令;params / options / 返回值仍从定义推断。
412
+ *
413
+ * ```ts
414
+ * export default defineCommand.withConfig<Config>()({
415
+ * params: { count: { type: 'integer' } },
416
+ * execute({ params, config }) { params.count; config.prefix },
417
+ * })
418
+ * ```
419
+ *
420
+ * `defineCommand<Config>({...})` 仍然可用,但 TypeScript 不支持部分推断,显式写了
421
+ * 类型参数后 params 会退化成 `Record<string, CommandParameterValue>`。
422
+ * 这里是独立的属性而不是 `defineCommand()` 重载:`defineCommand` 常被当作值传给
423
+ * `createEndpointCommands(spec, defineCommand)` 这类工厂,重载会让它推断错签名。
424
+ */
425
+ defineCommand.withConfig = function withConfig<
426
+ TConfig = unknown,
427
+ TInput extends CommandMessage = CommandMessage,
428
+ >(): CommandDefiner<TConfig, TInput> {
429
+ return (definition) => defineCommand(definition);
430
+ };
431
+
391
432
  function validateAdapterName(adapter: string | undefined): void {
392
433
  if (adapter !== undefined && (typeof adapter !== 'string' || adapter.trim() === '')) {
393
434
  throw new TypeError('Command adapter must be a non-empty string');
@@ -464,6 +505,7 @@ export function parseCommandDefinition(value: unknown): CommandDefinition {
464
505
  throw new TypeError('Command module must default-export defineCommand(...)');
465
506
  }
466
507
  validateAdapterName(definition.adapter);
508
+ normalizeExecutionPolicy(definition.execution);
467
509
  return definition as CommandDefinition;
468
510
  }
469
511
 
@@ -496,7 +538,7 @@ export function createCommandContext(
496
538
  adapter?: string,
497
539
  options: Readonly<Record<string, CommandParameterValue>> = Object.freeze({}),
498
540
  ): CommandContext {
499
- const context = createCapabilityContext(snapshot, ownerId);
541
+ const context = createCapabilityContext(snapshot, ownerId, undefined, readOperationInputSignal(input));
500
542
  const session = resolveCommandSession(input);
501
543
  const parameterValues: Record<string, CommandParameterValue> = { __proto__: null, ...params };
502
544
  const optionValues: Record<string, CommandParameterValue> = { __proto__: null, ...options };
@@ -534,7 +576,7 @@ export function resolveCommandSession(input: unknown): CommandSession {
534
576
  || (typeof metadata?.endpoint === 'string' && metadata.endpoint ? metadata.endpoint : undefined);
535
577
 
536
578
  const scene = resolveScene(input, metadata);
537
- const sender = resolveSender(input, metadata);
579
+ const sender = resolveCommandSender(input, metadata);
538
580
 
539
581
  return Object.freeze({
540
582
  ...(adapter ? { adapter } : {}),
@@ -583,8 +625,12 @@ function resolveScene(
583
625
  });
584
626
  }
585
627
 
586
- function resolveSender(
587
- input: CommandMessage,
628
+ /**
629
+ * Pure canonical sender projection shared with read-only command condition simulation.
630
+ * @internal Command dispatcher projection helper.
631
+ */
632
+ export function resolveCommandSender(
633
+ input: Pick<CommandMessage, 'sender'>,
588
634
  metadata: Readonly<Record<string, unknown>> | undefined,
589
635
  ): CommandSender | undefined {
590
636
  const structured = (input as { readonly from?: unknown }).from;
@@ -606,7 +652,7 @@ function resolveSender(
606
652
  }
607
653
 
608
654
  function resolveRoles(
609
- input: CommandMessage,
655
+ input: Pick<CommandMessage, 'sender'>,
610
656
  metadata: Readonly<Record<string, unknown>> | undefined,
611
657
  ): readonly string[] {
612
658
  if (input.sender?.roles?.length) {
@@ -0,0 +1,39 @@
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
+
9
+ export interface CommandExecutionLimit {
10
+ readonly reason: 'cooldown' | 'busy' | 'capacity' | 'operation_unavailable' | 'identity_unavailable' | 'service_unavailable';
11
+ /** Exact remaining cooldown; a busy operation has no predictable completion time. */
12
+ readonly retryAfterMs?: number;
13
+ }
14
+
15
+ export class CommandExecutionLimitedError extends Error {
16
+ readonly limit: CommandExecutionLimit;
17
+ constructor(limit: CommandExecutionLimit) {
18
+ super(`Command execution limited: ${limit.reason}`);
19
+ this.name = 'CommandExecutionLimitedError';
20
+ this.limit = limit;
21
+ }
22
+ }
23
+
24
+ /** @internal Validate at declaration and projection, without coercion or silent clamping. */
25
+ export function normalizeExecutionPolicy(value: unknown): Readonly<CommandExecutionPolicy> | undefined {
26
+ if (value === undefined) return undefined;
27
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('Command execution must be an object');
28
+ const limits = { cooldownMs: 86_400_000, maxConcurrent: 1000, progressAfterMs: 3_600_000 };
29
+ const result: Record<string, number> = {};
30
+ for (const [key, item] of Object.entries(value)) {
31
+ if (!Object.hasOwn(limits, key) || !Number.isSafeInteger(item) || (item as number) <= 0
32
+ || (item as number) > limits[key as keyof typeof limits]) {
33
+ throw new TypeError(`Command execution.${key} must be a positive bounded integer`);
34
+ }
35
+ result[key] = item as number;
36
+ }
37
+ if (Object.keys(result).length === 0) throw new TypeError('Command execution requires at least one policy');
38
+ return Object.freeze(result);
39
+ }