@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 +22 -2
- package/lib/arguments.d.ts +8 -2
- package/lib/arguments.js +40 -27
- package/lib/command-index.d.ts +33 -5
- package/lib/command-index.js +95 -43
- package/lib/command-menu.d.ts +7 -6
- package/lib/command-menu.js +7 -5
- package/lib/definition.d.ts +58 -6
- package/lib/definition.js +56 -3
- package/lib/index.d.ts +1 -1
- package/lib/messages.d.ts +8 -0
- package/lib/messages.js +57 -0
- package/lib/permission-explanation.d.ts +2 -0
- package/lib/permission-explanation.js +15 -12
- package/lib/provider.js +1 -1
- package/package.json +4 -4
- package/src/arguments.ts +54 -26
- package/src/command-index.ts +139 -54
- package/src/command-menu.ts +17 -13
- package/src/definition.ts +113 -20
- package/src/index.ts +1 -1
- package/src/messages.ts +62 -0
- package/src/permission-explanation.ts +16 -13
- package/src/provider.ts +1 -1
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
|
|
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
|
|
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
|
|
171
|
-
if (schema.max !== undefined && (size === undefined || size > schema.max)) return
|
|
172
|
-
if (schema.choices && !schema.choices.includes(item)) return
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/command-index.ts
CHANGED
|
@@ -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 {
|
|
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?:
|
|
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?:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
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:
|
|
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
|
|
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([
|
|
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
|
|
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(
|
|
337
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
528
|
+
return { value: commandMessages(locale, 'invalidPage', { command: `${commandPrefix}${keyword}` }) };
|
|
465
529
|
}
|
|
466
|
-
return this.#buildMenu(flag === 1 ? args[0] : undefined,
|
|
530
|
+
return this.#buildMenu({ pluginKey: flag === 1 ? args[0] : undefined, page: Number(pageText) }, context);
|
|
467
531
|
}
|
|
468
|
-
if (key) return this.#buildMenu(key
|
|
469
|
-
return this.#buildMenu(
|
|
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
|
|
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:
|
|
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:
|
|
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[] = [
|
|
497
|
-
if (pages > 1) lines.push(
|
|
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
|
|
503
|
-
|
|
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(
|
|
524
|
-
if (page > 1) hints.push(
|
|
525
|
-
if (page < pages) hints.push(
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
})),
|
|
542
|
-
|
|
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';
|