@zhin.js/command 1.1.10 → 1.1.12

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/src/arguments.ts CHANGED
@@ -2,10 +2,30 @@ import type {
2
2
  CommandDefinition, CommandParams, CommandOptions, CommandParamSchema,
3
3
  CommandParameterValue, CommandSegment, CommandSession,
4
4
  } from './definition.js';
5
+ import type { Locale } from '@zhin.js/plugin-runtime';
6
+ import { commandDescription, commandMessages } from './messages.js';
7
+
8
+ export type CommandValidationIssueCode = 'expectedType' | 'expectedArray' | 'minimum' | 'maximum'
9
+ | 'choices' | 'invalidFormat' | 'invalidValue' | 'unknownOption' | 'duplicateOption'
10
+ | 'defaultUndefined' | 'parameterUndefined' | 'missingOption' | 'unexpectedArguments'
11
+ | 'invalidParameter' | 'custom';
5
12
 
6
13
  export interface CommandValidationIssue {
7
14
  readonly field: string;
8
15
  readonly message: string;
16
+ readonly code?: CommandValidationIssueCode;
17
+ readonly parameters?: Readonly<Record<string, string | number>>;
18
+ }
19
+
20
+ /** Stable validation reason and values; never reverse-parse an English message. */
21
+ export function commandValidationIssue(
22
+ code: Exclude<CommandValidationIssueCode, 'custom'>,
23
+ field: string,
24
+ locale: Locale,
25
+ parameters: Readonly<Record<string, string | number>> = {},
26
+ ): CommandValidationIssue {
27
+ const values = Object.freeze({ field, ...parameters });
28
+ return Object.freeze({ field, code, parameters: values, message: commandMessages(locale, code, values) });
9
29
  }
