@zhin.js/command 1.1.16 → 1.2.0

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.
@@ -1,1063 +0,0 @@
1
- import {
2
- SegmentMatcher,
3
- TypeMatcherRegistry,
4
- type MessageSegment,
5
- } from 'segment-matcher';
6
- import {
7
- htmlRendererToken,
8
- resolveLocale,
9
- type CapabilityId,
10
- type CapabilitySlot,
11
- type PluginId,
12
- type RuntimeSnapshot,
13
- type Locale,
14
- } from '@zhin.js/plugin-runtime';
15
- import type { UserInteractionFactory } from '@zhin.js/im-contract';
16
- import { bindOperationInput, type CapabilityOperationFactory } from '@zhin.js/feature-kit';
17
- import {
18
- permissionHostToken,
19
- type PermissionHost,
20
- } from '@zhin.js/permission';
21
- import { commandAdapterMatches, toCommandPermissionSubject } from './permission-explanation.js';
22
- import { commandUsage, commandHelp, parseCommandArguments, requestsCommandHelp, commandValidationIssue, type CommandValidationIssue } from './arguments.js';
23
- import { commandDescription, commandMessages } from './messages.js';
24
- import { MENU_PAGE_SIZE, readMenuAppearance, renderCommandMenu, type CommandMenuSegment, type CommandMenuEntry, type CommandMenuAppearance } from './command-menu.js';
25
- export type { CommandMenuEntry, CommandMenuAppearance } from './command-menu.js';
26
- import { admitCommandExecution, admitDirectCommandExecution } from './execution-runtime.js';
27
- import { CommandExecutionLimitedError, normalizeExecutionPolicy, type CommandExecutionLimit } from './execution-policy.js';
28
- export { CommandExecutionLimitedError } from './execution-policy.js';
29
- import {
30
- createCommandContext,
31
- resolveCommandSession,
32
- type CommandDefinition,
33
- type CommandParameterDefinition,
34
- type CommandParameterType,
35
- type CommandSegment,
36
- type CommandDynamicValue,
37
- resolveDynamicParams,
38
- } from './definition.js';
39
-
40
- export interface CommandParameterDescriptor extends CommandParameterDefinition {
41
- readonly required: boolean;
42
- }
43
-
44
- export interface CommandDescriptor {
45
- readonly id: CapabilityId;
46
- readonly owner: PluginId;
47
- readonly name: string;
48
- readonly description?: string;
49
- readonly adapter?: string;
50
- readonly source: string;
51
- readonly parameters: readonly CommandParameterDescriptor[];
52
- readonly usage: string;
53
- readonly options: NonNullable<CommandDefinition['options']>;
54
- readonly alias?: readonly string[];
55
- readonly permit?: readonly string[];
56
- readonly execution?: CommandDefinition['execution'];
57
- /** shortcut 触发键列表(不含预填 params)。 */
58
- readonly shortcut?: readonly string[];
59
- }
60
-
61
- /** One dispatch reads the caller's exact operation generation and presentation. */
62
- export interface CommandDispatchRequest {
63
- readonly input: CommandMatchInput;
64
- readonly source?: unknown;
65
- readonly interactionFactory?: UserInteractionFactory;
66
- readonly commandPrefix?: string;
67
- readonly snapshot?: RuntimeSnapshot;
68
- readonly operation?: CapabilityOperationFactory;
69
- readonly locale?: Locale;
70
- }
71
- /** Presentation and generation shared across one menu lookup/render operation. */
72
- interface CommandMenuContext {
73
- readonly commandPrefix: string;
74
- readonly keyword: string;
75
- readonly snapshot: RuntimeSnapshot;
76
- readonly locale: Locale;
77
- }
78
- interface CommandMenuSelection { readonly pluginKey?: string; readonly page?: number }
79
-
80
- export interface CommandDispatchResult {
81
- readonly matched: boolean;
82
- readonly status: 'unmatched' | 'invalid' | 'denied' | 'limited' | 'executed';
83
- readonly limit?: CommandExecutionLimit;
84
- readonly issues?: readonly CommandValidationIssue[];
85
- readonly help?: string;
86
- readonly command?: string;
87
- readonly owner?: PluginId;
88
- readonly value?: unknown;
89
- readonly menu?: CommandMenuPage;
90
- }
91
-
92
- /** Semantic menu navigation; the IM composition layer supplies platform controls. */
93
- export interface CommandMenuNavigation {
94
- readonly keyword: string;
95
- readonly pluginKey?: string;
96
- readonly page: number;
97
- readonly pages: number;
98
- }
99
-
100
- /** Immutable page data; presentation cannot change the catalog or execute commands. */
101
- export interface CommandMenuPage extends CommandMenuNavigation {
102
- readonly locale: Locale;
103
- readonly title: string;
104
- readonly commands: readonly CommandMenuEntry[];
105
- readonly plugins: readonly CommandMenuEntry[];
106
- readonly commandTotal: number;
107
- readonly pluginTotal: number;
108
- readonly hint?: string;
109
- readonly text: string;
110
- readonly appearance: CommandMenuAppearance;
111
- }
112
-
113
- interface CommandMenuResult {
114
- readonly value: string | CommandMenuSegment;
115
- readonly menu?: CommandMenuPage;
116
- }
117
-
118
- interface CommandRecord extends CommandDescriptor {
119
- readonly slot: Readonly<CapabilitySlot<CommandDefinition>>;
120
- readonly segments: readonly string[];
121
- readonly preferredSegments: Readonly<Partial<Record<Locale, readonly string[]>>>;
122
- readonly parameter?: CommandParameterDefinition;
123
- }
124
-
125
- interface CommandRoute {
126
- readonly record: CommandRecord;
127
- readonly segments: readonly string[];
128
- readonly matcher: SegmentMatcher;
129
- readonly literalMatcher?: SegmentMatcher;
130
- readonly kind: 'primary' | 'alias';
131
- }
132
-
133
- interface ShortcutEntry {
134
- readonly record: CommandRecord;
135
- readonly params: Readonly<Record<string, CommandDynamicValue>>;
136
- }
137
-
138
- interface CommandMatch {
139
- readonly command: CommandRecord;
140
- readonly params: Readonly<Record<string, CommandDynamicValue>>;
141
- readonly remaining: readonly Readonly<CommandSegment>[];
142
- readonly helpRequested: boolean;
143
- }
144
-
145
- export type CommandMatchInput = string | readonly Readonly<CommandSegment>[];
146
-
147
- const segmentFields = {
148
- text: 'text',
149
- mention: 'target',
150
- at: ['target', 'user_id', 'qq'],
151
- face: 'id',
152
- image: (segment: MessageSegment) =>
153
- segment.data.media ?? segment.data.file ?? segment.data.url ?? segment.data.src,
154
- reply: ['message_id', 'reply_id', 'id'],
155
- forward: ['forward_id', 'res_id', 'message_id', 'id'],
156
- dice: 'result',
157
- rps: 'result',
158
- };
159
-
160
- export interface CommandMenuConfig {
161
- /** Omission selects the built-in keyword for each operation's language. */
162
- readonly keyword?: string;
163
- }
164
-
165
- const DEFAULT_MENU_KEYWORDS = new Set(['菜单', '帮助', 'help', 'menu']);
166
-
167
- export class CommandIndex {
168
- readonly $projection = 'zhin.command-index/1' as const;
169
- readonly #commands: readonly CommandRecord[];
170
- readonly #routes: readonly CommandRoute[];
171
- readonly #shortcuts: ReadonlyMap<string, ShortcutEntry>;
172
- readonly #menu?: CommandMenuConfig;
173
-
174
- constructor(
175
- slots: readonly Readonly<CapabilitySlot<CommandDefinition>>[],
176
- private readonly snapshot: RuntimeSnapshot,
177
- menu?: CommandMenuConfig,
178
- ) {
179
- this.#menu = menu;
180
- const commands: CommandRecord[] = [];
181
- const routes: CommandRoute[] = [];
182
- const occupancy = new Map<string, string>();
183
- const shortcuts = new Map<string, ShortcutEntry>();
184
-
185
- const claim = (keys: readonly string[], source: string): void => {
186
- for (const key of keys) {
187
- for (const [occupied, existing] of occupancy) {
188
- if (routeShapesOverlap(key, occupied)) {
189
- throw new Error(`Duplicate Command route "${key}" (${source} vs ${existing})`);
190
- }
191
- }
192
- }
193
- for (const key of keys) occupancy.set(key, source);
194
- };
195
-
196
- const disabledByOwner = new Map<PluginId, readonly string[]>();
197
- for (const slot of slots) {
198
- let disabled = disabledByOwner.get(slot.owner);
199
- if (!disabled) {
200
- disabled = disabledCommands(this.snapshot, slot.owner);
201
- disabledByOwner.set(slot.owner, disabled);
202
- }
203
- if (isDisabledCommand(slot.localName, disabled)) continue;
204
- const namespace = commandNamespace(this.snapshot, slot.owner);
205
- const primarySegments = [...namespace, ...runtimeSegments(slot.localName)];
206
- const parameter = slot.definition.$parameter;
207
- assertParameterSegment(primarySegments, parameter, slot.source);
208
- const name = displayName(primarySegments, parameter);
209
- const alias = normalizeAliasList(slot.definition.alias);
210
- const permit = slot.definition.permit
211
- ? Object.freeze([...slot.definition.permit])
212
- : undefined;
213
- const shortcutKeys = slot.definition.shortcut
214
- ? Object.freeze(Object.keys(slot.definition.shortcut).map((key) => key.trim()))
215
- : undefined;
216
-
217
- const execution = normalizeExecutionPolicy(slot.definition.execution);
218
- const record: CommandRecord = Object.freeze({
219
- id: slot.id,
220
- owner: slot.owner,
221
- name,
222
- description: slot.definition.description,
223
- ...(execution ? { execution } : {}),
224
- ...(slot.definition.adapter ? { adapter: slot.definition.adapter } : {}),
225
- source: slot.source,
226
- usage: commandUsage(name, slot.definition),
227
- options: Object.freeze({ ...slot.definition.options }),
228
- parameters: Object.freeze([
229
- ...Object.entries(slot.definition.params ?? {}).map(([key, schema]) => ({
230
- name: key, ...schema, ...(parameter?.name === key ? parameter : {}), defaultValue: schema.default,
231
- required: parameter?.name === key ? isRequiredParameter(parameter) : !schema.optional && schema.default === undefined,
232
- })),
233
- ...(parameter && !Object.hasOwn(slot.definition.params ?? {}, parameter.name)
234
- ? [{ ...parameter, required: isRequiredParameter(parameter) }] : []),
235
- ]),
236
- ...(alias ? { alias } : {}),
237
- ...(permit ? { permit } : {}),
238
- ...(shortcutKeys && shortcutKeys.length > 0 ? { shortcut: shortcutKeys } : {}),
239
- slot,
240
- segments: Object.freeze(primarySegments),
241
- preferredSegments: Object.freeze(Object.fromEntries(Object.entries(slot.definition.preferredAliasI18n ?? {}).map(([locale, entry]) =>
242
- [locale, Object.freeze([...namespace, ...aliasRuntimeSegments(entry, primarySegments.slice(namespace.length))])]))),
243
- parameter,
244
- });
245
-
246
- claim([...new Set([occupancyKey(primarySegments, parameter), ...declaredRouteShapes(primarySegments, slot.definition)])], slot.source);
247
- routes.push({
248
- record,
249
- segments: primarySegments,
250
- matcher: new SegmentMatcher(matcherPattern(primarySegments, parameter), segmentFields),
251
- literalMatcher: compileLiteralMatcher(primarySegments, parameter),
252
- kind: 'primary',
253
- });
254
-
255
- if (alias) {
256
- for (const entry of alias) {
257
- const aliasSegments = [...namespace, ...aliasRuntimeSegments(
258
- entry,
259
- primarySegments.slice(namespace.length),
260
- )];
261
- assertParameterSegment(aliasSegments, parameter, `${slot.source} alias ${JSON.stringify(entry)}`);
262
- claim([...new Set([occupancyKey(aliasSegments, parameter), ...declaredRouteShapes(aliasSegments, slot.definition)])], `${slot.source} alias ${JSON.stringify(entry)}`);
263
- routes.push({
264
- record,
265
- segments: Object.freeze(aliasSegments),
266
- matcher: new SegmentMatcher(matcherPattern(aliasSegments, parameter), segmentFields),
267
- literalMatcher: compileLiteralMatcher(aliasSegments, parameter),
268
- kind: 'alias',
269
- });
270
- }
271
- }
272
-
273
- if (slot.definition.shortcut) {
274
- for (const [rawTrigger, prefill] of Object.entries(slot.definition.shortcut)) {
275
- const trigger = [...namespace, ...splitCommand(rawTrigger)].join(' ');
276
- claim([trigger], `${slot.source} shortcut ${JSON.stringify(trigger)}`);
277
- shortcuts.set(trigger, {
278
- record,
279
- params: Object.freeze(resolveShortcutParams(
280
- slot.definition,
281
- prefill,
282
- `${slot.source} shortcut ${JSON.stringify(trigger)}`,
283
- )),
284
- });
285
- }
286
- }
287
-
288
- commands.push(record);
289
- }
290
-
291
- this.#commands = Object.freeze(commands.sort(compareRecords));
292
- this.#routes = Object.freeze(routes.sort(compareRoutes));
293
- this.#shortcuts = shortcuts;
294
- }
295
-
296
- list(locale: Locale = 'zh-CN'): readonly CommandDescriptor[] {
297
- return this.#commands.map(record => ({ ...toDescriptor(record),
298
- name: commandDisplayName(record, locale), usage: commandUsage(commandDisplayName(record, locale), record.slot.definition),
299
- description: commandDescription(record.slot.definition, locale),
300
- parameters: record.parameters.map(parameter => ({ ...parameter, description: commandDescription(parameter, locale) })),
301
- options: Object.freeze(Object.fromEntries(Object.entries(record.options).map(([key, schema]) =>
302
- [key, { ...schema, description: commandDescription(schema, locale) }]))),
303
- }));
304
- }
305
-
306
- /** Resolve a local static route without executing parameters, permission checks or commands. */
307
- formatCommand(owner: PluginId, localPath: string, prefix: string | undefined, locale: Locale = 'zh-CN'): string | undefined {
308
- if (prefix === undefined || typeof localPath !== 'string') return undefined;
309
- const path = localPath.trim().split(/\s+/u).filter(Boolean).join(' ');
310
- if (!path) return undefined;
311
- const command = this.#commands.find(record => record.owner === owner
312
- && record.slot.localName.split('/').filter(segment => !segment.startsWith('$')).join(' ') === path);
313
- return command ? prefix + commandDisplaySegments(command, locale).filter(segment => !segment.startsWith('$')).join(' ') : undefined;
314
- }
315
-
316
- /** Return the configured menu entry only when the calling environment is known. */
317
- formatMenu(prefix: string | undefined, locale: Locale): string | undefined {
318
- if (prefix === undefined || !this.#menu) return undefined;
319
- return prefix + (this.#menu.keyword ?? commandMessages(locale, 'menuKeyword'));
320
- }
321
-
322
- has(name: string): boolean {
323
- try {
324
- return this.#match(name, true) !== undefined;
325
- } catch (error) {
326
- if (error instanceof CommandParameterValueError) return false;
327
- throw error;
328
- }
329
- }
330
-
331
- async execute(name: string, args: readonly string[] = [], operationSnapshot: RuntimeSnapshot = this.snapshot): Promise<unknown> {
332
- const locale = commandLocale(undefined, operationSnapshot);
333
- const match = this.#match(name, false);
334
- if (!match) {
335
- this.#diagnoseParameter(name);
336
- throw new Error(`Unknown Command: ${name}`);
337
- }
338
- if (!(await this.#permitAllows(match.command, undefined, operationSnapshot))) throw new Error(`Permission denied for Command: ${match.command.name}`);
339
- const definition = match.command.slot.definition;
340
- const supplied = [...match.remaining, ...(args.length > 0 ? [{ type: 'text', data: { text: args.join(' ') } }] : [])];
341
- const parsed = parseCommandArguments(definition, supplied, resolveDynamicParams(match.params, undefined), {}, locale);
342
- if (parsed.issues.length > 0) throw new CommandArgumentValidationError(parsed.issues, commandHelp(commandDisplayName(match.command, locale), definition, '', locale));
343
- // No inbound source: execution policy applies per command, never as a blanket refusal.
344
- const admission = admitDirectCommandExecution(match.command.slot.id, match.command.execution, operationSnapshot);
345
- if (admission.limit) throw new CommandExecutionLimitedError(admission.limit);
346
- try {
347
- return await definition.execute(createCommandContext({ snapshot: operationSnapshot, ownerId: match.command.slot.owner,
348
- adapter: definition.adapter, presentation: { locale } }, {
349
- args: textArgs(parsed.remaining), params: parsed.params, segments: parsed.remaining, options: parsed.options,
350
- }));
351
- } finally {
352
- admission.release();
353
- }
354
- }
355
-
356
- async dispatch({ input, source, interactionFactory, commandPrefix, snapshot: operationSnapshot = this.snapshot,
357
- locale: localeOverride, operation: operationFactory }: CommandDispatchRequest): Promise<CommandDispatchResult> {
358
- const locale = localeOverride ?? commandLocale(source, operationSnapshot);
359
- const prefix = commandPrefix ?? '';
360
- if (this.#menu) {
361
- const keyword = this.#menu.keyword ?? commandMessages(locale, 'menuKeyword');
362
- const menuResult = this.#dispatchMenu(input, { commandPrefix: prefix, snapshot: operationSnapshot, locale, keyword });
363
- if (menuResult !== undefined) {
364
- return Object.freeze({
365
- matched: true,
366
- status: 'executed',
367
- command: keyword,
368
- owner: operationSnapshot.root,
369
- ...menuResult,
370
- });
371
- }
372
- }
373
- const shortcut = this.#matchShortcut(input);
374
- const match = shortcut
375
- ? { command: shortcut.record, params: shortcut.params, remaining: Object.freeze([]), helpRequested: false }
376
- : this.#match(input, false);
377
- if (!match) {
378
- const candidate = this.#invalidRoute(input);
379
- if (!candidate) return Object.freeze({ matched: false, status: 'unmatched' });
380
- if (!commandAdapterMatches(candidate, source)) return Object.freeze({ matched: false, status: 'unmatched' });
381
- if (!(await this.#permitAllows(candidate, source, operationSnapshot))) return this.#denied(candidate);
382
- const help = commandHelp(commandDisplayName(candidate, locale), candidate.slot.definition, prefix, locale);
383
- const segments = normalizeSegments(typeof input === 'string' ? [{ type: 'text', data: { text: input } }] : input);
384
- if (requestsCommandHelp(segments)) return Object.freeze({ matched: true, status: 'executed', command: candidate.name, owner: candidate.slot.owner, value: help });
385
- return Object.freeze({ matched: true, status: 'invalid', command: candidate.name, owner: candidate.slot.owner,
386
- issues: Object.freeze([commandValidationIssue('invalidParameter', candidate.parameter?.name ?? 'arguments', locale)]), help });
387
- }
388
- if (!commandAdapterMatches(match.command, source)) return Object.freeze({ matched: false, status: 'unmatched' });
389
- if (!(await this.#permitAllows(match.command, source, operationSnapshot))) return this.#denied(match.command);
390
- const definition = match.command.slot.definition;
391
- const authorSource = bindOperationInput(source, match.command.slot.owner, operationSnapshot);
392
- const help = commandHelp(commandDisplayName(match.command, locale), definition, prefix, locale);
393
- if (match.helpRequested) {
394
- return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value: help });
395
- }
396
- const parsed = parseCommandArguments(definition, match.remaining, resolveDynamicParams(match.params, authorSource), resolveCommandSession(authorSource), locale);
397
- if (parsed.issues.length > 0) return Object.freeze({ matched: true, status: 'invalid', command: match.command.name,
398
- owner: match.command.slot.owner, issues: parsed.issues, help });
399
- const limit = admitCommandExecution(authorSource, match.command.slot.id, match.command.slot.owner, match.command.execution, operationSnapshot);
400
- if (limit) return Object.freeze({ matched: true, status: 'limited', command: match.command.name, owner: match.command.slot.owner, limit });
401
- const operation = operationFactory?.create({ owner: match.command.slot.owner, signal: operationFactory.signal });
402
- try {
403
- const value = await definition.execute(createCommandContext({
404
- snapshot: operationSnapshot, ownerId: match.command.slot.owner, input: authorSource,
405
- operation,
406
- interaction: interactionFactory?.(authorSource), adapter: definition.adapter,
407
- presentation: { locale, formatCommand: (localPath, owner = match.command.owner) => this.formatCommand(owner, localPath,
408
- source && typeof source === 'object' && 'conversation' in source ? commandPrefix : undefined, locale) },
409
- }, { args: textArgs(parsed.remaining), params: parsed.params, segments: parsed.remaining, options: parsed.options }));
410
- return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value });
411
- } finally {
412
- try { await operation?.resources?.close(); } catch { /* Cleanup never replaces the command's actual outcome. */ }
413
- }
414
- }
415
-
416
- #denied(record: CommandRecord): CommandDispatchResult {
417
- return Object.freeze({ matched: true, status: 'denied', command: record.name, owner: record.slot.owner });
418
- }
419
-
420
- /** Diagnose only a reliable static route prefix; fully dynamic routes are ordinary chat candidates. */
421
- #invalidRoute(input: CommandMatchInput): CommandRecord | undefined {
422
- const segments = normalizeSegments(typeof input === 'string' ? [{ type: 'text', data: { text: input } }] : input);
423
- for (const route of this.#routes) {
424
- if (!route.record.parameter) continue;
425
- const result = route.literalMatcher?.match(asMatcherSegments(segments));
426
- if (result && hasCommandBoundary(result.remaining)) return route.record;
427
- }
428
- return undefined;
429
- }
430
-
431
- async #permitAllows(record: CommandRecord, source: unknown, operationSnapshot: RuntimeSnapshot): Promise<boolean> {
432
- const permits = record.permit;
433
- if (!permits || permits.length === 0) return true;
434
- if (!source || typeof source !== 'object') return false;
435
- const host = this.#resolveHost(operationSnapshot);
436
- if (!host) return false;
437
- const subject = toCommandPermissionSubject(source);
438
- if (!subject.sender?.id && !subject.scene?.id && !subject.adapter && !subject.endpoint) return false;
439
- return host.checkAll(permits, subject);
440
- }
441
-
442
- #resolveHost(operationSnapshot: RuntimeSnapshot): PermissionHost | undefined {
443
- try {
444
- const resources = operationSnapshot.resources.get(operationSnapshot.root);
445
- if (!resources) return undefined;
446
- const host = resources.get(permissionHostToken.id);
447
- return host && typeof (host as PermissionHost).check === 'function'
448
- ? host as PermissionHost
449
- : undefined;
450
- } catch {
451
- return undefined;
452
- }
453
- }
454
-
455
- #matchShortcut(input: CommandMatchInput): ShortcutEntry | undefined {
456
- const text = exactMessageText(input);
457
- if (text === undefined) return undefined;
458
- return this.#shortcuts.get(text);
459
- }
460
-
461
- #match(input: CommandMatchInput, exact: boolean): CommandMatch | undefined {
462
- const segments = normalizeSegments(
463
- typeof input === 'string'
464
- ? input.trim()
465
- ? [{ type: 'text', data: { text: input.trim() } }]
466
- : []
467
- : input,
468
- );
469
- if (segments.length === 0) return undefined;
470
-
471
- for (const route of this.#routes) {
472
- // Options may appear between the literal route and a directory-bound parameter.
473
- const literalResult = !exact ? route.literalMatcher?.match(asMatcherSegments(segments)) : undefined;
474
- const helpRequested = literalResult && hasCommandBoundary(literalResult.remaining)
475
- && requestsCommandHelp(normalizeSegments(literalResult.remaining));
476
- const parseDeclaredArguments = !exact && route.record.parameter && route.literalMatcher !== undefined
477
- && (Object.hasOwn(route.record.slot.definition.params ?? {}, route.record.parameter.name) || helpRequested);
478
- const result = parseDeclaredArguments ? literalResult : route.matcher.match(asMatcherSegments(segments));
479
- if (!result || !hasCommandBoundary(result.remaining)) continue;
480
- const parameter = route.record.parameter;
481
- const params: Record<string, CommandDynamicValue> = { ...result.params };
482
- if (parameter?.rest && !parseDeclaredArguments) {
483
- const raw = result.params[parameter.name];
484
- const coerced = coerceRestValues(parameter, Array.isArray(raw) ? raw : []);
485
- if (!coerced || (isRequiredParameter(parameter) && coerced.length === 0)) continue;
486
- params[parameter.name] = coerced;
487
- }
488
- const remaining = normalizeSegments(result.remaining);
489
- if (exact && remaining.length > 0) continue;
490
- if (parameter && !parseDeclaredArguments && !parameter.rest && !isRequiredParameter(parameter)
491
- && (params[parameter.name] === '' || params[parameter.name] === null || params[parameter.name] === undefined)) {
492
- if (parameter.defaultValue !== undefined) params[parameter.name] = parameter.defaultValue;
493
- else delete params[parameter.name];
494
- }
495
- return {
496
- command: route.record,
497
- params: Object.freeze(params),
498
- remaining,
499
- helpRequested: literalResult ? !!helpRequested : requestsCommandHelp(remaining),
500
- };
501
- }
502
- return undefined;
503
- }
504
-
505
- #diagnoseParameter(name: string): void {
506
- const words = splitCommand(name);
507
- for (const route of this.#routes) {
508
- const parameter = route.record.parameter;
509
- if (!parameter || parameter.rest) continue;
510
- const parameterIndex = route.segments.findIndex((segment) => segment.startsWith('$'));
511
- if (words.length !== route.segments.length) continue;
512
- if (!route.segments.every((segment, index) =>
513
- index === parameterIndex || segment === words[index])) continue;
514
- const value = words[parameterIndex];
515
- if (value === undefined || matchesParameter(parameter.type, value)) continue;
516
- throw new CommandParameterValueError(parameter.name, parameter.type, value);
517
- }
518
- }
519
-
520
- // ==========================================================================
521
- // 内置菜单命令
522
- // ==========================================================================
523
-
524
- #dispatchMenu(input: CommandMatchInput, context: CommandMenuContext): CommandMenuResult | undefined {
525
- const { commandPrefix, keyword, locale } = context;
526
- const text = typeof input === 'string' ? input.trim() : exactMessageText(input);
527
- if (text === undefined) return undefined;
528
- const spaceIndex = text.search(/\s/u);
529
- const firstToken = spaceIndex === -1 ? text : text.slice(0, spaceIndex);
530
- const normalizedFirst = !commandPrefix && firstToken.startsWith('/')
531
- ? firstToken.slice(1)
532
- : firstToken;
533
- const matchedDefaultAlias = this.#menu?.keyword === undefined
534
- && DEFAULT_MENU_KEYWORDS.has(normalizedFirst.toLowerCase());
535
- if (text === keyword || (matchedDefaultAlias && spaceIndex === -1)) return this.#buildMenu({}, context);
536
- if (text.startsWith(keyword + ' ') || (matchedDefaultAlias && spaceIndex !== -1)) {
537
- const key = (text.startsWith(keyword + ' ')
538
- ? text.slice(keyword.length + 1)
539
- : text.slice(spaceIndex + 1)).trim();
540
- const args = key.split(/\s+/u);
541
- const flag = args.indexOf('--page');
542
- if (flag !== -1) {
543
- const pageText = args[flag + 1] ?? '';
544
- if (flag > 1 || args.length !== flag + 2 || !/^[1-9]\d*$/u.test(pageText)
545
- || !Number.isSafeInteger(Number(pageText))) {
546
- return { value: commandMessages(locale, 'invalidPage', { command: `${commandPrefix}${keyword}` }) };
547
- }
548
- return this.#buildMenu({ pluginKey: flag === 1 ? args[0] : undefined, page: Number(pageText) }, context);
549
- }
550
- if (key) return this.#buildMenu({ pluginKey: key }, context);
551
- return this.#buildMenu({}, context);
552
- }
553
- return undefined;
554
- }
555
-
556
- #buildMenu({ pluginKey, page = 1 }: CommandMenuSelection, { commandPrefix, keyword, snapshot, locale }: CommandMenuContext): CommandMenuResult {
557
- const root = snapshot.root;
558
- const targetId = pluginKey
559
- ? `${root}/${pluginKey.split('.').join('/')}` as PluginId
560
- : root;
561
-
562
- const node = snapshot.tree.get(targetId);
563
- if (!node) return { value: commandMessages(locale, 'missingPlugin', { plugin: pluginKey ?? '' }) };
564
-
565
- const directCommands = this.#commands.filter((cmd) => cmd.slot.owner === targetId);
566
- const children = node.children
567
- .map((childId) => snapshot.tree.get(childId))
568
- .filter((child): child is NonNullable<typeof child> => !!child);
569
- const effectiveCommands = targetId === root
570
- && directCommands.length === 0
571
- && this.#commands.length > 0
572
- && this.#commands.length <= MENU_PAGE_SIZE
573
- ? this.#commands
574
- : directCommands;
575
-
576
- const pages = Math.max(1, Math.ceil((effectiveCommands.length + children.length) / MENU_PAGE_SIZE));
577
- const invocation = `${commandPrefix}${keyword}${pluginKey ? ` ${pluginKey}` : ''}`;
578
- if (page > pages) return { value: commandMessages(locale, 'pageOutOfRange', { pages, command: invocation }) };
579
- const offset = (page - 1) * MENU_PAGE_SIZE;
580
- const pageCommands = effectiveCommands.slice(offset, offset + MENU_PAGE_SIZE);
581
- const pageChildren = children.slice(Math.max(0, offset - effectiveCommands.length), Math.max(0, offset + MENU_PAGE_SIZE - effectiveCommands.length));
582
-
583
- const displayKey = pluginKey ?? node.instanceKey;
584
- const lines: string[] = [commandMessages(locale, 'menuTitle', { plugin: displayKey })];
585
- if (pages > 1) lines.push(commandMessages(locale, 'menuSummary', { page, pages, commands: effectiveCommands.length, plugins: children.length }));
586
-
587
- if (pageCommands.length > 0) {
588
- lines.push('');
589
- for (const cmd of pageCommands) {
590
- const description = commandDescription(cmd.slot.definition, locale);
591
- const desc = description ? ` - ${description}` : '';
592
- lines.push(` ${commandPrefix}${commandDisplayName(cmd, locale)}${desc}`);
593
- }
594
- }
595
-
596
- if (pageChildren.length > 0) {
597
- lines.push('');
598
- lines.push(commandMessages(locale, 'childPlugins'));
599
- for (const child of pageChildren) {
600
- const childKey = pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey;
601
- const label = child.metadata?.displayName ?? child.instanceKey;
602
- lines.push(` ${childKey} (${label})`);
603
- }
604
- }
605
-
606
- if (effectiveCommands.length === 0 && children.length === 0) {
607
- lines.push('');
608
- lines.push(commandMessages(locale, 'emptyMenu'));
609
- }
610
-
611
- const hints: string[] = [];
612
- if (children.length > 0) hints.push(commandMessages(locale, 'pluginHint', { command: `${commandPrefix}${keyword}` }));
613
- if (page > 1) hints.push(commandMessages(locale, 'previousPage', { command: `${invocation} --page ${page - 1}` }));
614
- if (page < pages) hints.push(commandMessages(locale, 'nextPage', { command: `${invocation} --page ${page + 1}` }));
615
- const hint = hints.length ? hints.join('\n') : undefined;
616
- if (hint) {
617
- lines.push('');
618
- lines.push(hint);
619
- }
620
-
621
- const text = lines.join('\n');
622
- const config = snapshot.config.get(root) as Record<string, unknown> | undefined;
623
- const menu: CommandMenuPage = Object.freeze({
624
- keyword, ...(pluginKey ? { pluginKey } : {}), page, pages, locale, title: displayKey,
625
- commands: Object.freeze(pageCommands.map(cmd => Object.freeze({
626
- name: `${commandPrefix}${commandDisplayName(cmd, locale)}`, description: commandDescription(cmd.slot.definition, locale),
627
- }))),
628
- plugins: Object.freeze(pageChildren.map(child => Object.freeze({
629
- name: pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey,
630
- description: child.metadata?.displayName ?? child.instanceKey,
631
- }))),
632
- commandTotal: effectiveCommands.length, pluginTotal: children.length, hint, text,
633
- appearance: Object.freeze(readMenuAppearance(config?.menuStyle)),
634
- });
635
- if (!snapshot.resources.get(root)?.has(htmlRendererToken.id)) return { value: text, menu };
636
- return { menu, value: renderCommandMenu(displayKey, menu.commands, menu.plugins, hint, text, {
637
- page, pages, locale, commandTotal: menu.commandTotal, pluginTotal: menu.pluginTotal, appearance: menu.appearance,
638
- }) };
639
- }
640
- }
641
-
642
- function commandDisplaySegments(record: CommandRecord, locale: Locale): readonly string[] {
643
- return record.preferredSegments[locale] ?? record.segments;
644
- }
645
-
646
- function commandDisplayName(record: CommandRecord, locale: Locale): string {
647
- return displayName(commandDisplaySegments(record, locale), record.parameter);
648
- }
649
-
650
- function commandLocale(source: unknown, snapshot: RuntimeSnapshot): Locale {
651
- const locale = source && typeof source === 'object' && 'locale' in source ? source.locale : undefined;
652
- return resolveLocale(locale ?? (snapshot.config.get(snapshot.root) as { locale?: unknown } | undefined)?.locale);
653
- }
654
-
655
- export function isCommandIndex(value: unknown): value is CommandIndex {
656
- return !!value && typeof value === 'object'
657
- && (value as { readonly $projection?: unknown }).$projection === 'zhin.command-index/1';
658
- }
659
-
660
- /**
661
- * 用户路由来自命令的本地能力路径;只有插件配置显式声明 commandNamespace
662
- * 时才在其前面增加命名空间。owner 始终只属于 CapabilityId。
663
- */
664
- function runtimeSegments(localName: string): string[] {
665
- return localName.split('/');
666
- }
667
-
668
- function commandNamespace(snapshot: RuntimeSnapshot, owner: PluginId): readonly string[] {
669
- const config = snapshot.config.get(owner) as Readonly<Record<string, unknown>> | undefined;
670
- const value = config?.commandNamespace;
671
- if (value === undefined || value === '') return Object.freeze([]);
672
- if (typeof value !== 'string') {
673
- throw new TypeError(`Invalid commandNamespace for ${owner}: expected a string`);
674
- }
675
- const segments = splitCommand(value);
676
- if (segments.length === 0 || segments.some((segment) => !isCommandNamespaceSegment(segment))) {
677
- throw new TypeError(
678
- `Invalid commandNamespace for ${owner}: expected space-separated command segments`,
679
- );
680
- }
681
- return Object.freeze([...segments]);
682
- }
683
-
684
- /**
685
- * 插件实例配置 disabledCommands 列出的指令不进入路由表。条目写插件本地的静态路径
686
- * (不含 commandNamespace 与参数段,如 `fanyi`、`netease login`);写上级路径会连同
687
- * 其子命令一起禁用。未匹配任何指令的条目被忽略。
688
- */
689
- function disabledCommands(snapshot: RuntimeSnapshot, owner: PluginId): readonly string[] {
690
- const config = snapshot.config.get(owner) as Readonly<Record<string, unknown>> | undefined;
691
- const value = config?.disabledCommands;
692
- if (value === undefined) return Object.freeze([]);
693
- if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
694
- throw new TypeError(`Invalid disabledCommands for ${owner}: expected an array of command names`);
695
- }
696
- return Object.freeze(value.map((entry: string) => splitCommand(entry).join(' ')).filter(Boolean));
697
- }
698
-
699
- function isDisabledCommand(localName: string, disabled: readonly string[]): boolean {
700
- if (disabled.length === 0) return false;
701
- const path = runtimeSegments(localName).filter((segment) => !segment.startsWith('$')).join(' ');
702
- return disabled.some((entry) => path === entry || path.startsWith(`${entry} `));
703
- }
704
-
705
- function isCommandNamespaceSegment(value: string): boolean {
706
- return /^[\p{L}\p{N}][\p{L}\p{N}_-]*$/u.test(value);
707
- }
708
-
709
- /**
710
- * 用 alias 词序列替换全部本地静态段;动态段保留。
711
- */
712
- function aliasRuntimeSegments(
713
- alias: string,
714
- primarySegments: readonly string[],
715
- ): string[] {
716
- const aliasTokens = alias.trim().split(/\s+/u).filter(Boolean);
717
- const dynamicTail = primarySegments.filter((segment) => segment.startsWith('$'));
718
- return [...aliasTokens, ...dynamicTail];
719
- }
720
-
721
- function occupancyKey(
722
- segments: readonly string[],
723
- parameter: CommandParameterDefinition | undefined,
724
- ): string {
725
- return parameter ? routeShape(segments) : segments.join(' ');
726
- }
727
-
728
- /** Accepted token arities, including optional tails and an unbounded rest tail. */
729
- function declaredRouteShapes(segments: readonly string[], definition: CommandDefinition): readonly string[] {
730
- let variants: string[][] = [[]];
731
- const append = (token: string, optional = false): void => {
732
- const withToken = variants.map((variant) => [...variant, token]);
733
- variants = [...new Map((optional ? [...variants, ...withToken] : withToken)
734
- .map((variant) => [variant.join(' '), variant])).values()];
735
- };
736
- for (const segment of segments) {
737
- if (!segment.startsWith('$')) { append(segment); continue; }
738
- const parameter = definition.$parameter;
739
- if (!parameter) continue;
740
- if (parameter.rest) append(isRequiredParameter(parameter) ? '$+' : '$*');
741
- else append('$', !isRequiredParameter(parameter));
742
- }
743
- for (const [name, schema] of Object.entries(definition.params ?? {})) {
744
- if (name === definition.$parameter?.name) continue;
745
- const optional = schema.optional === true || schema.default !== undefined;
746
- if (schema.rest) append(optional ? '$*' : '$+');
747
- else append('$', optional);
748
- }
749
- return [...new Set(variants.map((variant) => variant.join(' ')))];
750
- }
751
-
752
- function routeShapesOverlap(left: string, right: string): boolean {
753
- const shape = (key: string) => {
754
- const tokens = key.split(' ');
755
- const repeat = tokens.at(-1);
756
- const rest = repeat === '$*' || repeat === '$+';
757
- const prefix = rest ? tokens.slice(0, -1) : tokens;
758
- return { prefix, min: prefix.length + (repeat === '$+' ? 1 : 0), max: rest ? Infinity : prefix.length };
759
- };
760
- const a = shape(left);
761
- const b = shape(right);
762
- const length = Math.max(a.min, b.min);
763
- if (length > Math.min(a.max, b.max)) return false;
764
- for (let index = 0; index < length; index++) {
765
- if ((a.prefix[index] ?? '$') !== (b.prefix[index] ?? '$')) return false;
766
- }
767
- return true;
768
- }
769
-
770
- function normalizeAliasList(
771
- alias: readonly string[] | undefined,
772
- ): readonly string[] | undefined {
773
- if (!alias || alias.length === 0) return undefined;
774
- return Object.freeze(alias.map((entry) => entry.trim().split(/\s+/u).filter(Boolean).join(' ')));
775
- }
776
-
777
- function resolveShortcutParams(
778
- definition: CommandDefinition,
779
- prefill: Readonly<Record<string, CommandDynamicValue>>,
780
- source: string,
781
- ): Record<string, CommandDynamicValue> {
782
- for (const [name, schema] of Object.entries(definition.options ?? {})) {
783
- if (schema.optional === false && schema.default === undefined) {
784
- throw new TypeError(`Invalid shortcut for ${source}: cannot supply required option --${name}`);
785
- }
786
- }
787
- const allowed = new Set<string>();
788
- const parameter = definition.$parameter;
789
- if (parameter) allowed.add(parameter.name);
790
- if (definition.params) {
791
- for (const key of Object.keys(definition.params)) allowed.add(key);
792
- }
793
-
794
- for (const key of Object.keys(prefill)) {
795
- if (!allowed.has(key)) {
796
- throw new TypeError(
797
- `Invalid shortcut params for ${source}: unknown key ${JSON.stringify(key)}`,
798
- );
799
- }
800
- }
801
-
802
- const result: Record<string, CommandDynamicValue> = { ...prefill };
803
-
804
- if (parameter) {
805
- if (result[parameter.name] === undefined) {
806
- if (parameter.defaultValue !== undefined) {
807
- result[parameter.name] = parameter.defaultValue;
808
- } else if (isRequiredParameter(parameter)) {
809
- throw new TypeError(
810
- `Invalid shortcut params for ${source}: missing required ${parameter.name}`,
811
- );
812
- }
813
- }
814
- } else if (allowed.size === 0 && Object.keys(prefill).length > 0) {
815
- throw new TypeError(
816
- `Invalid shortcut params for ${source}: command has no params declaration`,
817
- );
818
- }
819
-
820
- if (definition.params) {
821
- for (const [name, schema] of Object.entries(definition.params)) {
822
- if (result[name] === undefined && schema.default !== undefined) {
823
- result[name] = schema.default;
824
- }
825
- }
826
- }
827
-
828
- return result;
829
- }
830
-
831
- function exactMessageText(input: CommandMatchInput): string | undefined {
832
- if (typeof input === 'string') {
833
- const trimmed = input.trim();
834
- return trimmed || undefined;
835
- }
836
- // 仅纯单 text 段可作整句 shortcut;含 mention/image 等则不走 shortcut。
837
- if (input.length !== 1) return undefined;
838
- const only = input[0];
839
- if (!only || only.type !== 'text' || typeof only.data.text !== 'string') return undefined;
840
- const trimmed = only.data.text.trim();
841
- return trimmed || undefined;
842
- }
843
-
844
- /**
845
- * Route-shape rule for a convention command: at most one dynamic segment, and it is the final one.
846
- * Shared by the runtime index and {@link bindCommandSource}.
847
- */
848
- export function assertParameterSegment(
849
- segments: readonly string[],
850
- parameter: CommandParameterDefinition | undefined,
851
- source: string,
852
- ): void {
853
- const dynamicSegments = segments.filter((segment) => segment.startsWith('$'));
854
- if (!parameter && dynamicSegments.length === 0) return;
855
- if (parameter && dynamicSegments.length === 1
856
- && dynamicSegments[0] === `$${parameter.name}`
857
- && segments.at(-1) === dynamicSegments[0]) return;
858
- const dynamic = dynamicSegments[0] ?? (parameter ? `$${parameter.name}` : '$?');
859
- throw new Error(
860
- `Invalid Command path for ${source}: the dynamic segment "${dynamic}" must be the only dynamic `
861
- + `segment and be the final path segment. `
862
- + (parameter
863
- ? `Hint: keep only one dynamic entry, e.g. "commands/add/[${parameter.name}]/index.ts".`
864
- : 'Hint: keep only one dynamic entry at the end of the command path.'),
865
- );
866
- }
867
-
868
- function isRequiredParameter(parameter: CommandParameterDefinition): boolean {
869
- return parameter.optional === true ? false : parameter.defaultValue === undefined;
870
- }
871
-
872
- function displayName(
873
- segments: readonly string[],
874
- parameter: CommandParameterDefinition | undefined,
875
- ): string {
876
- return segments.map((segment) => {
877
- if (!segment.startsWith('$')) return segment;
878
- const label = segment.slice(1);
879
- const required = !parameter || isRequiredParameter(parameter);
880
- if (parameter?.rest) return required ? `<...${label}>` : `[...${label}]`;
881
- return required ? `<${label}>` : `[${label}]`;
882
- }).join(' ');
883
- }
884
-
885
- function compileLiteralMatcher(segments: readonly string[], parameter: CommandParameterDefinition | undefined): SegmentMatcher | undefined {
886
- if (!parameter) return undefined;
887
- const literals = segments.filter((segment) => !segment.startsWith('$'));
888
- return literals.length > 0 ? new SegmentMatcher(literals.join(' '), segmentFields) : undefined;
889
- }
890
-
891
- function matcherPattern(
892
- segments: readonly string[],
893
- parameter: CommandParameterDefinition | undefined,
894
- ): string {
895
- return segments.map((segment) => {
896
- if (!segment.startsWith('$')) return segment;
897
- if (!parameter) throw new Error(`Missing Command parameter metadata: ${segment}`);
898
- const type = matcherType(parameter.type);
899
- if (parameter.rest) {
900
- return `[...${parameter.name}:${isStructuredRestType(parameter.type) ? type : 'text'}]`;
901
- }
902
- if (isRequiredParameter(parameter)) return `<${parameter.name}:${type}>`;
903
- return `[${parameter.name}:${type}]`;
904
- }).join(' ');
905
- }
906
-
907
- function matcherType(type: CommandParameterType): string {
908
- return type === 'string' ? 'word' : type;
909
- }
910
-
911
- function isStructuredRestType(type: CommandParameterType): boolean {
912
- return type === 'mention'
913
- || type === 'image'
914
- || type === 'face'
915
- || type === 'reply'
916
- || type === 'forward'
917
- || type === 'dice'
918
- || type === 'rps';
919
- }
920
-
921
- function coerceRestValues(
922
- parameter: CommandParameterDefinition,
923
- values: readonly unknown[],
924
- ): readonly (string | number | boolean)[] | undefined {
925
- const type = parameter.type;
926
- if (type === 'text' || isStructuredRestType(type)) {
927
- return values as readonly (string | number | boolean)[];
928
- }
929
- const words = values.flatMap((value) =>
930
- typeof value === 'string' ? value.split(/\s+/u).filter(Boolean) : []);
931
- if (type === 'string' || type === 'word') return words;
932
- if (type === 'number' || type === 'integer' || type === 'float') {
933
- const numbers: number[] = [];
934
- for (const [index, word] of words.entries()) {
935
- const number = Number(word);
936
- if (!Number.isFinite(number)
937
- || (type === 'integer' && !Number.isInteger(number))
938
- || (type === 'float' && !word.includes('.'))) return undefined;
939
- numbers[index] = number;
940
- }
941
- return numbers;
942
- }
943
- if (type === 'boolean') {
944
- if (!words.every((word) => word === 'true' || word === 'false')) return undefined;
945
- return words.map((word) => word === 'true');
946
- }
947
- return undefined;
948
- }
949
-
950
- function routeShape(segments: readonly string[]): string {
951
- return segments.map((segment) => segment.startsWith('$') ? '$' : segment).join(' ');
952
- }
953
-
954
- function compareRecords(left: CommandRecord, right: CommandRecord): number {
955
- return left.name.localeCompare(right.name);
956
- }
957
-
958
- function compareRoutes(left: CommandRoute, right: CommandRoute): number {
959
- return dynamicWeight(left) - dynamicWeight(right)
960
- || staticSegmentCount(right.segments) - staticSegmentCount(left.segments)
961
- || right.segments.length - left.segments.length
962
- || right.record.name.length - left.record.name.length
963
- || left.record.name.localeCompare(right.record.name);
964
- }
965
-
966
- function dynamicWeight(route: CommandRoute): number {
967
- if (!route.record.parameter) return 0;
968
- return route.record.parameter.rest ? 2 : 1;
969
- }
970
-
971
- function staticSegmentCount(segments: readonly string[]): number {
972
- return segments.filter((segment) => !segment.startsWith('$')).length;
973
- }
974
-
975
- function splitCommand(value: string): readonly string[] {
976
- const normalized = value.trim();
977
- return normalized ? normalized.split(/\s+/) : [];
978
- }
979
-
980
- function matchesParameter(type: CommandParameterType, value: string): boolean {
981
- const matcher = TypeMatcherRegistry.getMatcher(matcherType(type));
982
- return matcher ? matcher.match(value).success : false;
983
- }
984
-
985
- function asMatcherSegments(
986
- segments: readonly Readonly<CommandSegment>[],
987
- ): MessageSegment[] {
988
- return segments.map((segment) => ({
989
- type: typeof segment.type === 'string' ? segment.type : { name: segment.type.name },
990
- data: { ...segment.data },
991
- }));
992
- }
993
-
994
- function hasCommandBoundary(segments: readonly MessageSegment[]): boolean {
995
- const first = segments[0];
996
- if (!first || first.type !== 'text') return true;
997
- const text = first.data.text;
998
- return typeof text !== 'string' || text.length === 0 || /^\s/u.test(text);
999
- }
1000
-
1001
- function normalizeSegments(
1002
- input: readonly Readonly<CommandSegment>[],
1003
- ): readonly Readonly<CommandSegment>[] {
1004
- const segments = input.map((segment) => ({
1005
- type: typeof segment.type === 'string' ? segment.type : { name: segment.type.name },
1006
- data: { ...segment.data },
1007
- }));
1008
- trimBoundary(segments, 'start');
1009
- trimBoundary(segments, 'end');
1010
- return Object.freeze(segments.map((segment) => Object.freeze({
1011
- type: typeof segment.type === 'string'
1012
- ? segment.type
1013
- : Object.freeze({ name: segment.type.name }),
1014
- data: Object.freeze(segment.data),
1015
- })));
1016
- }
1017
-
1018
- function trimBoundary(
1019
- segments: Array<{ type: string | { name: string }; data: Record<string, unknown> }>,
1020
- side: 'start' | 'end',
1021
- ): void {
1022
- while (segments.length > 0) {
1023
- const index = side === 'start' ? 0 : segments.length - 1;
1024
- const segment = segments[index];
1025
- if (!segment || segment.type !== 'text' || typeof segment.data.text !== 'string') return;
1026
- const text = side === 'start' ? segment.data.text.trimStart() : segment.data.text.trimEnd();
1027
- if (text) {
1028
- segment.data.text = text;
1029
- return;
1030
- }
1031
- segments.splice(index, 1);
1032
- }
1033
- }
1034
-
1035
- function textArgs(segments: readonly Readonly<CommandSegment>[]): readonly string[] {
1036
- return Object.freeze(segments.flatMap((segment) => {
1037
- if (segment.type !== 'text' || typeof segment.data.text !== 'string') return [];
1038
- return splitCommand(segment.data.text);
1039
- }));
1040
- }
1041
-
1042
- function toDescriptor({
1043
- slot: _slot,
1044
- segments: _segments,
1045
- parameter: _parameter,
1046
- ...descriptor
1047
- }: CommandRecord): CommandDescriptor {
1048
- return descriptor;
1049
- }
1050
-
1051
- export class CommandParameterValueError extends TypeError {
1052
- constructor(name: string, type: CommandParameterType, value: string) {
1053
- super(`Invalid value for Command parameter ${name}:${type}: ${value}`);
1054
- this.name = 'CommandParameterValueError';
1055
- }
1056
- }
1057
-
1058
- export class CommandArgumentValidationError extends TypeError {
1059
- constructor(readonly issues: readonly CommandValidationIssue[], readonly help: string) {
1060
- super(issues.map((issue) => issue.message).join('\n') + '\n' + help);
1061
- this.name = 'CommandArgumentValidationError';
1062
- }
1063
- }