@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/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
 
@@ -113,7 +117,7 @@ export default defineCommand({
113
117
  后面的内容作为参数。未知/重复选项拒绝。`remind --help` 从同一声明生成帮助,包含
114
118
  Endpoint 前缀及显式插件命名空间,不执行命令;同一权限声明仍约束帮助读取。
115
119
 
116
- `dispatch()` 返回 `status`:`unmatched`(继续普通消息处理)、`invalid`(`issues` +
120
+ `dispatch({ input, source, snapshot })` 返回 `status`:`unmatched`(继续普通消息处理)、`invalid`(`issues` +
117
121
  `help`)、`denied`(权限拒绝)、`limited`(执行预算拒绝)、`executed`。后四者 `matched: true`,只有 `executed`
118
122
  包含业务结果。可靠静态路径匹配后才提示错误;纯动态路由的模糊聊天保持未匹配。
119
123
  受限命令没有可鉴权主体时拒绝,Host 可通过 `dispatch` 提供真实 `PermissionSubject`;
@@ -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';
@@ -23,6 +25,15 @@ export interface CommandDescriptor {
23
25
  /** shortcut 触发键列表(不含预填 params)。 */
24
26
  readonly shortcut?: readonly string[];
25
27
  }
28
+ /** One dispatch reads the caller's exact operation generation and presentation. */
29
+ export interface CommandDispatchRequest {
30
+ readonly input: CommandMatchInput;
31
+ readonly source?: unknown;
32
+ readonly interactionFactory?: UserInteractionFactory;
33
+ readonly commandPrefix?: string;
34
+ readonly snapshot?: RuntimeSnapshot;
35
+ readonly locale?: Locale;
36
+ }
26
37
  export interface CommandDispatchResult {
27
38
  readonly matched: boolean;
28
39
  readonly status: 'unmatched' | 'invalid' | 'denied' | 'limited' | 'executed';
@@ -32,7 +43,7 @@ export interface CommandDispatchResult {
32
43
  readonly command?: string;
33
44
  readonly owner?: PluginId;
34
45
  readonly value?: unknown;
35
- readonly menu?: CommandMenuNavigation;
46
+ readonly menu?: CommandMenuPage;
36
47
  }
37
48
  /** Semantic menu navigation; the IM composition layer supplies platform controls. */
38
49
  export interface CommandMenuNavigation {
@@ -41,19 +52,36 @@ export interface CommandMenuNavigation {
41
52
  readonly page: number;
42
53
  readonly pages: number;
43
54
  }
55
+ /** Immutable page data; presentation cannot change the catalog or execute commands. */
56
+ export interface CommandMenuPage extends CommandMenuNavigation {
57
+ readonly locale: Locale;
58
+ readonly title: string;
59
+ readonly commands: readonly CommandMenuEntry[];
60
+ readonly plugins: readonly CommandMenuEntry[];
61
+ readonly commandTotal: number;
62
+ readonly pluginTotal: number;
63
+ readonly hint?: string;
64
+ readonly text: string;
65
+ readonly appearance: CommandMenuAppearance;
66
+ }
44
67
  export type CommandMatchInput = string | readonly Readonly<CommandSegment>[];
45
68
  export interface CommandMenuConfig {
46
- readonly keyword: string;
69
+ /** Omission selects the built-in keyword for each operation's language. */
70
+ readonly keyword?: string;
47
71
  }
48
72
  export declare class CommandIndex {
49
73
  #private;
50
74
  private readonly snapshot;
51
75
  readonly $projection: "zhin.command-index/1";
52
76
  constructor(slots: readonly Readonly<CapabilitySlot<CommandDefinition>>[], snapshot: RuntimeSnapshot, menu?: CommandMenuConfig);
53
- list(): readonly CommandDescriptor[];
77
+ list(locale?: Locale): readonly CommandDescriptor[];
78
+ /** Resolve a local static route without executing parameters, permission checks or commands. */
79
+ formatCommand(owner: PluginId, localPath: string, prefix: string | undefined, locale?: Locale): string | undefined;
80
+ /** Return the configured menu entry only when the calling environment is known. */
81
+ formatMenu(prefix: string | undefined, locale: Locale): string | undefined;
54
82
  has(name: string): boolean;
55
83
  execute(name: string, args?: readonly string[], operationSnapshot?: RuntimeSnapshot): Promise<unknown>;
56
- dispatch(input: CommandMatchInput, source?: unknown, interactionFactory?: UserInteractionFactory, commandPrefix?: string, operationSnapshot?: RuntimeSnapshot): Promise<CommandDispatchResult>;
84
+ dispatch({ input, source, interactionFactory, commandPrefix, snapshot: operationSnapshot, locale: localeOverride }: CommandDispatchRequest): Promise<CommandDispatchResult>;
57
85
  }
58
86
  export declare function isCommandIndex(value: unknown): value is CommandIndex;
59
87
  /**
@@ -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,34 @@ 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({ snapshot: operationSnapshot, ownerId: match.command.slot.owner,
190
+ adapter: definition.adapter, presentation: { locale } }, {
191
+ args: textArgs(parsed.remaining), params: parsed.params, segments: parsed.remaining, options: parsed.options,
192
+ }));
165
193
  }
166
194
  finally {
167
195
  admission.release();
168
196
  }
169
197
  }
170
- async dispatch(input, source = undefined, interactionFactory, commandPrefix = '', operationSnapshot = this.snapshot) {
198
+ async dispatch({ input, source, interactionFactory, commandPrefix, snapshot: operationSnapshot = this.snapshot, locale: localeOverride }) {
199
+ const locale = localeOverride ?? commandLocale(source, operationSnapshot);
200
+ const prefix = commandPrefix ?? '';
171
201
  if (this.#menu) {
172
- const menuResult = this.#dispatchMenu(input, commandPrefix, operationSnapshot);
202
+ const keyword = this.#menu.keyword ?? commandMessages(locale, 'menuKeyword');
203
+ const menuResult = this.#dispatchMenu(input, { commandPrefix: prefix, snapshot: operationSnapshot, locale, keyword });
173
204
  if (menuResult !== undefined) {
174
205
  return Object.freeze({
175
206
  matched: true,
176
207
  status: 'executed',
177
- command: this.#menu.keyword,
208
+ command: keyword,
178
209
  owner: operationSnapshot.root,
179
210
  ...menuResult,
180
211
  });
@@ -192,12 +223,12 @@ export class CommandIndex {
192
223
  return Object.freeze({ matched: false, status: 'unmatched' });
193
224
  if (!(await this.#permitAllows(candidate, source, operationSnapshot)))
194
225
  return this.#denied(candidate);
195
- const help = commandHelp(candidate.name, candidate.slot.definition, commandPrefix);
226
+ const help = commandHelp(commandDisplayName(candidate, locale), candidate.slot.definition, prefix, locale);
196
227
  const segments = normalizeSegments(typeof input === 'string' ? [{ type: 'text', data: { text: input } }] : input);
197
228
  if (requestsCommandHelp(segments))
198
229
  return Object.freeze({ matched: true, status: 'executed', command: candidate.name, owner: candidate.slot.owner, value: help });
199
230
  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 });
231
+ issues: Object.freeze([commandValidationIssue('invalidParameter', candidate.parameter?.name ?? 'arguments', locale)]), help });
201
232
  }
202
233
  if (!commandAdapterMatches(match.command, source))
203
234
  return Object.freeze({ matched: false, status: 'unmatched' });
@@ -205,18 +236,22 @@ export class CommandIndex {
205
236
  return this.#denied(match.command);
206
237
  const definition = match.command.slot.definition;
207
238
  const authorSource = bindOperationInput(source, match.command.slot.owner, operationSnapshot);
208
- const help = commandHelp(match.command.name, definition, commandPrefix);
239
+ const help = commandHelp(commandDisplayName(match.command, locale), definition, prefix, locale);
209
240
  if (match.helpRequested) {
210
241
  return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value: help });
211
242
  }
212
- const parsed = parseCommandArguments(definition, match.remaining, resolveDynamicParams(match.params, authorSource), resolveCommandSession(authorSource));
243
+ const parsed = parseCommandArguments(definition, match.remaining, resolveDynamicParams(match.params, authorSource), resolveCommandSession(authorSource), locale);
213
244
  if (parsed.issues.length > 0)
214
245
  return Object.freeze({ matched: true, status: 'invalid', command: match.command.name,
215
246
  owner: match.command.slot.owner, issues: parsed.issues, help });
216
247
  const limit = admitCommandExecution(authorSource, match.command.slot.id, match.command.slot.owner, match.command.execution, operationSnapshot);
217
248
  if (limit)
218
249
  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));
250
+ const value = await definition.execute(createCommandContext({
251
+ snapshot: operationSnapshot, ownerId: match.command.slot.owner, input: authorSource,
252
+ interaction: interactionFactory?.(authorSource), adapter: definition.adapter,
253
+ presentation: { locale, formatCommand: (localPath, owner = match.command.owner) => this.formatCommand(owner, localPath, source && typeof source === 'object' && 'conversation' in source ? commandPrefix : undefined, locale) },
254
+ }, { args: textArgs(parsed.remaining), params: parsed.params, segments: parsed.remaining, options: parsed.options }));
220
255
  return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value });
221
256
  }
222
257
  #denied(record) {
@@ -334,16 +369,13 @@ export class CommandIndex {
334
369
  // ==========================================================================
335
370
  // 内置菜单命令
336
371
  // ==========================================================================
337
- #dispatchMenu(input, commandPrefix, snapshot) {
372
+ #dispatchMenu(input, context) {
373
+ const { commandPrefix, keyword, locale } = context;
338
374
  const text = typeof input === 'string' ? input.trim() : exactMessageText(input);
339
375
  if (text === undefined)
340
376
  return undefined;
341
- const menu = this.#menu;
342
- if (!menu)
343
- return undefined;
344
- const keyword = menu.keyword;
345
377
  if (text === keyword)
346
- return this.#buildMenu(undefined, commandPrefix, keyword, snapshot);
378
+ return this.#buildMenu({}, context);
347
379
  if (text.startsWith(keyword + ' ')) {
348
380
  const key = text.slice(keyword.length + 1).trim();
349
381
  const args = key.split(/\s+/u);
@@ -352,24 +384,24 @@ export class CommandIndex {
352
384
  const pageText = args[flag + 1] ?? '';
353
385
  if (flag > 1 || args.length !== flag + 2 || !/^[1-9]\d*$/u.test(pageText)
354
386
  || !Number.isSafeInteger(Number(pageText))) {
355
- return { value: `页码必须是正整数。用法:${commandPrefix}${keyword} [插件名] --page <页码>` };
387
+ return { value: commandMessages(locale, 'invalidPage', { command: `${commandPrefix}${keyword}` }) };
356
388
  }
357
- return this.#buildMenu(flag === 1 ? args[0] : undefined, commandPrefix, keyword, snapshot, Number(pageText));
389
+ return this.#buildMenu({ pluginKey: flag === 1 ? args[0] : undefined, page: Number(pageText) }, context);
358
390
  }
359
391
  if (key)
360
- return this.#buildMenu(key, commandPrefix, keyword, snapshot);
361
- return this.#buildMenu(undefined, commandPrefix, keyword, snapshot);
392
+ return this.#buildMenu({ pluginKey: key }, context);
393
+ return this.#buildMenu({}, context);
362
394
  }
363
395
  return undefined;
364
396
  }
365
- #buildMenu(pluginKey, commandPrefix, keyword, snapshot, page = 1) {
397
+ #buildMenu({ pluginKey, page = 1 }, { commandPrefix, keyword, snapshot, locale }) {
366
398
  const root = snapshot.root;
367
399
  const targetId = pluginKey
368
400
  ? `${root}/${pluginKey.split('.').join('/')}`
369
401
  : root;
370
402
  const node = snapshot.tree.get(targetId);
371
403
  if (!node)
372
- return { value: `未找到插件: ${pluginKey}` };
404
+ return { value: commandMessages(locale, 'missingPlugin', { plugin: pluginKey ?? '' }) };
373
405
  const directCommands = this.#commands.filter((cmd) => cmd.slot.owner === targetId);
374
406
  const children = node.children
375
407
  .map((childId) => snapshot.tree.get(childId))
@@ -377,24 +409,25 @@ export class CommandIndex {
377
409
  const pages = Math.max(1, Math.ceil((directCommands.length + children.length) / MENU_PAGE_SIZE));
378
410
  const invocation = `${commandPrefix}${keyword}${pluginKey ? ` ${pluginKey}` : ''}`;
379
411
  if (page > pages)
380
- return { value: `页码超出范围:共 ${pages} 页。请使用「${invocation} --page <页码>」。` };
412
+ return { value: commandMessages(locale, 'pageOutOfRange', { pages, command: invocation }) };
381
413
  const offset = (page - 1) * MENU_PAGE_SIZE;
382
414
  const pageCommands = directCommands.slice(offset, offset + MENU_PAGE_SIZE);
383
415
  const pageChildren = children.slice(Math.max(0, offset - directCommands.length), Math.max(0, offset + MENU_PAGE_SIZE - directCommands.length));
384
416
  const displayKey = pluginKey ?? node.instanceKey;
385
- const lines = [`=== ${displayKey} 指令菜单 ===`];
417
+ const lines = [commandMessages(locale, 'menuTitle', { plugin: displayKey })];
386
418
  if (pages > 1)
387
- lines.push(`第 ${page}/${pages} 页 · ${directCommands.length} 条指令 · ${children.length} 个子插件`);
419
+ lines.push(commandMessages(locale, 'menuSummary', { page, pages, commands: directCommands.length, plugins: children.length }));
388
420
  if (pageCommands.length > 0) {
389
421
  lines.push('');
390
422
  for (const cmd of pageCommands) {
391
- const desc = cmd.description ? ` - ${cmd.description}` : '';
392
- lines.push(` ${commandPrefix}${cmd.name}${desc}`);
423
+ const description = commandDescription(cmd.slot.definition, locale);
424
+ const desc = description ? ` - ${description}` : '';
425
+ lines.push(` ${commandPrefix}${commandDisplayName(cmd, locale)}${desc}`);
393
426
  }
394
427
  }
395
428
  if (pageChildren.length > 0) {
396
429
  lines.push('');
397
- lines.push('子插件:');
430
+ lines.push(commandMessages(locale, 'childPlugins'));
398
431
  for (const child of pageChildren) {
399
432
  const childKey = pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey;
400
433
  const label = child.metadata?.displayName ?? child.instanceKey;
@@ -403,32 +436,51 @@ export class CommandIndex {
403
436
  }
404
437
  if (directCommands.length === 0 && children.length === 0) {
405
438
  lines.push('');
406
- lines.push('(暂无指令和子插件)');
439
+ lines.push(commandMessages(locale, 'emptyMenu'));
407
440
  }
408
441
  const hints = [];
409
442
  if (children.length > 0)
410
- hints.push(`提示:使用「${commandPrefix}${keyword} <插件名>」查看子插件的指令`);
443
+ hints.push(commandMessages(locale, 'pluginHint', { command: `${commandPrefix}${keyword}` }));
411
444
  if (page > 1)
412
- hints.push(`上一页:${invocation} --page ${page - 1}`);
445
+ hints.push(commandMessages(locale, 'previousPage', { command: `${invocation} --page ${page - 1}` }));
413
446
  if (page < pages)
414
- hints.push(`下一页:${invocation} --page ${page + 1}`);
447
+ hints.push(commandMessages(locale, 'nextPage', { command: `${invocation} --page ${page + 1}` }));
415
448
  const hint = hints.length ? hints.join('\n') : undefined;
416
449
  if (hint) {
417
450
  lines.push('');
418
451
  lines.push(hint);
419
452
  }
420
453
  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
454
  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 => ({
455
+ const menu = Object.freeze({
456
+ keyword, ...(pluginKey ? { pluginKey } : {}), page, pages, locale, title: displayKey,
457
+ commands: Object.freeze(pageCommands.map(cmd => Object.freeze({
458
+ name: `${commandPrefix}${commandDisplayName(cmd, locale)}`, description: commandDescription(cmd.slot.definition, locale),
459
+ }))),
460
+ plugins: Object.freeze(pageChildren.map(child => Object.freeze({
426
461
  name: pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey,
427
462
  description: child.metadata?.displayName ?? child.instanceKey,
428
- })), hint, text, { page, pages, commandTotal: directCommands.length, pluginTotal: children.length,
429
- appearance: readMenuAppearance(config?.menuStyle) }) };
463
+ }))),
464
+ commandTotal: directCommands.length, pluginTotal: children.length, hint, text,
465
+ appearance: Object.freeze(readMenuAppearance(config?.menuStyle)),
466
+ });
467
+ if (!snapshot.resources.get(root)?.has(htmlRendererToken.id))
468
+ return { value: text, menu };
469
+ return { menu, value: renderCommandMenu(displayKey, menu.commands, menu.plugins, hint, text, {
470
+ page, pages, locale, commandTotal: menu.commandTotal, pluginTotal: menu.pluginTotal, appearance: menu.appearance,
471
+ }) };
430
472
  }
431
473
  }
474
+ function commandDisplaySegments(record, locale) {
475
+ return record.preferredSegments[locale] ?? record.segments;
476
+ }
477
+ function commandDisplayName(record, locale) {
478
+ return displayName(commandDisplaySegments(record, locale), record.parameter);
479
+ }
480
+ function commandLocale(source, snapshot) {
481
+ const locale = source && typeof source === 'object' && 'locale' in source ? source.locale : undefined;
482
+ return resolveLocale(locale ?? snapshot.config.get(snapshot.root)?.locale);
483
+ }
432
484
  export function isCommandIndex(value) {
433
485
  return !!value && typeof value === 'object'
434
486
  && value.$projection === 'zhin.command-index/1';