10
30
  export interface ParsedCommandArguments {
11
31
  readonly params: Readonly<Record<string, CommandParameterValue>>;
@@ -63,6 +83,11 @@ function validateSchema(name: string, schema: CommandParamSchema): void {
63
83
  if (schema.choices !== undefined && !Array.isArray(schema.choices)) {
64
84
  throw new TypeError(`Command argument ${name}.choices must be an array`);
65
85
  }
86
+ if (schema.descriptionI18n !== undefined && (!schema.descriptionI18n || typeof schema.descriptionI18n !== 'object'
87
+ || Array.isArray(schema.descriptionI18n)
88
+ || Object.entries(schema.descriptionI18n).some(([locale, text]) => !['zh-CN', 'en'].includes(locale) || typeof text !== 'string'))) {
89
+ throw new TypeError(`Command argument ${name}.descriptionI18n must map zh-CN/en to strings`);
90
+ }
66
91
  if (schema.default !== undefined && !scalarTypes.has(schema.type)) throw new TypeError(`Structured Command argument ${name} cannot declare a default`);
67
92
  if (schema.default !== undefined && typeof schema.default !== 'function') {
68
93
  if (Array.isArray(schema.default) && !schema.rest) throw new TypeError(`Command argument ${name} requires rest: true for an array default`);
@@ -161,22 +186,23 @@ function valueMatches(schema: CommandParamSchema, value: unknown): boolean {
161
186
  if (schema.type === 'image') return typeof value === 'string' || !!value && typeof value === 'object';
162
187
  return typeof value === 'string';
163
188
  }
164
- function validateValue(field: string, schema: CommandParamSchema, value: CommandParameterValue, session: CommandSession): CommandValidationIssue | undefined {
165
- if (schema.rest && !Array.isArray(value)) return { field, message: `${field}: expected an array of ${schema.type}` };
189
+ function validateValue(field: string, schema: CommandParamSchema, value: CommandParameterValue, session: CommandSession, locale: Locale = 'zh-CN'): CommandValidationIssue | undefined {
190
+ if (schema.rest && !Array.isArray(value)) return commandValidationIssue('expectedArray', field, locale, { type: schema.type });
166
191
  const values = schema.rest ? (Array.isArray(value) ? value : [value]) : [value];
167
192
  for (const item of values) {
168
- if (!valueMatches(schema, item)) return { field, message: `${field}: expected ${schema.type}` };
193
+ if (!valueMatches(schema, item)) return commandValidationIssue('expectedType', field, locale, { type: schema.type });
169
194
  const size = typeof item === 'number' ? item : typeof item === 'string' ? item.length : undefined;
170
- if (schema.min !== undefined && (size === undefined || size < schema.min)) return { field, message: `${field}: minimum ${schema.min}` };
171
- if (schema.max !== undefined && (size === undefined || size > schema.max)) return { field, message: `${field}: maximum ${schema.max}` };
172
- if (schema.choices && !schema.choices.includes(item)) return { field, message: `${field}: expected one of ${schema.choices.join(', ')}` };
195
+ if (schema.min !== undefined && (size === undefined || size < schema.min)) return commandValidationIssue('minimum', field, locale, { minimum: schema.min });
196
+ if (schema.max !== undefined && (size === undefined || size > schema.max)) return commandValidationIssue('maximum', field, locale, { maximum: schema.max });
197
+ if (schema.choices && !schema.choices.includes(item)) return commandValidationIssue('choices', field, locale, { choices: schema.choices.join(', ') });
173
198
  if (schema.pattern) {
174
199
  const pattern = new RegExp(schema.pattern.source, schema.pattern.flags.replace(/[gy]/gu, ''));
175
- if (typeof item !== 'string' || !pattern.test(item)) return { field, message: `${field}: invalid format` };
200
+ if (typeof item !== 'string' || !pattern.test(item)) return commandValidationIssue('invalidFormat', field, locale);
176
201
  }
177
202
  }
178
203
  const result = schema.validate?.(value, session);
179
- return result === false || typeof result === 'string' ? { field, message: typeof result === 'string' ? result : `${field}: invalid value` } : undefined;
204
+ return typeof result === 'string' ? Object.freeze({ field, code: 'custom', message: result })
205
+ : result === false ? commandValidationIssue('invalidValue', field, locale) : undefined;
180
206
  }
181
207
 
182
208
  /** Parse arguments after a route match; the CommandIndex remains the only dispatcher. */
@@ -185,6 +211,7 @@ export function parseCommandArguments(
185
211
  remaining: readonly Readonly<CommandSegment>[],
186
212
  initial: Readonly<Record<string, CommandParameterValue>>,
187
213
  session: CommandSession,
214
+ locale: Locale = 'zh-CN',
188
215
  ): ParsedCommandArguments {
189
216
  const params: Record<string, CommandParameterValue> = { __proto__: null, ...initial };
190
217
  const options: Record<string, CommandParameterValue> = { __proto__: null };
@@ -203,10 +230,10 @@ export function parseCommandArguments(
203
230
  const [rawFlag, inline] = token.value.split(/=(.*)/u);
204
231
  const flag = rawFlag ?? token.value;
205
232
  const name = optionNames.get(flag);
206
- if (!name) { issues.push({ field: flag, message: `Unknown option ${flag}` }); continue; }
233
+ if (!name) { issues.push(commandValidationIssue('unknownOption', flag, locale)); continue; }
207
234
  const schema = schemas[name];
208
235
  if (!schema) continue;
209
- if (Object.hasOwn(options, name)) { issues.push({ field: name, message: `Duplicate option --${name}` }); continue; }
236
+ if (Object.hasOwn(options, name)) { issues.push(commandValidationIssue('duplicateOption', name, locale)); continue; }
210
237
  let value: CommandParameterValue | undefined;
211
238
  if (inline !== undefined) value = convert(schema, { value: inline, quoted: false });
212
239
  else if (schema.type === 'boolean') value = true;
@@ -214,14 +241,14 @@ export function parseCommandArguments(
214
241
  const next = tokens[index + 1];
215
242
  if (isTextToken(next) && !(!next.quoted && /^--?[a-zA-Z]/u.test(next.value))) { index++; value = convert(schema, next); }
216
243
  }
217
- if (value === undefined) issues.push({ field: name, message: `--${name}: expected ${schema.type}` });
244
+ if (value === undefined) issues.push(commandValidationIssue('expectedType', name, locale, { field: `--${name}`, type: schema.type }));
218
245
  else options[name] = value;
219
246
  }
220
247
  for (const [name, schema] of Object.entries(schemas ?? {})) {
221
248
  if (!Object.hasOwn(options, name) && schema.default !== undefined) options[name] = typeof schema.default === 'function' ? schema.default(session) : schema.default;
222
- if (Object.hasOwn(options, name) && options[name] === undefined) issues.push({ field: name, message: `--${name}: default returned undefined` });
223
- else if (options[name] !== undefined) { const issue = validateValue(`--${name}`, schema, options[name], session); if (issue) issues.push(issue); }
224
- else if (schema.optional === false) issues.push({ field: name, message: `Missing option --${name}` });
249
+ if (Object.hasOwn(options, name) && options[name] === undefined) issues.push(commandValidationIssue('defaultUndefined', name, locale, { field: `--${name}` }));
250
+ else if (options[name] !== undefined) { const issue = validateValue(`--${name}`, schema, options[name], session, locale); if (issue) issues.push(issue); }
251
+ else if (schema.optional === false) issues.push(commandValidationIssue('missingOption', name, locale));
225
252
  }
226
253
  const declared = Object.entries(definition.params ?? {});
227
254
  for (const [name, declaredSchema] of declared) {
@@ -233,7 +260,7 @@ export function parseCommandArguments(
233
260
  const converted = schema.type === 'text'
234
261
  ? segmentsFromTokens(raw).map((segment) => segment.type === 'text' && typeof segment.data.text === 'string' ? segment.data.text : undefined)
235
262
  : raw.map((token) => convert(schema, token));
236
- if (converted.some((item) => item === undefined)) issues.push({ field: name, message: `${name}: expected ${schema.type}` });
263
+ if (converted.some((item) => item === undefined)) issues.push(commandValidationIssue('expectedType', name, locale, { type: schema.type }));
237
264
  else if (converted.length > 0 || schema.optional) value = converted as readonly (string | number | boolean | Readonly<Record<string, unknown>>)[];
238
265
  } else if (schema.type === 'text') {
239
266
  const raw: string[] = [];
@@ -246,20 +273,20 @@ export function parseCommandArguments(
246
273
  value = convert(schema, positional.shift());
247
274
  // Defaults apply to omitted input, never to a supplied value of the wrong type.
248
275
  if (value === undefined) {
249
- issues.push({ field: name, message: `${name}: expected ${schema.type}` });
276
+ issues.push(commandValidationIssue('expectedType', name, locale, { type: schema.type }));
250
277
  continue;
251
278
  }
252
279
  }
253
280
  if (value !== undefined) params[name] = value;
254
281
  else if (schema.default !== undefined) params[name] = typeof schema.default === 'function' ? schema.default(session) : schema.default;
255
282
  else if (!schema.optional && !(definition.$parameter?.name === name && definition.$parameter.optional)) {
256
- issues.push({ field: name, message: `${name}: expected ${schema.type}` });
283
+ issues.push(commandValidationIssue('expectedType', name, locale, { type: schema.type }));
257
284
  }
258
285
  }
259
- if (Object.hasOwn(params, name) && params[name] === undefined) issues.push({ field: name, message: `${name}: default or shortcut returned undefined` });
260
- else if (params[name] !== undefined) { const effective = definition.$parameter?.name === name && definition.$parameter.rest ? { ...schema, rest: true } : schema; const issue = validateValue(name, effective, params[name], session); if (issue) issues.push(issue); }
286
+ if (Object.hasOwn(params, name) && params[name] === undefined) issues.push(commandValidationIssue('parameterUndefined', name, locale));
287
+ else if (params[name] !== undefined) { const effective = definition.$parameter?.name === name && definition.$parameter.rest ? { ...schema, rest: true } : schema; const issue = validateValue(name, effective, params[name], session, locale); if (issue) issues.push(issue); }
261
288
  }
262
- if (declared.length > 0 && !definition.$parameter && positional.length > 0) issues.push({ field: 'arguments', message: 'Unexpected arguments' });
289
+ if (declared.length > 0 && !definition.$parameter && positional.length > 0) issues.push(commandValidationIssue('unexpectedArguments', 'arguments', locale));
263
290
  return Object.freeze({ params: Object.freeze(params), options: Object.freeze(options), remaining: schemas === undefined && declared.every(([name]) => Object.hasOwn(initial, name)) ? remaining : segmentsFromTokens(positional), issues: Object.freeze(issues) });
264
291
  }
265
292
 
@@ -269,12 +296,13 @@ export function commandUsage(name: string, definition: CommandDefinition): strin
269
296
  const options = Object.entries(definition.options ?? {}).map(([key, schema]) => `[--${key}${schema.type === 'boolean' ? '' : ` <${schema.type}>`}]`);
270
297
  return [name, ...parameters, ...options].join(' ');
271
298
  }
272
- export function commandHelp(name: string, definition: CommandDefinition, prefix = ''): string {
273
- return [prefix + commandUsage(name, definition), definition.description,
274
- ...Object.entries(definition.params ?? {}).map(([key, schema]) => argumentHelp(key, schema)),
275
- ...Object.entries(definition.options ?? {}).map(([key, schema]) => argumentHelp(`--${key}${schema.alias ? ` (-${schema.alias})` : ''}`, schema)),
299
+ export function commandHelp(name: string, definition: CommandDefinition, prefix = '', locale: Locale = 'zh-CN'): string {
300
+ return [prefix + commandUsage(name, definition), commandDescription(definition, locale),
301
+ ...Object.entries(definition.params ?? {}).map(([key, schema]) => argumentHelp(key, schema, locale)),
302
+ ...Object.entries(definition.options ?? {}).map(([key, schema]) => argumentHelp(`--${key}${schema.alias ? ` (-${schema.alias})` : ''}`, schema, locale)),
276
303
  ].filter(Boolean).join('\n');
277
304
  }
278
- function argumentHelp(name: string, schema: CommandParamSchema): string {
279
- return ` ${name}: ${schema.type}${schema.description ? ` — ${schema.description}` : ''}${schema.default !== undefined ? ` (default: ${typeof schema.default === 'function' ? 'dynamic' : JSON.stringify(schema.default)})` : ''}${schema.choices ? ` (${schema.choices.join(', ')})` : ''}${schema.min !== undefined ? ` min=${schema.min}` : ''}${schema.max !== undefined ? ` max=${schema.max}` : ''}`;
305
+ function argumentHelp(name: string, schema: CommandParamSchema, locale: Locale): string {
306
+ const description = commandDescription(schema, locale);
307
+ return ` ${name}: ${schema.type}${description ? ` — ${description}` : ''}${schema.default !== undefined ? ` (${commandMessages(locale, 'defaultValue', { value: typeof schema.default === 'function' ? commandMessages(locale, 'dynamicValue') : JSON.stringify(schema.default) })})` : ''}${schema.choices ? ` (${schema.choices.join(', ')})` : ''}${schema.min !== undefined ? ` min=${schema.min}` : ''}${schema.max !== undefined ? ` max=${schema.max}` : ''}`;
280
308
  }
@@ -5,10 +5,12 @@ import {
5
5
  } from 'segment-matcher';
6
6
  import {
7
7
  htmlRendererToken,
8
+ resolveLocale,
8
9
  type CapabilityId,
9
10
  type CapabilitySlot,
10
11
  type PluginId,
11
12
  type RuntimeSnapshot,
13
+ type Locale,
12
14
  } from '@zhin.js/plugin-runtime';
13
15
  import type { UserInteractionFactory } from '@zhin.js/interaction';
14
16
  import { bindOperationInput } from '@zhin.js/feature-kit';
@@ -17,8 +19,10 @@ import {
17
19
  type PermissionHost,
18
20
  } from '@zhin.js/permission';
19
21
  import { commandAdapterMatches, toCommandPermissionSubject } from './permission-explanation.js';
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 { 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';
22
26
  import { admitCommandExecution, admitDirectCommandExecution } from './execution-runtime.js';
23
27
  import { CommandExecutionLimitedError, normalizeExecutionPolicy, type CommandExecutionLimit } from './execution-policy.js';
24
28
  export { CommandExecutionLimitedError } from './execution-policy.js';
@@ -54,6 +58,24 @@ export interface CommandDescriptor {
54
58
  readonly shortcut?: readonly string[];
55
59
  }
56
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 locale?: Locale;
69
+ }
70
+ /** Presentation and generation shared across one menu lookup/render operation. */
71
+ interface CommandMenuContext {
72
+ readonly commandPrefix: string;
73
+ readonly keyword: string;
74
+ readonly snapshot: RuntimeSnapshot;
75
+ readonly locale: Locale;
76
+ }
77
+ interface CommandMenuSelection { readonly pluginKey?: string; readonly page?: number }
78
+
57
79
  export interface CommandDispatchResult {
58
80
  readonly matched: boolean;
59
81
  readonly status: 'unmatched' | 'invalid' | 'denied' | 'limited' | 'executed';
@@ -63,7 +85,7 @@ export interface CommandDispatchResult {
63
85
  readonly command?: string;
64
86
  readonly owner?: PluginId;
65
87
  readonly value?: unknown;
66
- readonly menu?: CommandMenuNavigation;
88
+ readonly menu?: CommandMenuPage;
67
89
  }
68
90
 
69
91
  /** Semantic menu navigation; the IM composition layer supplies platform controls. */
@@ -74,14 +96,28 @@ export interface CommandMenuNavigation {
74
96
  readonly pages: number;
75
97
  }
76
98
 
99
+ /** Immutable page data; presentation cannot change the catalog or execute commands. */
100
+ export interface CommandMenuPage extends CommandMenuNavigation {
101
+ readonly locale: Locale;
102
+ readonly title: string;
103
+ readonly commands: readonly CommandMenuEntry[];
104
+ readonly plugins: readonly CommandMenuEntry[];
105
+ readonly commandTotal: number;
106
+ readonly pluginTotal: number;
107
+ readonly hint?: string;
108
+ readonly text: string;
109
+ readonly appearance: CommandMenuAppearance;
110
+ }
111
+
77
112
  interface CommandMenuResult {
78
113
  readonly value: string | CommandMenuSegment;
79
- readonly menu?: CommandMenuNavigation;
114
+ readonly menu?: CommandMenuPage;
80
115
  }
81
116
 
82
117
  interface CommandRecord extends CommandDescriptor {
83
118
  readonly slot: Readonly<CapabilitySlot<CommandDefinition>>;
84
119
  readonly segments: readonly string[];
120
+ readonly preferredSegments: Readonly<Partial<Record<Locale, readonly string[]>>>;
85
121
  readonly parameter?: CommandParameterDefinition;
86
122
  }
87
123
 
@@ -121,7 +157,8 @@ const segmentFields = {
121
157
  };
122
158
 
123
159
  export interface CommandMenuConfig {
124
- readonly keyword: string;
160
+ /** Omission selects the built-in keyword for each operation's language. */
161
+ readonly keyword?: string;
125
162
  }
126
163
 
127
164
  export class CommandIndex {
@@ -198,6 +235,8 @@ export class CommandIndex {
198
235
  ...(shortcutKeys && shortcutKeys.length > 0 ? { shortcut: shortcutKeys } : {}),
199
236
  slot,
200
237
  segments: Object.freeze(primarySegments),
238
+ preferredSegments: Object.freeze(Object.fromEntries(Object.entries(slot.definition.preferredAliasI18n ?? {}).map(([locale, entry]) =>
239
+ [locale, Object.freeze([...namespace, ...aliasRuntimeSegments(entry, primarySegments.slice(namespace.length))])]))),
201
240
  parameter,
202
241
  });
203
242
 
@@ -251,8 +290,30 @@ export class CommandIndex {
251
290
  this.#shortcuts = shortcuts;
252
291
  }
253
292
 
254
- list(): readonly CommandDescriptor[] {
255
- return this.#commands.map(toDescriptor);
293
+ list(locale: Locale = 'zh-CN'): readonly CommandDescriptor[] {
294
+ return this.#commands.map(record => ({ ...toDescriptor(record),
295
+ name: commandDisplayName(record, locale), usage: commandUsage(commandDisplayName(record, locale), record.slot.definition),
296
+ description: commandDescription(record.slot.definition, locale),
297
+ parameters: record.parameters.map(parameter => ({ ...parameter, description: commandDescription(parameter, locale) })),
298
+ options: Object.freeze(Object.fromEntries(Object.entries(record.options).map(([key, schema]) =>
299
+ [key, { ...schema, description: commandDescription(schema, locale) }]))),
300
+ }));
301
+ }
302
+
303
+ /** Resolve a local static route without executing parameters, permission checks or commands. */
304
+ formatCommand(owner: PluginId, localPath: string, prefix: string | undefined, locale: Locale = 'zh-CN'): string | undefined {
305
+ if (prefix === undefined || typeof localPath !== 'string') return undefined;
306
+ const path = localPath.trim().split(/\s+/u).filter(Boolean).join(' ');
307
+ if (!path) return undefined;
308
+ const command = this.#commands.find(record => record.owner === owner
309
+ && record.slot.localName.split('/').filter(segment => !segment.startsWith('$')).join(' ') === path);
310
+ return command ? prefix + commandDisplaySegments(command, locale).filter(segment => !segment.startsWith('$')).join(' ') : undefined;
311
+ }
312
+
313
+ /** Return the configured menu entry only when the calling environment is known. */
314
+ formatMenu(prefix: string | undefined, locale: Locale): string | undefined {
315
+ if (prefix === undefined || !this.#menu) return undefined;
316
+ return prefix + (this.#menu.keyword ?? commandMessages(locale, 'menuKeyword'));
256
317
  }
257
318
 
258
319
  has(name: string): boolean {
@@ -265,6 +326,7 @@ export class CommandIndex {
265
326
  }
266
327
 
267
328
  async execute(name: string, args: readonly string[] = [], operationSnapshot: RuntimeSnapshot = this.snapshot): Promise<unknown> {
329
+ const locale = commandLocale(undefined, operationSnapshot);
268
330
  const match = this.#match(name, false);
269
331
  if (!match) {
270
332
  this.#diagnoseParameter(name);
@@ -273,33 +335,33 @@ export class CommandIndex {
273
335
  if (!(await this.#permitAllows(match.command, undefined, operationSnapshot))) throw new Error(`Permission denied for Command: ${match.command.name}`);
274
336
  const definition = match.command.slot.definition;
275
337
  const supplied = [...match.remaining, ...(args.length > 0 ? [{ type: 'text', data: { text: args.join(' ') } }] : [])];
276
- const parsed = parseCommandArguments(definition, supplied, resolveDynamicParams(match.params, undefined), {});
277
- if (parsed.issues.length > 0) throw new CommandArgumentValidationError(parsed.issues, commandHelp(match.command.name, definition));
338
+ const parsed = parseCommandArguments(definition, supplied, resolveDynamicParams(match.params, undefined), {}, locale);
339
+ if (parsed.issues.length > 0) throw new CommandArgumentValidationError(parsed.issues, commandHelp(commandDisplayName(match.command, locale), definition, '', locale));
278
340
  // No inbound source: execution policy applies per command, never as a blanket refusal.
279
341
  const admission = admitDirectCommandExecution(match.command.slot.id, match.command.execution, operationSnapshot);
280
342
  if (admission.limit) throw new CommandExecutionLimitedError(admission.limit);
281
343
  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));
344
+ return await definition.execute(createCommandContext({ snapshot: operationSnapshot, ownerId: match.command.slot.owner,
345
+ adapter: definition.adapter, presentation: { locale } }, {
346
+ args: textArgs(parsed.remaining), params: parsed.params, segments: parsed.remaining, options: parsed.options,
347
+ }));
284
348
  } finally {
285
349
  admission.release();
286
350
  }
287
351
  }
288
352
 
289
- async dispatch(
290
- input: CommandMatchInput,
291
- source: unknown = undefined,
292
- interactionFactory?: UserInteractionFactory,
293
- commandPrefix = '',
294
- operationSnapshot: RuntimeSnapshot = this.snapshot,
295
- ): Promise<CommandDispatchResult> {
353
+ async dispatch({ input, source, interactionFactory, commandPrefix, snapshot: operationSnapshot = this.snapshot,
354
+ locale: localeOverride }: CommandDispatchRequest): Promise<CommandDispatchResult> {
355
+ const locale = localeOverride ?? commandLocale(source, operationSnapshot);
356
+ const prefix = commandPrefix ?? '';
296
357
  if (this.#menu) {
297
- const menuResult = this.#dispatchMenu(input, commandPrefix, operationSnapshot);
358
+ const keyword = this.#menu.keyword ?? commandMessages(locale, 'menuKeyword');
359
+ const menuResult = this.#dispatchMenu(input, { commandPrefix: prefix, snapshot: operationSnapshot, locale, keyword });
298
360
  if (menuResult !== undefined) {
299
361
  return Object.freeze({
300
362
  matched: true,
301
363
  status: 'executed',
302
- command: this.#menu.keyword,
364
+ command: keyword,
303
365
  owner: operationSnapshot.root,
304
366
  ...menuResult,
305
367
  });
@@ -314,27 +376,31 @@ export class CommandIndex {
314
376
  if (!candidate) return Object.freeze({ matched: false, status: 'unmatched' });
315
377
  if (!commandAdapterMatches(candidate, source)) return Object.freeze({ matched: false, status: 'unmatched' });
316
378
  if (!(await this.#permitAllows(candidate, source, operationSnapshot))) return this.#denied(candidate);
317
- const help = commandHelp(candidate.name, candidate.slot.definition, commandPrefix);
379
+ const help = commandHelp(commandDisplayName(candidate, locale), candidate.slot.definition, prefix, locale);
318
380
  const segments = normalizeSegments(typeof input === 'string' ? [{ type: 'text', data: { text: input } }] : input);
319
381
  if (requestsCommandHelp(segments)) return Object.freeze({ matched: true, status: 'executed', command: candidate.name, owner: candidate.slot.owner, value: help });
320
382
  return Object.freeze({ matched: true, status: 'invalid', command: candidate.name, owner: candidate.slot.owner,
321
- issues: Object.freeze([{ field: candidate.parameter?.name ?? 'arguments', message: 'Missing or invalid command parameter' }]), help });
383
+ issues: Object.freeze([commandValidationIssue('invalidParameter', candidate.parameter?.name ?? 'arguments', locale)]), help });
322
384
  }
323
385
  if (!commandAdapterMatches(match.command, source)) return Object.freeze({ matched: false, status: 'unmatched' });
324
386
  if (!(await this.#permitAllows(match.command, source, operationSnapshot))) return this.#denied(match.command);
325
387
  const definition = match.command.slot.definition;
326
388
  const authorSource = bindOperationInput(source, match.command.slot.owner, operationSnapshot);
327
- const help = commandHelp(match.command.name, definition, commandPrefix);
389
+ const help = commandHelp(commandDisplayName(match.command, locale), definition, prefix, locale);
328
390
  if (match.helpRequested) {
329
391
  return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value: help });
330
392
  }
331
- const parsed = parseCommandArguments(definition, match.remaining, resolveDynamicParams(match.params, authorSource), resolveCommandSession(authorSource));
393
+ const parsed = parseCommandArguments(definition, match.remaining, resolveDynamicParams(match.params, authorSource), resolveCommandSession(authorSource), locale);
332
394
  if (parsed.issues.length > 0) return Object.freeze({ matched: true, status: 'invalid', command: match.command.name,
333
395
  owner: match.command.slot.owner, issues: parsed.issues, help });
334
396
  const limit = admitCommandExecution(authorSource, match.command.slot.id, match.command.slot.owner, match.command.execution, operationSnapshot);
335
397
  if (limit) return Object.freeze({ matched: true, status: 'limited', command: match.command.name, owner: match.command.slot.owner, limit });
336
- const value = await definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner,
337
- textArgs(parsed.remaining), parsed.params, authorSource, parsed.remaining, interactionFactory?.(authorSource), definition.adapter, parsed.options));
398
+ const value = await definition.execute(createCommandContext({
399
+ snapshot: operationSnapshot, ownerId: match.command.slot.owner, input: authorSource,
400
+ interaction: interactionFactory?.(authorSource), adapter: definition.adapter,
401
+ presentation: { locale, formatCommand: (localPath, owner = match.command.owner) => this.formatCommand(owner, localPath,
402
+ source && typeof source === 'object' && 'conversation' in source ? commandPrefix : undefined, locale) },
403
+ }, { args: textArgs(parsed.remaining), params: parsed.params, segments: parsed.remaining, options: parsed.options }));
338
404
  return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value });
339
405
  }
340
406
 
@@ -446,13 +512,11 @@ export class CommandIndex {
446
512
  // 内置菜单命令
447
513
  // ==========================================================================
448
514
 
449
- #dispatchMenu(input: CommandMatchInput, commandPrefix: string, snapshot: RuntimeSnapshot): CommandMenuResult | undefined {
515
+ #dispatchMenu(input: CommandMatchInput, context: CommandMenuContext): CommandMenuResult | undefined {
516
+ const { commandPrefix, keyword, locale } = context;
450
517
  const text = typeof input === 'string' ? input.trim() : exactMessageText(input);
451
518
  if (text === undefined) return undefined;
452
- const menu = this.#menu;
453
- if (!menu) return undefined;
454
- const keyword = menu.keyword;
455
- if (text === keyword) return this.#buildMenu(undefined, commandPrefix, keyword, snapshot);
519
+ if (text === keyword) return this.#buildMenu({}, context);
456
520
  if (text.startsWith(keyword + ' ')) {
457
521
  const key = text.slice(keyword.length + 1).trim();
458
522
  const args = key.split(/\s+/u);
@@ -461,24 +525,24 @@ export class CommandIndex {
461
525
  const pageText = args[flag + 1] ?? '';
462
526
  if (flag > 1 || args.length !== flag + 2 || !/^[1-9]\d*$/u.test(pageText)
463
527
  || !Number.isSafeInteger(Number(pageText))) {
464
- return { value: `页码必须是正整数。用法:${commandPrefix}${keyword} [插件名] --page <页码>` };
528
+ return { value: commandMessages(locale, 'invalidPage', { command: `${commandPrefix}${keyword}` }) };
465
529
  }
466
- return this.#buildMenu(flag === 1 ? args[0] : undefined, commandPrefix, keyword, snapshot, Number(pageText));
530
+ return this.#buildMenu({ pluginKey: flag === 1 ? args[0] : undefined, page: Number(pageText) }, context);
467
531
  }
468
- if (key) return this.#buildMenu(key, commandPrefix, keyword, snapshot);
469
- return this.#buildMenu(undefined, commandPrefix, keyword, snapshot);
532
+ if (key) return this.#buildMenu({ pluginKey: key }, context);
533
+ return this.#buildMenu({}, context);
470
534
  }
471
535
  return undefined;
472
536
  }
473
537
 
474
- #buildMenu(pluginKey: string | undefined, commandPrefix: string, keyword: string, snapshot: RuntimeSnapshot, page = 1): CommandMenuResult {
538
+ #buildMenu({ pluginKey, page = 1 }: CommandMenuSelection, { commandPrefix, keyword, snapshot, locale }: CommandMenuContext): CommandMenuResult {
475
539
  const root = snapshot.root;
476
540
  const targetId = pluginKey
477
541
  ? `${root}/${pluginKey.split('.').join('/')}` as PluginId
478
542
  : root;
479
543
 
480
544
  const node = snapshot.tree.get(targetId);
481
- if (!node) return { value: `未找到插件: ${pluginKey}` };
545
+ if (!node) return { value: commandMessages(locale, 'missingPlugin', { plugin: pluginKey ?? '' }) };
482
546
 
483
547
  const directCommands = this.#commands.filter((cmd) => cmd.slot.owner === targetId);
484
548
  const children = node.children
@@ -487,26 +551,27 @@ export class CommandIndex {
487
551
 
488
552
  const pages = Math.max(1, Math.ceil((directCommands.length + children.length) / MENU_PAGE_SIZE));
489
553
  const invocation = `${commandPrefix}${keyword}${pluginKey ? ` ${pluginKey}` : ''}`;
490
- if (page > pages) return { value: `页码超出范围:共 ${pages} 页。请使用「${invocation} --page <页码>」。` };
554
+ if (page > pages) return { value: commandMessages(locale, 'pageOutOfRange', { pages, command: invocation }) };
491
555
  const offset = (page - 1) * MENU_PAGE_SIZE;
492
556
  const pageCommands = directCommands.slice(offset, offset + MENU_PAGE_SIZE);
493
557
  const pageChildren = children.slice(Math.max(0, offset - directCommands.length), Math.max(0, offset + MENU_PAGE_SIZE - directCommands.length));
494
558
 
495
559
  const displayKey = pluginKey ?? node.instanceKey;
496
- const lines: string[] = [`=== ${displayKey} 指令菜单 ===`];
497
- if (pages > 1) lines.push(`第 ${page}/${pages} 页 · ${directCommands.length} 条指令 · ${children.length} 个子插件`);
560
+ const lines: string[] = [commandMessages(locale, 'menuTitle', { plugin: displayKey })];
561
+ if (pages > 1) lines.push(commandMessages(locale, 'menuSummary', { page, pages, commands: directCommands.length, plugins: children.length }));
498
562
 
499
563
  if (pageCommands.length > 0) {
500
564
  lines.push('');
501
565
  for (const cmd of pageCommands) {
502
- const desc = cmd.description ? ` - ${cmd.description}` : '';
503
- lines.push(` ${commandPrefix}${cmd.name}${desc}`);
566
+ const description = commandDescription(cmd.slot.definition, locale);
567
+ const desc = description ? ` - ${description}` : '';
568
+ lines.push(` ${commandPrefix}${commandDisplayName(cmd, locale)}${desc}`);
504
569
  }
505
570
  }
506
571
 
507
572
  if (pageChildren.length > 0) {
508
573
  lines.push('');
509
- lines.push('子插件:');
574
+ lines.push(commandMessages(locale, 'childPlugins'));
510
575
  for (const child of pageChildren) {
511
576
  const childKey = pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey;
512
577
  const label = child.metadata?.displayName ?? child.instanceKey;
@@ -516,13 +581,13 @@ export class CommandIndex {
516
581
 
517
582
  if (directCommands.length === 0 && children.length === 0) {
518
583
  lines.push('');
519
- lines.push('(暂无指令和子插件)');
584
+ lines.push(commandMessages(locale, 'emptyMenu'));
520
585
  }
521
586
 
522
587
  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}`);
588
+ if (children.length > 0) hints.push(commandMessages(locale, 'pluginHint', { command: `${commandPrefix}${keyword}` }));
589
+ if (page > 1) hints.push(commandMessages(locale, 'previousPage', { command: `${invocation} --page ${page - 1}` }));
590
+ if (page < pages) hints.push(commandMessages(locale, 'nextPage', { command: `${invocation} --page ${page + 1}` }));
526
591
  const hint = hints.length ? hints.join('\n') : undefined;
527
592
  if (hint) {
528
593
  lines.push('');
@@ -530,19 +595,39 @@ export class CommandIndex {
530
595
  }
531
596
 
532
597
  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
598
  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 => ({
599
+ const menu: CommandMenuPage = Object.freeze({
600
+ keyword, ...(pluginKey ? { pluginKey } : {}), page, pages, locale, title: displayKey,
601
+ commands: Object.freeze(pageCommands.map(cmd => Object.freeze({
602
+ name: `${commandPrefix}${commandDisplayName(cmd, locale)}`, description: commandDescription(cmd.slot.definition, locale),
603
+ }))),
604
+ plugins: Object.freeze(pageChildren.map(child => Object.freeze({
539
605
  name: pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey,
540
606
  description: child.metadata?.displayName ?? child.instanceKey,
541
- })), hint, text, { page, pages, commandTotal: directCommands.length, pluginTotal: children.length,
542
- appearance: readMenuAppearance(config?.menuStyle) }) };
607
+ }))),
608
+ commandTotal: directCommands.length, pluginTotal: children.length, hint, text,
609
+ appearance: Object.freeze(readMenuAppearance(config?.menuStyle)),
610
+ });
611
+ if (!snapshot.resources.get(root)?.has(htmlRendererToken.id)) return { value: text, menu };
612
+ return { menu, value: renderCommandMenu(displayKey, menu.commands, menu.plugins, hint, text, {
613
+ page, pages, locale, commandTotal: menu.commandTotal, pluginTotal: menu.pluginTotal, appearance: menu.appearance,
614
+ }) };
543
615
  }
544
616
  }
545
617
 
618
+ function commandDisplaySegments(record: CommandRecord, locale: Locale): readonly string[] {
619
+ return record.preferredSegments[locale] ?? record.segments;
620
+ }
621
+
622
+ function commandDisplayName(record: CommandRecord, locale: Locale): string {
623
+ return displayName(commandDisplaySegments(record, locale), record.parameter);
624
+ }
625
+
626
+ function commandLocale(source: unknown, snapshot: RuntimeSnapshot): Locale {
627
+ const locale = source && typeof source === 'object' && 'locale' in source ? source.locale : undefined;
628
+ return resolveLocale(locale ?? (snapshot.config.get(snapshot.root) as { locale?: unknown } | undefined)?.locale);
629
+ }
630
+
546
631
  export function isCommandIndex(value: unknown): value is CommandIndex {
547
632
  return !!value && typeof value === 'object'
548
633
  && (value as { readonly $projection?: unknown }).$projection === 'zhin.command-index/1';