@zhin.js/command 1.1.10 → 1.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,6 +42,8 @@ commands/search/[...kw]/index.ts -> search <...kw> (params: { kw: { type:
42
42
  - `config` / `use()` / `owner` / `generation`:owner-scoped Runtime 上下文。
43
43
  - `signal`:真实 IM 入站操作的运行代退休时同步取消。补问、确认及其他需要随代结束的等待应观察此信号;它不会强制取消已经开始的发送,也不禁止持有旧 lease 的普通命令继续排空。没有携带运行代生命周期的输入(如普通 Host 调用)不会凭空获得 IM 生命周期。
44
44
  - `input`:派发来源;IM 中为满足 `CommandMessage` 的 Runtime `Message`。
45
+ - `locale`:本次操作已经解析的 `zh-CN` 或 `en`,群聊也按发送者偏好;无个人偏好时沿用项目默认语言。
46
+ - `formatCommand(localPath, owner?)`:从当前索引取得静态命令调用头,默认本插件,也可显式传入已知的插件 owner,自动拼上实际 Endpoint 前缀和显式 `commandNamespace`。例如 `formatCommand('remind list')`;参数在返回的调用头后显式附加。命令不存在、被禁用或当前没有已知聊天命令环境时返回 `undefined`,不要以硬编码 `/` 或命令名掩盖该结果。
45
47
  - `adapter` / `endpoint`:适配器实例 id 与 endpoint 名。
46
48
  - `scene`:`{ id, type, name? }` 场景对象。
47
49
  - `sender`:`{ id, name?, role: string[] }` 发送者对象。
@@ -78,7 +80,9 @@ PermissionHost 或缺少身份依据均不能被展示为“已授权”或“
78
80
 
79
81
  ## 集中声明参数、选项与帮助
80
82
 
81
- **列出指令**:在 Bot 会话中发送 `菜单`,列出根插件的指令和直接子插件;`菜单 <插件实例键>` 查看子插件,嵌套实例用 `菜单 foo.bar`。菜单逐层浏览,不一次展开全树。账号设有命令前缀时同样要加前缀,如 `/菜单`。项目根配置的顶层 `menuKeyword` 可修改关键词,`false` 或空字符串关闭;默认关键词为 `菜单`,不是 `help`。详见[指令菜单](../../../docs/authoring/commands.md#查看已加载的指令菜单)。
83
+ **列出指令**:中文操作使用 `菜单`,英文操作使用 `menu`,每次只接受该操作语言的默认关键词。列出根插件的指令和直接子插件;`菜单 <插件实例键>` 查看子插件,嵌套实例用 `菜单 foo.bar`(英文对应 `menu foo.bar`)。菜单逐层浏览,不一次展开全树。账号设有命令前缀时同样要加前缀,如 `/菜单`。项目根配置的顶层 `menuKeyword` 可显式修改关键词,不随语言翻译;`false` 或空字符串关闭。详见[指令菜单](../../../docs/authoring/commands.md#查看已加载的指令菜单)。
84
+
85
+ 命令、参数和选项保留 `description` 原文,可额外声明 `descriptionI18n: { en: 'English description' }`。菜单、`--help` 与 `CommandIndex.list(locale)` 使用对应说明,缺失时保留作者原文,不自动翻译机器名称、用户内容或参数值。参数错误带稳定的 `code` 与 `parameters`,`message` 是当前语言的展示;自定义 `validate` 返回的文本仍由作者负责。
82
86
 
83
87
  安装 `@zhin.js/html-renderer` 后,菜单发出带原始文本备用内容的 HTML 段,复用统一发送链的图片渲染和平台策略。渲染失败或平台无法消费图片时保留原文本;无渲染器时直接返回文本。Command Feature 不调用渲染器,也不新增 Core 或样式组件依赖。
84
88
 
@@ -170,3 +174,19 @@ pnpm --filter @zhin.js/command test
170
174
 
171
175
  完整用户契约见[命令创作指南](../../../docs/authoring/commands.md),架构迁移背景见
172
176
  [Plugin Runtime 原位迁移](../../../docs/architecture/target-implementation/in-place-migration.md)。
177
+
178
+
179
+ 命令可声明 `descriptionI18n: { en: 'Create a reminder' }` 翻译描述。若静态目录名是中文,可沿用 `alias` 提供英文入口,并用 `preferredAliasI18n` 显式选择英文展示:
180
+
181
+ ```ts
182
+ export default defineCommand({
183
+ alias: ['reminder subscribe'],
184
+ preferredAliasI18n: { en: 'reminder subscribe' },
185
+ execute({ locale, formatCommand }) {
186
+ // 参数仍由调用方明确附加;不存在或没有真实聊天环境时返回 undefined。
187
+ return formatCommand('提醒 订阅');
188
+ },
189
+ });
190
+ ```
191
+
192
+ `preferredAliasI18n` 必须引用该命令已声明的别名;它只选择菜单、帮助与指令引用的展示入口,不创建新路由或停用既有别名。实际插件挂载命名空间与当前消息前缀由同一个 `CommandIndex` 拼接。
@@ -1,8 +1,14 @@
1
1
  import type { CommandDefinition, CommandParams, CommandOptions, CommandParameterValue, CommandSegment, CommandSession } from './definition.js';
2
+ import type { Locale } from '@zhin.js/plugin-runtime';
3
+ export type CommandValidationIssueCode = 'expectedType' | 'expectedArray' | 'minimum' | 'maximum' | 'choices' | 'invalidFormat' | 'invalidValue' | 'unknownOption' | 'duplicateOption' | 'defaultUndefined' | 'parameterUndefined' | 'missingOption' | 'unexpectedArguments' | 'invalidParameter' | 'custom';
2
4
  export interface CommandValidationIssue {
3
5
  readonly field: string;
4
6
  readonly message: string;
7
+ readonly code?: CommandValidationIssueCode;
8
+ readonly parameters?: Readonly<Record<string, string | number>>;
5
9
  }
10
+ /** Stable validation reason and values; never reverse-parse an English message. */
11
+ export declare function commandValidationIssue(code: Exclude<CommandValidationIssueCode, 'custom'>, field: string, locale: Locale, parameters?: Readonly<Record<string, string | number>>): CommandValidationIssue;
6
12
  export interface ParsedCommandArguments {
7
13
  readonly params: Readonly<Record<string, CommandParameterValue>>;
8
14
  readonly options: Readonly<Record<string, CommandParameterValue>>;
@@ -14,6 +20,6 @@ export declare function validateArgumentSchemas(params?: CommandParams, options?
14
20
  /** Reserved flags use the same quoted-token and option-terminator rules as arguments. */
15
21
  export declare function requestsCommandHelp(segments: readonly Readonly<CommandSegment>[]): boolean;
16
22
  /** Parse arguments after a route match; the CommandIndex remains the only dispatcher. */
17
- export declare function parseCommandArguments(definition: CommandDefinition, remaining: readonly Readonly<CommandSegment>[], initial: Readonly<Record<string, CommandParameterValue>>, session: CommandSession): ParsedCommandArguments;
23
+ export declare function parseCommandArguments(definition: CommandDefinition, remaining: readonly Readonly<CommandSegment>[], initial: Readonly<Record<string, CommandParameterValue>>, session: CommandSession, locale?: Locale): ParsedCommandArguments;
18
24
  export declare function commandUsage(name: string, definition: CommandDefinition): string;
19
- export declare function commandHelp(name: string, definition: CommandDefinition, prefix?: string): string;
25
+ export declare function commandHelp(name: string, definition: CommandDefinition, prefix?: string, locale?: Locale): string;
package/lib/arguments.js CHANGED
@@ -1,3 +1,9 @@
1
+ import { commandDescription, commandMessages } from './messages.js';
2
+ /** Stable validation reason and values; never reverse-parse an English message. */
3
+ export function commandValidationIssue(code, field, locale, parameters = {}) {
4
+ const values = Object.freeze({ field, ...parameters });
5
+ return Object.freeze({ field, code, parameters: values, message: commandMessages(locale, code, values) });
6
+ }
1
7
  const scalarTypes = new Set(['string', 'word', 'text', 'number', 'integer', 'float', 'boolean']);
2
8
  /** Validate the single declaration used by parsing, help and execution. */
3
9
  export function validateArgumentSchemas(params, options) {
@@ -51,6 +57,11 @@ function validateSchema(name, schema) {
51
57
  if (schema.choices !== undefined && !Array.isArray(schema.choices)) {
52
58
  throw new TypeError(`Command argument ${name}.choices must be an array`);
53
59
  }
60
+ if (schema.descriptionI18n !== undefined && (!schema.descriptionI18n || typeof schema.descriptionI18n !== 'object'
61
+ || Array.isArray(schema.descriptionI18n)
62
+ || Object.entries(schema.descriptionI18n).some(([locale, text]) => !['zh-CN', 'en'].includes(locale) || typeof text !== 'string'))) {
63
+ throw new TypeError(`Command argument ${name}.descriptionI18n must map zh-CN/en to strings`);
64
+ }
54
65
  if (schema.default !== undefined && !scalarTypes.has(schema.type))
55
66
  throw new TypeError(`Structured Command argument ${name} cannot declare a default`);
56
67
  if (schema.default !== undefined && typeof schema.default !== 'function') {
@@ -158,31 +169,32 @@ function valueMatches(schema, value) {
158
169
  return typeof value === 'string' || !!value && typeof value === 'object';
159
170
  return typeof value === 'string';
160
171
  }
161
- function validateValue(field, schema, value, session) {
172
+ function validateValue(field, schema, value, session, locale = 'zh-CN') {
162
173
  if (schema.rest && !Array.isArray(value))
163
- return { field, message: `${field}: expected an array of ${schema.type}` };
174
+ return commandValidationIssue('expectedArray', field, locale, { type: schema.type });
164
175
  const values = schema.rest ? (Array.isArray(value) ? value : [value]) : [value];
165
176
  for (const item of values) {
166
177
  if (!valueMatches(schema, item))
167
- return { field, message: `${field}: expected ${schema.type}` };
178
+ return commandValidationIssue('expectedType', field, locale, { type: schema.type });
168
179
  const size = typeof item === 'number' ? item : typeof item === 'string' ? item.length : undefined;
169
180
  if (schema.min !== undefined && (size === undefined || size < schema.min))
170
- return { field, message: `${field}: minimum ${schema.min}` };
181
+ return commandValidationIssue('minimum', field, locale, { minimum: schema.min });
171
182
  if (schema.max !== undefined && (size === undefined || size > schema.max))
172
- return { field, message: `${field}: maximum ${schema.max}` };
183
+ return commandValidationIssue('maximum', field, locale, { maximum: schema.max });
173
184
  if (schema.choices && !schema.choices.includes(item))
174
- return { field, message: `${field}: expected one of ${schema.choices.join(', ')}` };
185
+ return commandValidationIssue('choices', field, locale, { choices: schema.choices.join(', ') });
175
186
  if (schema.pattern) {
176
187
  const pattern = new RegExp(schema.pattern.source, schema.pattern.flags.replace(/[gy]/gu, ''));
177
188
  if (typeof item !== 'string' || !pattern.test(item))
178
- return { field, message: `${field}: invalid format` };
189
+ return commandValidationIssue('invalidFormat', field, locale);
179
190
  }
180
191
  }
181
192
  const result = schema.validate?.(value, session);
182
- return result === false || typeof result === 'string' ? { field, message: typeof result === 'string' ? result : `${field}: invalid value` } : undefined;
193
+ return typeof result === 'string' ? Object.freeze({ field, code: 'custom', message: result })
194
+ : result === false ? commandValidationIssue('invalidValue', field, locale) : undefined;
183
195
  }
184
196
  /** Parse arguments after a route match; the CommandIndex remains the only dispatcher. */
185
- export function parseCommandArguments(definition, remaining, initial, session) {
197
+ export function parseCommandArguments(definition, remaining, initial, session, locale = 'zh-CN') {
186
198
  const params = { __proto__: null, ...initial };
187
199
  const options = { __proto__: null };
188
200
  const issues = [];
@@ -211,14 +223,14 @@ export function parseCommandArguments(definition, remaining, initial, session) {
211
223
  const flag = rawFlag ?? token.value;
212
224
  const name = optionNames.get(flag);
213
225
  if (!name) {
214
- issues.push({ field: flag, message: `Unknown option ${flag}` });
226
+ issues.push(commandValidationIssue('unknownOption', flag, locale));
215
227
  continue;
216
228
  }
217
229
  const schema = schemas[name];
218
230
  if (!schema)
219
231
  continue;
220
232
  if (Object.hasOwn(options, name)) {
221
- issues.push({ field: name, message: `Duplicate option --${name}` });
233
+ issues.push(commandValidationIssue('duplicateOption', name, locale));
222
234
  continue;
223
235
  }
224
236
  let value;
@@ -234,7 +246,7 @@ export function parseCommandArguments(definition, remaining, initial, session) {
234
246
  }
235
247
  }
236
248
  if (value === undefined)
237
- issues.push({ field: name, message: `--${name}: expected ${schema.type}` });
249
+ issues.push(commandValidationIssue('expectedType', name, locale, { field: `--${name}`, type: schema.type }));
238
250
  else
239
251
  options[name] = value;
240
252
  }
@@ -242,14 +254,14 @@ export function parseCommandArguments(definition, remaining, initial, session) {
242
254
  if (!Object.hasOwn(options, name) && schema.default !== undefined)
243
255
  options[name] = typeof schema.default === 'function' ? schema.default(session) : schema.default;
244
256
  if (Object.hasOwn(options, name) && options[name] === undefined)
245
- issues.push({ field: name, message: `--${name}: default returned undefined` });
257
+ issues.push(commandValidationIssue('defaultUndefined', name, locale, { field: `--${name}` }));
246
258
  else if (options[name] !== undefined) {
247
- const issue = validateValue(`--${name}`, schema, options[name], session);
259
+ const issue = validateValue(`--${name}`, schema, options[name], session, locale);
248
260
  if (issue)
249
261
  issues.push(issue);
250
262
  }
251
263
  else if (schema.optional === false)
252
- issues.push({ field: name, message: `Missing option --${name}` });
264
+ issues.push(commandValidationIssue('missingOption', name, locale));
253
265
  }
254
266
  const declared = Object.entries(definition.params ?? {});
255
267
  for (const [name, declaredSchema] of declared) {
@@ -262,7 +274,7 @@ export function parseCommandArguments(definition, remaining, initial, session) {
262
274
  ? segmentsFromTokens(raw).map((segment) => segment.type === 'text' && typeof segment.data.text === 'string' ? segment.data.text : undefined)
263
275
  : raw.map((token) => convert(schema, token));
264
276
  if (converted.some((item) => item === undefined))
265
- issues.push({ field: name, message: `${name}: expected ${schema.type}` });
277
+ issues.push(commandValidationIssue('expectedType', name, locale, { type: schema.type }));
266
278
  else if (converted.length > 0 || schema.optional)
267
279
  value = converted;
268
280
  }
@@ -279,7 +291,7 @@ export function parseCommandArguments(definition, remaining, initial, session) {
279
291
  value = convert(schema, positional.shift());
280
292
  // Defaults apply to omitted input, never to a supplied value of the wrong type.
281
293
  if (value === undefined) {
282
- issues.push({ field: name, message: `${name}: expected ${schema.type}` });
294
+ issues.push(commandValidationIssue('expectedType', name, locale, { type: schema.type }));
283
295
  continue;
284
296
  }
285
297
  }
@@ -288,20 +300,20 @@ export function parseCommandArguments(definition, remaining, initial, session) {
288
300
  else if (schema.default !== undefined)
289
301
  params[name] = typeof schema.default === 'function' ? schema.default(session) : schema.default;
290
302
  else if (!schema.optional && !(definition.$parameter?.name === name && definition.$parameter.optional)) {
291
- issues.push({ field: name, message: `${name}: expected ${schema.type}` });
303
+ issues.push(commandValidationIssue('expectedType', name, locale, { type: schema.type }));
292
304
  }
293
305
  }
294
306
  if (Object.hasOwn(params, name) && params[name] === undefined)
295
- issues.push({ field: name, message: `${name}: default or shortcut returned undefined` });
307
+ issues.push(commandValidationIssue('parameterUndefined', name, locale));
296
308
  else if (params[name] !== undefined) {
297
309
  const effective = definition.$parameter?.name === name && definition.$parameter.rest ? { ...schema, rest: true } : schema;
298
- const issue = validateValue(name, effective, params[name], session);
310
+ const issue = validateValue(name, effective, params[name], session, locale);
299
311
  if (issue)
300
312
  issues.push(issue);
301
313
  }
302
314
  }
303
315
  if (declared.length > 0 && !definition.$parameter && positional.length > 0)
304
- issues.push({ field: 'arguments', message: 'Unexpected arguments' });
316
+ issues.push(commandValidationIssue('unexpectedArguments', 'arguments', locale));
305
317
  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) });
306
318
  }
307
319
  export function commandUsage(name, definition) {
@@ -310,12 +322,13 @@ export function commandUsage(name, definition) {
310
322
  const options = Object.entries(definition.options ?? {}).map(([key, schema]) => `[--${key}${schema.type === 'boolean' ? '' : ` <${schema.type}>`}]`);
311
323
  return [name, ...parameters, ...options].join(' ');
312
324
  }
313
- export function commandHelp(name, definition, prefix = '') {
314
- return [prefix + commandUsage(name, definition), definition.description,
315
- ...Object.entries(definition.params ?? {}).map(([key, schema]) => argumentHelp(key, schema)),
316
- ...Object.entries(definition.options ?? {}).map(([key, schema]) => argumentHelp(`--${key}${schema.alias ? ` (-${schema.alias})` : ''}`, schema)),
325
+ export function commandHelp(name, definition, prefix = '', locale = 'zh-CN') {
326
+ return [prefix + commandUsage(name, definition), commandDescription(definition, locale),
327
+ ...Object.entries(definition.params ?? {}).map(([key, schema]) => argumentHelp(key, schema, locale)),
328
+ ...Object.entries(definition.options ?? {}).map(([key, schema]) => argumentHelp(`--${key}${schema.alias ? ` (-${schema.alias})` : ''}`, schema, locale)),
317
329
  ].filter(Boolean).join('\n');
318
330
  }
319
- function argumentHelp(name, schema) {
320
- 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}` : ''}`;
331
+ function argumentHelp(name, schema, locale) {
332
+ const description = commandDescription(schema, locale);
333
+ 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}` : ''}`;
321
334
  }
@@ -1,6 +1,8 @@
1
- import { type CapabilityId, type CapabilitySlot, type PluginId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
1
+ import { type CapabilityId, type CapabilitySlot, type PluginId, type RuntimeSnapshot, type Locale } from '@zhin.js/plugin-runtime';
2
2
  import type { UserInteractionFactory } from '@zhin.js/interaction';
3
3
  import { type CommandValidationIssue } from './arguments.js';
4
+ import { type CommandMenuEntry, type CommandMenuAppearance } from './command-menu.js';
5
+ export type { CommandMenuEntry, CommandMenuAppearance } from './command-menu.js';
4
6
  import { type CommandExecutionLimit } from './execution-policy.js';
5
7
  export { CommandExecutionLimitedError } from './execution-policy.js';
6
8
  import { type CommandDefinition, type CommandParameterDefinition, type CommandParameterType, type CommandSegment } from './definition.js';
@@ -32,7 +34,7 @@ export interface CommandDispatchResult {
32
34
  readonly command?: string;
33
35
  readonly owner?: PluginId;
34
36
  readonly value?: unknown;
35
- readonly menu?: CommandMenuNavigation;
37
+ readonly menu?: CommandMenuPage;
36
38
  }
37
39
  /** Semantic menu navigation; the IM composition layer supplies platform controls. */
38
40
  export interface CommandMenuNavigation {
@@ -41,19 +43,36 @@ export interface CommandMenuNavigation {
41
43
  readonly page: number;
42
44
  readonly pages: number;
43
45
  }
46
+ /** Immutable page data; presentation cannot change the catalog or execute commands. */
47
+ export interface CommandMenuPage extends CommandMenuNavigation {
48
+ readonly locale: Locale;
49
+ readonly title: string;
50
+ readonly commands: readonly CommandMenuEntry[];
51
+ readonly plugins: readonly CommandMenuEntry[];
52
+ readonly commandTotal: number;
53
+ readonly pluginTotal: number;
54
+ readonly hint?: string;
55
+ readonly text: string;
56
+ readonly appearance: CommandMenuAppearance;
57
+ }
44
58
  export type CommandMatchInput = string | readonly Readonly<CommandSegment>[];
45
59
  export interface CommandMenuConfig {
46
- readonly keyword: string;
60
+ /** Omission selects the built-in keyword for each operation's language. */
61
+ readonly keyword?: string;
47
62
  }
48
63
  export declare class CommandIndex {
49
64
  #private;
50
65
  private readonly snapshot;
51
66
  readonly $projection: "zhin.command-index/1";
52
67
  constructor(slots: readonly Readonly<CapabilitySlot<CommandDefinition>>[], snapshot: RuntimeSnapshot, menu?: CommandMenuConfig);
53
- list(): readonly CommandDescriptor[];
68
+ list(locale?: Locale): readonly CommandDescriptor[];
69
+ /** Resolve a local static route without executing parameters, permission checks or commands. */
70
+ formatCommand(owner: PluginId, localPath: string, prefix: string | undefined, locale?: Locale): string | undefined;
71
+ /** Return the configured menu entry only when the calling environment is known. */
72
+ formatMenu(prefix: string | undefined, locale: Locale): string | undefined;
54
73
  has(name: string): boolean;
55
74
  execute(name: string, args?: readonly string[], operationSnapshot?: RuntimeSnapshot): Promise<unknown>;
56
- dispatch(input: CommandMatchInput, source?: unknown, interactionFactory?: UserInteractionFactory, commandPrefix?: string, operationSnapshot?: RuntimeSnapshot): Promise<CommandDispatchResult>;
75
+ dispatch(input: CommandMatchInput, source?: unknown, interactionFactory?: UserInteractionFactory, commandPrefix?: string, operationSnapshot?: RuntimeSnapshot, localeOverride?: Locale): Promise<CommandDispatchResult>;
57
76
  }
58
77
  export declare function isCommandIndex(value: unknown): value is CommandIndex;
59
78
  /**
@@ -1,9 +1,10 @@
1
1
  import { SegmentMatcher, TypeMatcherRegistry, } from 'segment-matcher';
2
- import { htmlRendererToken, } from '@zhin.js/plugin-runtime';
2
+ import { htmlRendererToken, resolveLocale, } from '@zhin.js/plugin-runtime';
3
3
  import { bindOperationInput } from '@zhin.js/feature-kit';
4
4
  import { permissionHostToken, } from '@zhin.js/permission';
5
5
  import { commandAdapterMatches, toCommandPermissionSubject } from './permission-explanation.js';
6
- import { commandUsage, commandHelp, parseCommandArguments, requestsCommandHelp } from './arguments.js';
6
+ import { commandUsage, commandHelp, parseCommandArguments, requestsCommandHelp, commandValidationIssue } from './arguments.js';
7
+ import { commandDescription, commandMessages } from './messages.js';
7
8
  import { MENU_PAGE_SIZE, readMenuAppearance, renderCommandMenu } from './command-menu.js';
8
9
  import { admitCommandExecution, admitDirectCommandExecution } from './execution-runtime.js';
9
10
  import { CommandExecutionLimitedError, normalizeExecutionPolicy } from './execution-policy.js';
@@ -90,6 +91,7 @@ export class CommandIndex {
90
91
  ...(shortcutKeys && shortcutKeys.length > 0 ? { shortcut: shortcutKeys } : {}),
91
92
  slot,
92
93
  segments: Object.freeze(primarySegments),
94
+ preferredSegments: Object.freeze(Object.fromEntries(Object.entries(slot.definition.preferredAliasI18n ?? {}).map(([locale, entry]) => [locale, Object.freeze([...namespace, ...aliasRuntimeSegments(entry, primarySegments.slice(namespace.length))])]))),
93
95
  parameter,
94
96
  });
95
97
  claim([...new Set([occupancyKey(primarySegments, parameter), ...declaredRouteShapes(primarySegments, slot.definition)])], slot.source);
@@ -130,8 +132,30 @@ export class CommandIndex {
130
132
  this.#routes = Object.freeze(routes.sort(compareRoutes));
131
133
  this.#shortcuts = shortcuts;
132
134
  }
133
- list() {
134
- return this.#commands.map(toDescriptor);
135
+ list(locale = 'zh-CN') {
136
+ return this.#commands.map(record => ({ ...toDescriptor(record),
137
+ name: commandDisplayName(record, locale), usage: commandUsage(commandDisplayName(record, locale), record.slot.definition),
138
+ description: commandDescription(record.slot.definition, locale),
139
+ parameters: record.parameters.map(parameter => ({ ...parameter, description: commandDescription(parameter, locale) })),
140
+ options: Object.freeze(Object.fromEntries(Object.entries(record.options).map(([key, schema]) => [key, { ...schema, description: commandDescription(schema, locale) }]))),
141
+ }));
142
+ }
143
+ /** Resolve a local static route without executing parameters, permission checks or commands. */
144
+ formatCommand(owner, localPath, prefix, locale = 'zh-CN') {
145
+ if (prefix === undefined || typeof localPath !== 'string')
146
+ return undefined;
147
+ const path = localPath.trim().split(/\s+/u).filter(Boolean).join(' ');
148
+ if (!path)
149
+ return undefined;
150
+ const command = this.#commands.find(record => record.owner === owner
151
+ && record.slot.localName.split('/').filter(segment => !segment.startsWith('$')).join(' ') === path);
152
+ return command ? prefix + commandDisplaySegments(command, locale).filter(segment => !segment.startsWith('$')).join(' ') : undefined;
153
+ }
154
+ /** Return the configured menu entry only when the calling environment is known. */
155
+ formatMenu(prefix, locale) {
156
+ if (prefix === undefined || !this.#menu)
157
+ return undefined;
158
+ return prefix + (this.#menu.keyword ?? commandMessages(locale, 'menuKeyword'));
135
159
  }
136
160
  has(name) {
137
161
  try {
@@ -144,6 +168,7 @@ export class CommandIndex {
144
168
  }
145
169
  }
146
170
  async execute(name, args = [], operationSnapshot = this.snapshot) {
171
+ const locale = commandLocale(undefined, operationSnapshot);
147
172
  const match = this.#match(name, false);
148
173
  if (!match) {
149
174
  this.#diagnoseParameter(name);
@@ -153,28 +178,31 @@ export class CommandIndex {
153
178
  throw new Error(`Permission denied for Command: ${match.command.name}`);
154
179
  const definition = match.command.slot.definition;
155
180
  const supplied = [...match.remaining, ...(args.length > 0 ? [{ type: 'text', data: { text: args.join(' ') } }] : [])];
156
- const parsed = parseCommandArguments(definition, supplied, resolveDynamicParams(match.params, undefined), {});
181
+ const parsed = parseCommandArguments(definition, supplied, resolveDynamicParams(match.params, undefined), {}, locale);
157
182
  if (parsed.issues.length > 0)
158
- throw new CommandArgumentValidationError(parsed.issues, commandHelp(match.command.name, definition));
183
+ throw new CommandArgumentValidationError(parsed.issues, commandHelp(commandDisplayName(match.command, locale), definition, '', locale));
159
184
  // No inbound source: execution policy applies per command, never as a blanket refusal.
160
185
  const admission = admitDirectCommandExecution(match.command.slot.id, match.command.execution, operationSnapshot);
161
186
  if (admission.limit)
162
187
  throw new CommandExecutionLimitedError(admission.limit);
163
188
  try {
164
- return await definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner, textArgs(parsed.remaining), parsed.params, undefined, parsed.remaining, undefined, definition.adapter, parsed.options));
189
+ return await definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner, textArgs(parsed.remaining), parsed.params, undefined, parsed.remaining, undefined, definition.adapter, parsed.options, { locale }));
165
190
  }
166
191
  finally {
167
192
  admission.release();
168
193
  }
169
194
  }
170
- async dispatch(input, source = undefined, interactionFactory, commandPrefix = '', operationSnapshot = this.snapshot) {
195
+ async dispatch(input, source = undefined, interactionFactory, commandPrefix, operationSnapshot = this.snapshot, localeOverride) {
196
+ const locale = localeOverride ?? commandLocale(source, operationSnapshot);
197
+ const prefix = commandPrefix ?? '';
171
198
  if (this.#menu) {
172
- const menuResult = this.#dispatchMenu(input, commandPrefix, operationSnapshot);
199
+ const keyword = this.#menu.keyword ?? commandMessages(locale, 'menuKeyword');
200
+ const menuResult = this.#dispatchMenu(input, prefix, operationSnapshot, locale, keyword);
173
201
  if (menuResult !== undefined) {
174
202
  return Object.freeze({
175
203
  matched: true,
176
204
  status: 'executed',
177
- command: this.#menu.keyword,
205
+ command: keyword,
178
206
  owner: operationSnapshot.root,
179
207
  ...menuResult,
180
208
  });
@@ -192,12 +220,12 @@ export class CommandIndex {
192
220
  return Object.freeze({ matched: false, status: 'unmatched' });
193
221
  if (!(await this.#permitAllows(candidate, source, operationSnapshot)))
194
222
  return this.#denied(candidate);
195
- const help = commandHelp(candidate.name, candidate.slot.definition, commandPrefix);
223
+ const help = commandHelp(commandDisplayName(candidate, locale), candidate.slot.definition, prefix, locale);
196
224
  const segments = normalizeSegments(typeof input === 'string' ? [{ type: 'text', data: { text: input } }] : input);
197
225
  if (requestsCommandHelp(segments))
198
226
  return Object.freeze({ matched: true, status: 'executed', command: candidate.name, owner: candidate.slot.owner, value: help });
199
227
  return Object.freeze({ matched: true, status: 'invalid', command: candidate.name, owner: candidate.slot.owner,
200
- issues: Object.freeze([{ field: candidate.parameter?.name ?? 'arguments', message: 'Missing or invalid command parameter' }]), help });
228
+ issues: Object.freeze([commandValidationIssue('invalidParameter', candidate.parameter?.name ?? 'arguments', locale)]), help });
201
229
  }
202
230
  if (!commandAdapterMatches(match.command, source))
203
231
  return Object.freeze({ matched: false, status: 'unmatched' });
@@ -205,18 +233,18 @@ export class CommandIndex {
205
233
  return this.#denied(match.command);
206
234
  const definition = match.command.slot.definition;
207
235
  const authorSource = bindOperationInput(source, match.command.slot.owner, operationSnapshot);
208
- const help = commandHelp(match.command.name, definition, commandPrefix);
236
+ const help = commandHelp(commandDisplayName(match.command, locale), definition, prefix, locale);
209
237
  if (match.helpRequested) {
210
238
  return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value: help });
211
239
  }
212
- const parsed = parseCommandArguments(definition, match.remaining, resolveDynamicParams(match.params, authorSource), resolveCommandSession(authorSource));
240
+ const parsed = parseCommandArguments(definition, match.remaining, resolveDynamicParams(match.params, authorSource), resolveCommandSession(authorSource), locale);
213
241
  if (parsed.issues.length > 0)
214
242
  return Object.freeze({ matched: true, status: 'invalid', command: match.command.name,
215
243
  owner: match.command.slot.owner, issues: parsed.issues, help });
216
244
  const limit = admitCommandExecution(authorSource, match.command.slot.id, match.command.slot.owner, match.command.execution, operationSnapshot);
217
245
  if (limit)
218
246
  return Object.freeze({ matched: true, status: 'limited', command: match.command.name, owner: match.command.slot.owner, limit });
219
- const value = await definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner, textArgs(parsed.remaining), parsed.params, authorSource, parsed.remaining, interactionFactory?.(authorSource), definition.adapter, parsed.options));
247
+ const value = await definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner, textArgs(parsed.remaining), parsed.params, authorSource, parsed.remaining, interactionFactory?.(authorSource), definition.adapter, parsed.options, { locale, formatCommand: (localPath, owner = match.command.owner) => this.formatCommand(owner, localPath, source && typeof source === 'object' && 'conversation' in source ? commandPrefix : undefined, locale) }));
220
248
  return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value });
221
249
  }
222
250
  #denied(record) {
@@ -334,16 +362,12 @@ export class CommandIndex {
334
362
  // ==========================================================================
335
363
  // 内置菜单命令
336
364
  // ==========================================================================
337
- #dispatchMenu(input, commandPrefix, snapshot) {
365
+ #dispatchMenu(input, commandPrefix, snapshot, locale, keyword) {
338
366
  const text = typeof input === 'string' ? input.trim() : exactMessageText(input);
339
367
  if (text === undefined)
340
368
  return undefined;
341
- const menu = this.#menu;
342
- if (!menu)
343
- return undefined;
344
- const keyword = menu.keyword;
345
369
  if (text === keyword)
346
- return this.#buildMenu(undefined, commandPrefix, keyword, snapshot);
370
+ return this.#buildMenu(undefined, commandPrefix, keyword, snapshot, locale);
347
371
  if (text.startsWith(keyword + ' ')) {
348
372
  const key = text.slice(keyword.length + 1).trim();
349
373
  const args = key.split(/\s+/u);
@@ -352,24 +376,24 @@ export class CommandIndex {
352
376
  const pageText = args[flag + 1] ?? '';
353
377
  if (flag > 1 || args.length !== flag + 2 || !/^[1-9]\d*$/u.test(pageText)
354
378
  || !Number.isSafeInteger(Number(pageText))) {
355
- return { value: `页码必须是正整数。用法:${commandPrefix}${keyword} [插件名] --page <页码>` };
379
+ return { value: commandMessages(locale, 'invalidPage', { command: `${commandPrefix}${keyword}` }) };
356
380
  }
357
- return this.#buildMenu(flag === 1 ? args[0] : undefined, commandPrefix, keyword, snapshot, Number(pageText));
381
+ return this.#buildMenu(flag === 1 ? args[0] : undefined, commandPrefix, keyword, snapshot, locale, Number(pageText));
358
382
  }
359
383
  if (key)
360
- return this.#buildMenu(key, commandPrefix, keyword, snapshot);
361
- return this.#buildMenu(undefined, commandPrefix, keyword, snapshot);
384
+ return this.#buildMenu(key, commandPrefix, keyword, snapshot, locale);
385
+ return this.#buildMenu(undefined, commandPrefix, keyword, snapshot, locale);
362
386
  }
363
387
  return undefined;
364
388
  }
365
- #buildMenu(pluginKey, commandPrefix, keyword, snapshot, page = 1) {
389
+ #buildMenu(pluginKey, commandPrefix, keyword, snapshot, locale, page = 1) {
366
390
  const root = snapshot.root;
367
391
  const targetId = pluginKey
368
392
  ? `${root}/${pluginKey.split('.').join('/')}`
369
393
  : root;
370
394
  const node = snapshot.tree.get(targetId);
371
395
  if (!node)
372
- return { value: `未找到插件: ${pluginKey}` };
396
+ return { value: commandMessages(locale, 'missingPlugin', { plugin: pluginKey ?? '' }) };
373
397
  const directCommands = this.#commands.filter((cmd) => cmd.slot.owner === targetId);
374
398
  const children = node.children
375
399
  .map((childId) => snapshot.tree.get(childId))
@@ -377,24 +401,25 @@ export class CommandIndex {
377
401
  const pages = Math.max(1, Math.ceil((directCommands.length + children.length) / MENU_PAGE_SIZE));
378
402
  const invocation = `${commandPrefix}${keyword}${pluginKey ? ` ${pluginKey}` : ''}`;
379
403
  if (page > pages)
380
- return { value: `页码超出范围:共 ${pages} 页。请使用「${invocation} --page <页码>」。` };
404
+ return { value: commandMessages(locale, 'pageOutOfRange', { pages, command: invocation }) };
381
405
  const offset = (page - 1) * MENU_PAGE_SIZE;
382
406
  const pageCommands = directCommands.slice(offset, offset + MENU_PAGE_SIZE);
383
407
  const pageChildren = children.slice(Math.max(0, offset - directCommands.length), Math.max(0, offset + MENU_PAGE_SIZE - directCommands.length));
384
408
  const displayKey = pluginKey ?? node.instanceKey;
385
- const lines = [`=== ${displayKey} 指令菜单 ===`];
409
+ const lines = [commandMessages(locale, 'menuTitle', { plugin: displayKey })];
386
410
  if (pages > 1)
387
- lines.push(`第 ${page}/${pages} 页 · ${directCommands.length} 条指令 · ${children.length} 个子插件`);
411
+ lines.push(commandMessages(locale, 'menuSummary', { page, pages, commands: directCommands.length, plugins: children.length }));
388
412
  if (pageCommands.length > 0) {
389
413
  lines.push('');
390
414
  for (const cmd of pageCommands) {
391
- const desc = cmd.description ? ` - ${cmd.description}` : '';
392
- lines.push(` ${commandPrefix}${cmd.name}${desc}`);
415
+ const description = commandDescription(cmd.slot.definition, locale);
416
+ const desc = description ? ` - ${description}` : '';
417
+ lines.push(` ${commandPrefix}${commandDisplayName(cmd, locale)}${desc}`);
393
418
  }
394
419
  }
395
420
  if (pageChildren.length > 0) {
396
421
  lines.push('');
397
- lines.push('子插件:');
422
+ lines.push(commandMessages(locale, 'childPlugins'));
398
423
  for (const child of pageChildren) {
399
424
  const childKey = pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey;
400
425
  const label = child.metadata?.displayName ?? child.instanceKey;
@@ -403,32 +428,51 @@ export class CommandIndex {
403
428
  }
404
429
  if (directCommands.length === 0 && children.length === 0) {
405
430
  lines.push('');
406
- lines.push('(暂无指令和子插件)');
431
+ lines.push(commandMessages(locale, 'emptyMenu'));
407
432
  }
408
433
  const hints = [];
409
434
  if (children.length > 0)
410
- hints.push(`提示:使用「${commandPrefix}${keyword} <插件名>」查看子插件的指令`);
435
+ hints.push(commandMessages(locale, 'pluginHint', { command: `${commandPrefix}${keyword}` }));
411
436
  if (page > 1)
412
- hints.push(`上一页:${invocation} --page ${page - 1}`);
437
+ hints.push(commandMessages(locale, 'previousPage', { command: `${invocation} --page ${page - 1}` }));
413
438
  if (page < pages)
414
- hints.push(`下一页:${invocation} --page ${page + 1}`);
439
+ hints.push(commandMessages(locale, 'nextPage', { command: `${invocation} --page ${page + 1}` }));
415
440
  const hint = hints.length ? hints.join('\n') : undefined;
416
441
  if (hint) {
417
442
  lines.push('');
418
443
  lines.push(hint);
419
444
  }
420
445
  const text = lines.join('\n');
421
- const menu = Object.freeze({ keyword, ...(pluginKey ? { pluginKey } : {}), page, pages });
422
- if (!snapshot.resources.get(root)?.has(htmlRendererToken.id))
423
- return { value: text, menu };
424
446
  const config = snapshot.config.get(root);
425
- return { menu, value: renderCommandMenu(displayKey, pageCommands.map(cmd => ({ name: `${commandPrefix}${cmd.name}`, description: cmd.description })), pageChildren.map(child => ({
447
+ const menu = Object.freeze({
448
+ keyword, ...(pluginKey ? { pluginKey } : {}), page, pages, locale, title: displayKey,
449
+ commands: Object.freeze(pageCommands.map(cmd => Object.freeze({
450
+ name: `${commandPrefix}${commandDisplayName(cmd, locale)}`, description: commandDescription(cmd.slot.definition, locale),
451
+ }))),
452
+ plugins: Object.freeze(pageChildren.map(child => Object.freeze({
426
453
  name: pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey,
427
454
  description: child.metadata?.displayName ?? child.instanceKey,
428
- })), hint, text, { page, pages, commandTotal: directCommands.length, pluginTotal: children.length,
429
- appearance: readMenuAppearance(config?.menuStyle) }) };
455
+ }))),
456
+ commandTotal: directCommands.length, pluginTotal: children.length, hint, text,
457
+ appearance: Object.freeze(readMenuAppearance(config?.menuStyle)),
458
+ });
459
+ if (!snapshot.resources.get(root)?.has(htmlRendererToken.id))
460
+ return { value: text, menu };
461
+ return { menu, value: renderCommandMenu(displayKey, menu.commands, menu.plugins, hint, text, {
462
+ page, pages, locale, commandTotal: menu.commandTotal, pluginTotal: menu.pluginTotal, appearance: menu.appearance,
463
+ }) };
430
464
  }
431
465
  }
466
+ function commandDisplaySegments(record, locale) {
467
+ return record.preferredSegments[locale] ?? record.segments;
468
+ }
469
+ function commandDisplayName(record, locale) {
470
+ return displayName(commandDisplaySegments(record, locale), record.parameter);
471
+ }
472
+ function commandLocale(source, snapshot) {
473
+ const locale = source && typeof source === 'object' && 'locale' in source ? source.locale : undefined;
474
+ return resolveLocale(locale ?? snapshot.config.get(snapshot.root)?.locale);
475
+ }
432
476
  export function isCommandIndex(value) {
433
477
  return !!value && typeof value === 'object'
434
478
  && value.$projection === 'zhin.command-index/1';
@@ -1,8 +1,9 @@
1
- interface MenuEntry {
1
+ import type { Locale } from '@zhin.js/plugin-runtime';
2
+ export interface CommandMenuEntry {
2
3
  readonly name: string;
3
4
  readonly description?: string;
4
5
  }
5
- interface MenuAppearance {
6
+ export interface CommandMenuAppearance {
6
7
  readonly title?: string;
7
8
  readonly subtitle?: string;
8
9
  readonly accentColor?: string;
@@ -19,13 +20,13 @@ export interface CommandMenuSegment {
19
20
  }
20
21
  export declare const MENU_PAGE_SIZE = 30;
21
22
  /** Configuration is text/data, never an interpolated CSS or HTML fragment. */
22
- export declare function readMenuAppearance(value: unknown): MenuAppearance;
23
+ export declare function readMenuAppearance(value: unknown): CommandMenuAppearance;
23
24
  /** Presentation only: the unified outbound pipeline owns image rendering and fallback. */
24
- export declare function renderCommandMenu(title: string, commands: readonly MenuEntry[], children: readonly MenuEntry[], hint: string | undefined, text: string, options?: {
25
+ export declare function renderCommandMenu(title: string, commands: readonly CommandMenuEntry[], children: readonly CommandMenuEntry[], hint: string | undefined, text: string, options?: {
25
26
  readonly page?: number;
26
27
  readonly pages?: number;
28
+ readonly locale?: Locale;
27
29
  readonly commandTotal?: number;
28
30
  readonly pluginTotal?: number;
29
- readonly appearance?: MenuAppearance;
31
+ readonly appearance?: CommandMenuAppearance;
30
32
  }): CommandMenuSegment;
31
- export {};