@zhin.js/command 1.1.4 → 1.1.6
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 +48 -3
- package/lib/arguments.d.ts +19 -0
- package/lib/arguments.js +321 -0
- package/lib/command-index.d.ts +11 -0
- package/lib/command-index.js +185 -70
- package/lib/definition.d.ts +56 -6
- package/lib/definition.js +7 -2
- package/lib/index.d.ts +1 -0
- package/lib/provider.js +7 -0
- package/package.json +5 -5
- package/src/arguments.ts +280 -0
- package/src/command-index.ts +167 -102
- package/src/definition.ts +61 -7
- package/src/index.ts +2 -0
- package/src/provider.ts +7 -0
package/src/command-index.ts
CHANGED
|
@@ -9,12 +9,13 @@ import type {
|
|
|
9
9
|
RuntimeSnapshot,
|
|
10
10
|
} from '@zhin.js/plugin-runtime';
|
|
11
11
|
import type { UserInteractionFactory } from '@zhin.js/interaction';
|
|
12
|
-
import { operationClientAdapter } from '@zhin.js/feature-kit';
|
|
12
|
+
import { bindOperationInput, operationClientAdapter } from '@zhin.js/feature-kit';
|
|
13
13
|
import {
|
|
14
14
|
permissionHostToken,
|
|
15
15
|
toPermissionSubject,
|
|
16
16
|
type PermissionHost,
|
|
17
17
|
} from '@zhin.js/permission';
|
|
18
|
+
import { commandUsage, commandHelp, parseCommandArguments, requestsCommandHelp, type CommandValidationIssue } from './arguments.js';
|
|
18
19
|
import {
|
|
19
20
|
createCommandContext,
|
|
20
21
|
resolveCommandSession,
|
|
@@ -36,6 +37,8 @@ export interface CommandDescriptor {
|
|
|
36
37
|
readonly adapter?: string;
|
|
37
38
|
readonly source: string;
|
|
38
39
|
readonly parameters: readonly CommandParameterDescriptor[];
|
|
40
|
+
readonly usage: string;
|
|
41
|
+
readonly options: NonNullable<CommandDefinition['options']>;
|
|
39
42
|
readonly alias?: readonly string[];
|
|
40
43
|
readonly permit?: readonly string[];
|
|
41
44
|
/** shortcut 触发键列表(不含预填 params)。 */
|
|
@@ -44,6 +47,9 @@ export interface CommandDescriptor {
|
|
|
44
47
|
|
|
45
48
|
export interface CommandDispatchResult {
|
|
46
49
|
readonly matched: boolean;
|
|
50
|
+
readonly status: 'unmatched' | 'invalid' | 'denied' | 'executed';
|
|
51
|
+
readonly issues?: readonly CommandValidationIssue[];
|
|
52
|
+
readonly help?: string;
|
|
47
53
|
readonly command?: string;
|
|
48
54
|
readonly owner?: PluginId;
|
|
49
55
|
readonly value?: unknown;
|
|
@@ -59,6 +65,7 @@ interface CommandRoute {
|
|
|
59
65
|
readonly record: CommandRecord;
|
|
60
66
|
readonly segments: readonly string[];
|
|
61
67
|
readonly matcher: SegmentMatcher;
|
|
68
|
+
readonly literalMatcher?: SegmentMatcher;
|
|
62
69
|
readonly kind: 'primary' | 'alias';
|
|
63
70
|
}
|
|
64
71
|
|
|
@@ -71,6 +78,7 @@ interface CommandMatch {
|
|
|
71
78
|
readonly command: CommandRecord;
|
|
72
79
|
readonly params: Readonly<Record<string, CommandDynamicValue>>;
|
|
73
80
|
readonly remaining: readonly Readonly<CommandSegment>[];
|
|
81
|
+
readonly helpRequested: boolean;
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
export type CommandMatchInput = string | readonly Readonly<CommandSegment>[];
|
|
@@ -110,12 +118,15 @@ export class CommandIndex {
|
|
|
110
118
|
const occupancy = new Map<string, string>();
|
|
111
119
|
const shortcuts = new Map<string, ShortcutEntry>();
|
|
112
120
|
|
|
113
|
-
const claim = (
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
121
|
+
const claim = (keys: readonly string[], source: string): void => {
|
|
122
|
+
for (const key of keys) {
|
|
123
|
+
for (const [occupied, existing] of occupancy) {
|
|
124
|
+
if (routeShapesOverlap(key, occupied)) {
|
|
125
|
+
throw new Error(`Duplicate Command route "${key}" (${source} vs ${existing})`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
117
128
|
}
|
|
118
|
-
occupancy.set(key, source);
|
|
129
|
+
for (const key of keys) occupancy.set(key, source);
|
|
119
130
|
};
|
|
120
131
|
|
|
121
132
|
for (const slot of slots) {
|
|
@@ -137,10 +148,16 @@ export class CommandIndex {
|
|
|
137
148
|
description: slot.definition.description,
|
|
138
149
|
...(slot.definition.adapter ? { adapter: slot.definition.adapter } : {}),
|
|
139
150
|
source: slot.source,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
151
|
+
usage: commandUsage(name, slot.definition),
|
|
152
|
+
options: Object.freeze({ ...slot.definition.options }),
|
|
153
|
+
parameters: Object.freeze([
|
|
154
|
+
...Object.entries(slot.definition.params ?? {}).map(([key, schema]) => ({
|
|
155
|
+
name: key, ...schema, ...(parameter?.name === key ? parameter : {}), defaultValue: schema.default,
|
|
156
|
+
required: parameter?.name === key ? isRequiredParameter(parameter) : !schema.optional && schema.default === undefined,
|
|
157
|
+
})),
|
|
158
|
+
...(parameter && !Object.hasOwn(slot.definition.params ?? {}, parameter.name)
|
|
159
|
+
? [{ ...parameter, required: isRequiredParameter(parameter) }] : []),
|
|
160
|
+
]),
|
|
144
161
|
...(alias ? { alias } : {}),
|
|
145
162
|
...(permit ? { permit } : {}),
|
|
146
163
|
...(shortcutKeys && shortcutKeys.length > 0 ? { shortcut: shortcutKeys } : {}),
|
|
@@ -149,11 +166,12 @@ export class CommandIndex {
|
|
|
149
166
|
parameter,
|
|
150
167
|
});
|
|
151
168
|
|
|
152
|
-
claim(occupancyKey(primarySegments, parameter), slot.source);
|
|
169
|
+
claim([...new Set([occupancyKey(primarySegments, parameter), ...declaredRouteShapes(primarySegments, slot.definition)])], slot.source);
|
|
153
170
|
routes.push({
|
|
154
171
|
record,
|
|
155
172
|
segments: primarySegments,
|
|
156
173
|
matcher: new SegmentMatcher(matcherPattern(primarySegments, parameter), segmentFields),
|
|
174
|
+
literalMatcher: compileLiteralMatcher(primarySegments, parameter),
|
|
157
175
|
kind: 'primary',
|
|
158
176
|
});
|
|
159
177
|
|
|
@@ -164,11 +182,12 @@ export class CommandIndex {
|
|
|
164
182
|
primarySegments.slice(namespace.length),
|
|
165
183
|
)];
|
|
166
184
|
assertParameterSegment(aliasSegments, parameter, `${slot.source} alias ${JSON.stringify(entry)}`);
|
|
167
|
-
claim(occupancyKey(aliasSegments, parameter), `${slot.source} alias ${JSON.stringify(entry)}`);
|
|
185
|
+
claim([...new Set([occupancyKey(aliasSegments, parameter), ...declaredRouteShapes(aliasSegments, slot.definition)])], `${slot.source} alias ${JSON.stringify(entry)}`);
|
|
168
186
|
routes.push({
|
|
169
187
|
record,
|
|
170
188
|
segments: Object.freeze(aliasSegments),
|
|
171
189
|
matcher: new SegmentMatcher(matcherPattern(aliasSegments, parameter), segmentFields),
|
|
190
|
+
literalMatcher: compileLiteralMatcher(aliasSegments, parameter),
|
|
172
191
|
kind: 'alias',
|
|
173
192
|
});
|
|
174
193
|
}
|
|
@@ -177,7 +196,7 @@ export class CommandIndex {
|
|
|
177
196
|
if (slot.definition.shortcut) {
|
|
178
197
|
for (const [rawTrigger, prefill] of Object.entries(slot.definition.shortcut)) {
|
|
179
198
|
const trigger = [...namespace, ...splitCommand(rawTrigger)].join(' ');
|
|
180
|
-
claim(trigger, `${slot.source} shortcut ${JSON.stringify(trigger)}`);
|
|
199
|
+
claim([trigger], `${slot.source} shortcut ${JSON.stringify(trigger)}`);
|
|
181
200
|
shortcuts.set(trigger, {
|
|
182
201
|
record,
|
|
183
202
|
params: Object.freeze(resolveShortcutParams(
|
|
@@ -211,24 +230,18 @@ export class CommandIndex {
|
|
|
211
230
|
}
|
|
212
231
|
|
|
213
232
|
async execute(name: string, args: readonly string[] = [], operationSnapshot: RuntimeSnapshot = this.snapshot): Promise<unknown> {
|
|
214
|
-
const match = this.#match(name,
|
|
233
|
+
const match = this.#match(name, false);
|
|
215
234
|
if (!match) {
|
|
216
235
|
this.#diagnoseParameter(name);
|
|
217
236
|
throw new Error(`Unknown Command: ${name}`);
|
|
218
237
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
undefined,
|
|
227
|
-
Object.freeze([]),
|
|
228
|
-
undefined,
|
|
229
|
-
match.command.slot.definition.adapter,
|
|
230
|
-
),
|
|
231
|
-
);
|
|
238
|
+
if (!(await this.#permitAllows(match.command, undefined, operationSnapshot))) throw new Error(`Permission denied for Command: ${match.command.name}`);
|
|
239
|
+
const definition = match.command.slot.definition;
|
|
240
|
+
const supplied = [...match.remaining, ...(args.length > 0 ? [{ type: 'text', data: { text: args.join(' ') } }] : [])];
|
|
241
|
+
const parsed = parseCommandArguments(definition, supplied, resolveDynamicParams(match.params, undefined), {});
|
|
242
|
+
if (parsed.issues.length > 0) throw new CommandArgumentValidationError(parsed.issues, commandHelp(match.command.name, definition));
|
|
243
|
+
return definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner,
|
|
244
|
+
textArgs(parsed.remaining), parsed.params, undefined, parsed.remaining, undefined, definition.adapter, parsed.options));
|
|
232
245
|
}
|
|
233
246
|
|
|
234
247
|
async dispatch(
|
|
@@ -243,77 +256,69 @@ export class CommandIndex {
|
|
|
243
256
|
if (menuValue !== undefined) {
|
|
244
257
|
return Object.freeze({
|
|
245
258
|
matched: true,
|
|
259
|
+
status: 'executed',
|
|
246
260
|
command: this.#menu.keyword,
|
|
247
261
|
owner: operationSnapshot.root,
|
|
248
262
|
value: menuValue,
|
|
249
263
|
});
|
|
250
264
|
}
|
|
251
265
|
}
|
|
252
|
-
const interaction = interactionFactory?.(source);
|
|
253
266
|
const shortcut = this.#matchShortcut(input);
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
source,
|
|
268
|
-
Object.freeze([]),
|
|
269
|
-
interaction,
|
|
270
|
-
shortcut.record.slot.definition.adapter,
|
|
271
|
-
),
|
|
272
|
-
);
|
|
273
|
-
return Object.freeze({
|
|
274
|
-
matched: true,
|
|
275
|
-
command: shortcut.record.name,
|
|
276
|
-
owner: shortcut.record.slot.owner,
|
|
277
|
-
value,
|
|
278
|
-
});
|
|
267
|
+
const match = shortcut
|
|
268
|
+
? { command: shortcut.record, params: shortcut.params, remaining: Object.freeze([]), helpRequested: false }
|
|
269
|
+
: this.#match(input, false);
|
|
270
|
+
if (!match) {
|
|
271
|
+
const candidate = this.#invalidRoute(input);
|
|
272
|
+
if (!candidate) return Object.freeze({ matched: false, status: 'unmatched' });
|
|
273
|
+
if (!commandAdapterMatches(candidate, source)) return Object.freeze({ matched: false, status: 'unmatched' });
|
|
274
|
+
if (!(await this.#permitAllows(candidate, source, operationSnapshot))) return this.#denied(candidate);
|
|
275
|
+
const help = commandHelp(candidate.name, candidate.slot.definition, commandPrefix);
|
|
276
|
+
const segments = normalizeSegments(typeof input === 'string' ? [{ type: 'text', data: { text: input } }] : input);
|
|
277
|
+
if (requestsCommandHelp(segments)) return Object.freeze({ matched: true, status: 'executed', command: candidate.name, owner: candidate.slot.owner, value: help });
|
|
278
|
+
return Object.freeze({ matched: true, status: 'invalid', command: candidate.name, owner: candidate.slot.owner,
|
|
279
|
+
issues: Object.freeze([{ field: candidate.parameter?.name ?? 'arguments', message: 'Missing or invalid command parameter' }]), help });
|
|
279
280
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
281
|
+
if (!commandAdapterMatches(match.command, source)) return Object.freeze({ matched: false, status: 'unmatched' });
|
|
282
|
+
if (!(await this.#permitAllows(match.command, source, operationSnapshot))) return this.#denied(match.command);
|
|
283
|
+
const definition = match.command.slot.definition;
|
|
284
|
+
const authorSource = bindOperationInput(source, match.command.slot.owner, operationSnapshot);
|
|
285
|
+
const help = commandHelp(match.command.name, definition, commandPrefix);
|
|
286
|
+
if (match.helpRequested) {
|
|
287
|
+
return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value: help });
|
|
285
288
|
}
|
|
286
|
-
|
|
287
|
-
|
|
289
|
+
const parsed = parseCommandArguments(definition, match.remaining, resolveDynamicParams(match.params, authorSource), resolveCommandSession(authorSource));
|
|
290
|
+
if (parsed.issues.length > 0) return Object.freeze({ matched: true, status: 'invalid', command: match.command.name,
|
|
291
|
+
owner: match.command.slot.owner, issues: parsed.issues, help });
|
|
292
|
+
const value = await definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner,
|
|
293
|
+
textArgs(parsed.remaining), parsed.params, authorSource, parsed.remaining, interactionFactory?.(authorSource), definition.adapter, parsed.options));
|
|
294
|
+
return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
#denied(record: CommandRecord): CommandDispatchResult {
|
|
298
|
+
return Object.freeze({ matched: true, status: 'denied', command: record.name, owner: record.slot.owner });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Diagnose only a reliable static route prefix; fully dynamic routes are ordinary chat candidates. */
|
|
302
|
+
#invalidRoute(input: CommandMatchInput): CommandRecord | undefined {
|
|
303
|
+
const segments = normalizeSegments(typeof input === 'string' ? [{ type: 'text', data: { text: input } }] : input);
|
|
304
|
+
for (const route of this.#routes) {
|
|
305
|
+
if (!route.record.parameter) continue;
|
|
306
|
+
const result = route.literalMatcher?.match(asMatcherSegments(segments));
|
|
307
|
+
if (result && hasCommandBoundary(result.remaining)) return route.record;
|
|
288
308
|
}
|
|
289
|
-
|
|
290
|
-
const value = await match.command.slot.definition.execute(
|
|
291
|
-
createCommandContext(
|
|
292
|
-
operationSnapshot,
|
|
293
|
-
match.command.slot.owner,
|
|
294
|
-
args,
|
|
295
|
-
resolveDynamicParams(match.params, source),
|
|
296
|
-
source,
|
|
297
|
-
match.remaining,
|
|
298
|
-
interaction,
|
|
299
|
-
match.command.slot.definition.adapter,
|
|
300
|
-
),
|
|
301
|
-
);
|
|
302
|
-
return Object.freeze({
|
|
303
|
-
matched: true,
|
|
304
|
-
command: match.command.name,
|
|
305
|
-
owner: match.command.slot.owner,
|
|
306
|
-
value,
|
|
307
|
-
});
|
|
309
|
+
return undefined;
|
|
308
310
|
}
|
|
309
311
|
|
|
310
312
|
async #permitAllows(record: CommandRecord, source: unknown, operationSnapshot: RuntimeSnapshot): Promise<boolean> {
|
|
311
313
|
const permits = record.permit;
|
|
312
314
|
if (!permits || permits.length === 0) return true;
|
|
313
|
-
if (!
|
|
315
|
+
if (!source || typeof source !== 'object') return false;
|
|
314
316
|
const host = this.#resolveHost(operationSnapshot);
|
|
315
317
|
if (!host) return false;
|
|
316
|
-
const
|
|
318
|
+
const projected = toPermissionSubject(source);
|
|
319
|
+
const session = resolveCommandSession(source);
|
|
320
|
+
const subject = { ...projected, ...session, ...(session.sender ? { sender: { ...projected.sender, ...session.sender } } : {}) };
|
|
321
|
+
if (!subject.sender?.id && !subject.scene?.id && !subject.adapter && !subject.endpoint) return false;
|
|
317
322
|
return host.checkAll(permits, subject);
|
|
318
323
|
}
|
|
319
324
|
|
|
@@ -347,11 +352,17 @@ export class CommandIndex {
|
|
|
347
352
|
if (segments.length === 0) return undefined;
|
|
348
353
|
|
|
349
354
|
for (const route of this.#routes) {
|
|
350
|
-
|
|
355
|
+
// Options may appear between the literal route and a directory-bound parameter.
|
|
356
|
+
const literalResult = !exact ? route.literalMatcher?.match(asMatcherSegments(segments)) : undefined;
|
|
357
|
+
const helpRequested = literalResult && hasCommandBoundary(literalResult.remaining)
|
|
358
|
+
&& requestsCommandHelp(normalizeSegments(literalResult.remaining));
|
|
359
|
+
const parseDeclaredArguments = !exact && route.record.parameter && route.literalMatcher !== undefined
|
|
360
|
+
&& (Object.hasOwn(route.record.slot.definition.params ?? {}, route.record.parameter.name) || helpRequested);
|
|
361
|
+
const result = parseDeclaredArguments ? literalResult : route.matcher.match(asMatcherSegments(segments));
|
|
351
362
|
if (!result || !hasCommandBoundary(result.remaining)) continue;
|
|
352
363
|
const parameter = route.record.parameter;
|
|
353
364
|
const params: Record<string, CommandDynamicValue> = { ...result.params };
|
|
354
|
-
if (parameter?.rest) {
|
|
365
|
+
if (parameter?.rest && !parseDeclaredArguments) {
|
|
355
366
|
const raw = result.params[parameter.name];
|
|
356
367
|
const coerced = coerceRestValues(parameter, Array.isArray(raw) ? raw : []);
|
|
357
368
|
if (!coerced || (isRequiredParameter(parameter) && coerced.length === 0)) continue;
|
|
@@ -359,15 +370,16 @@ export class CommandIndex {
|
|
|
359
370
|
}
|
|
360
371
|
const remaining = normalizeSegments(result.remaining);
|
|
361
372
|
if (exact && remaining.length > 0) continue;
|
|
362
|
-
if (parameter && !parameter.rest && parameter
|
|
363
|
-
&& parameter.
|
|
364
|
-
|
|
365
|
-
delete params[parameter.name];
|
|
373
|
+
if (parameter && !parseDeclaredArguments && !parameter.rest && !isRequiredParameter(parameter)
|
|
374
|
+
&& (params[parameter.name] === '' || params[parameter.name] === null || params[parameter.name] === undefined)) {
|
|
375
|
+
if (parameter.defaultValue !== undefined) params[parameter.name] = parameter.defaultValue;
|
|
376
|
+
else delete params[parameter.name];
|
|
366
377
|
}
|
|
367
378
|
return {
|
|
368
379
|
command: route.record,
|
|
369
380
|
params: Object.freeze(params),
|
|
370
381
|
remaining,
|
|
382
|
+
helpRequested: literalResult ? !!helpRequested : requestsCommandHelp(remaining),
|
|
371
383
|
};
|
|
372
384
|
}
|
|
373
385
|
return undefined;
|
|
@@ -395,17 +407,19 @@ export class CommandIndex {
|
|
|
395
407
|
#dispatchMenu(input: CommandMatchInput, commandPrefix = ''): string | undefined {
|
|
396
408
|
const text = typeof input === 'string' ? input.trim() : exactMessageText(input);
|
|
397
409
|
if (text === undefined) return undefined;
|
|
398
|
-
const
|
|
399
|
-
if (
|
|
410
|
+
const menu = this.#menu;
|
|
411
|
+
if (!menu) return undefined;
|
|
412
|
+
const keyword = menu.keyword;
|
|
413
|
+
if (text === keyword) return this.#buildMenu(undefined, commandPrefix, keyword);
|
|
400
414
|
if (text.startsWith(keyword + ' ')) {
|
|
401
415
|
const key = text.slice(keyword.length + 1).trim();
|
|
402
|
-
if (key) return this.#buildMenu(key, commandPrefix);
|
|
403
|
-
return this.#buildMenu(undefined, commandPrefix);
|
|
416
|
+
if (key) return this.#buildMenu(key, commandPrefix, keyword);
|
|
417
|
+
return this.#buildMenu(undefined, commandPrefix, keyword);
|
|
404
418
|
}
|
|
405
419
|
return undefined;
|
|
406
420
|
}
|
|
407
421
|
|
|
408
|
-
#buildMenu(pluginKey
|
|
422
|
+
#buildMenu(pluginKey: string | undefined, commandPrefix: string, keyword: string): string {
|
|
409
423
|
const root = this.snapshot.root;
|
|
410
424
|
const targetId = pluginKey
|
|
411
425
|
? `${root}/${pluginKey.split('.').join('/')}` as PluginId
|
|
@@ -419,7 +433,6 @@ export class CommandIndex {
|
|
|
419
433
|
.map((childId) => this.snapshot.tree.get(childId))
|
|
420
434
|
.filter((child): child is NonNullable<typeof child> => !!child);
|
|
421
435
|
|
|
422
|
-
const keyword = this.#menu!.keyword;
|
|
423
436
|
const displayKey = pluginKey ?? node.instanceKey;
|
|
424
437
|
const lines: string[] = [`=== ${displayKey} 指令菜单 ===`];
|
|
425
438
|
|
|
@@ -507,6 +520,48 @@ function occupancyKey(
|
|
|
507
520
|
return parameter ? routeShape(segments) : segments.join(' ');
|
|
508
521
|
}
|
|
509
522
|
|
|
523
|
+
/** Accepted token arities, including optional tails and an unbounded rest tail. */
|
|
524
|
+
function declaredRouteShapes(segments: readonly string[], definition: CommandDefinition): readonly string[] {
|
|
525
|
+
let variants: string[][] = [[]];
|
|
526
|
+
const append = (token: string, optional = false): void => {
|
|
527
|
+
const withToken = variants.map((variant) => [...variant, token]);
|
|
528
|
+
variants = [...new Map((optional ? [...variants, ...withToken] : withToken)
|
|
529
|
+
.map((variant) => [variant.join(' '), variant])).values()];
|
|
530
|
+
};
|
|
531
|
+
for (const segment of segments) {
|
|
532
|
+
if (!segment.startsWith('$')) { append(segment); continue; }
|
|
533
|
+
const parameter = definition.$parameter;
|
|
534
|
+
if (!parameter) continue;
|
|
535
|
+
if (parameter.rest) append(isRequiredParameter(parameter) ? '$+' : '$*');
|
|
536
|
+
else append('$', !isRequiredParameter(parameter));
|
|
537
|
+
}
|
|
538
|
+
for (const [name, schema] of Object.entries(definition.params ?? {})) {
|
|
539
|
+
if (name === definition.$parameter?.name) continue;
|
|
540
|
+
const optional = schema.optional === true || schema.default !== undefined;
|
|
541
|
+
if (schema.rest) append(optional ? '$*' : '$+');
|
|
542
|
+
else append('$', optional);
|
|
543
|
+
}
|
|
544
|
+
return [...new Set(variants.map((variant) => variant.join(' ')))];
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function routeShapesOverlap(left: string, right: string): boolean {
|
|
548
|
+
const shape = (key: string) => {
|
|
549
|
+
const tokens = key.split(' ');
|
|
550
|
+
const repeat = tokens.at(-1);
|
|
551
|
+
const rest = repeat === '$*' || repeat === '$+';
|
|
552
|
+
const prefix = rest ? tokens.slice(0, -1) : tokens;
|
|
553
|
+
return { prefix, min: prefix.length + (repeat === '$+' ? 1 : 0), max: rest ? Infinity : prefix.length };
|
|
554
|
+
};
|
|
555
|
+
const a = shape(left);
|
|
556
|
+
const b = shape(right);
|
|
557
|
+
const length = Math.max(a.min, b.min);
|
|
558
|
+
if (length > Math.min(a.max, b.max)) return false;
|
|
559
|
+
for (let index = 0; index < length; index++) {
|
|
560
|
+
if ((a.prefix[index] ?? '$') !== (b.prefix[index] ?? '$')) return false;
|
|
561
|
+
}
|
|
562
|
+
return true;
|
|
563
|
+
}
|
|
564
|
+
|
|
510
565
|
function normalizeAliasList(
|
|
511
566
|
alias: readonly string[] | undefined,
|
|
512
567
|
): readonly string[] | undefined {
|
|
@@ -519,6 +574,11 @@ function resolveShortcutParams(
|
|
|
519
574
|
prefill: Readonly<Record<string, CommandDynamicValue>>,
|
|
520
575
|
source: string,
|
|
521
576
|
): Record<string, CommandDynamicValue> {
|
|
577
|
+
for (const [name, schema] of Object.entries(definition.options ?? {})) {
|
|
578
|
+
if (schema.optional === false && schema.default === undefined) {
|
|
579
|
+
throw new TypeError(`Invalid shortcut for ${source}: cannot supply required option --${name}`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
522
582
|
const allowed = new Set<string>();
|
|
523
583
|
const parameter = definition.$parameter;
|
|
524
584
|
if (parameter) allowed.add(parameter.name);
|
|
@@ -563,12 +623,6 @@ function resolveShortcutParams(
|
|
|
563
623
|
return result;
|
|
564
624
|
}
|
|
565
625
|
|
|
566
|
-
function hasImSession(source: unknown): boolean {
|
|
567
|
-
if (!source || typeof source !== 'object') return false;
|
|
568
|
-
const conversation = (source as { conversation?: unknown }).conversation;
|
|
569
|
-
return !!conversation && typeof conversation === 'object';
|
|
570
|
-
}
|
|
571
|
-
|
|
572
626
|
function exactMessageText(input: CommandMatchInput): string | undefined {
|
|
573
627
|
if (typeof input === 'string') {
|
|
574
628
|
const trimmed = input.trim();
|
|
@@ -619,6 +673,12 @@ function displayName(
|
|
|
619
673
|
}).join(' ');
|
|
620
674
|
}
|
|
621
675
|
|
|
676
|
+
function compileLiteralMatcher(segments: readonly string[], parameter: CommandParameterDefinition | undefined): SegmentMatcher | undefined {
|
|
677
|
+
if (!parameter) return undefined;
|
|
678
|
+
const literals = segments.filter((segment) => !segment.startsWith('$'));
|
|
679
|
+
return literals.length > 0 ? new SegmentMatcher(literals.join(' '), segmentFields) : undefined;
|
|
680
|
+
}
|
|
681
|
+
|
|
622
682
|
function matcherPattern(
|
|
623
683
|
segments: readonly string[],
|
|
624
684
|
parameter: CommandParameterDefinition | undefined,
|
|
@@ -631,9 +691,7 @@ function matcherPattern(
|
|
|
631
691
|
return `[...${parameter.name}:${isStructuredRestType(parameter.type) ? type : 'text'}]`;
|
|
632
692
|
}
|
|
633
693
|
if (isRequiredParameter(parameter)) return `<${parameter.name}:${type}>`;
|
|
634
|
-
return parameter.
|
|
635
|
-
? `[${parameter.name}:${type}]`
|
|
636
|
-
: `[${parameter.name}:${type}=${typeof parameter.defaultValue === 'function' ? '<dynamic>' : String(parameter.defaultValue)}]`;
|
|
694
|
+
return `[${parameter.name}:${type}]`;
|
|
637
695
|
}).join(' ');
|
|
638
696
|
}
|
|
639
697
|
|
|
@@ -791,3 +849,10 @@ export class CommandParameterValueError extends TypeError {
|
|
|
791
849
|
this.name = 'CommandParameterValueError';
|
|
792
850
|
}
|
|
793
851
|
}
|
|
852
|
+
|
|
853
|
+
export class CommandArgumentValidationError extends TypeError {
|
|
854
|
+
constructor(readonly issues: readonly CommandValidationIssue[], readonly help: string) {
|
|
855
|
+
super(issues.map((issue) => issue.message).join('\n') + '\n' + help);
|
|
856
|
+
this.name = 'CommandArgumentValidationError';
|
|
857
|
+
}
|
|
858
|
+
}
|
package/src/definition.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type CapabilityContext,
|
|
12
12
|
} from '@zhin.js/feature-kit';
|
|
13
13
|
import { assertPermitSyntax } from '@zhin.js/permission';
|
|
14
|
+
import { validateArgumentSchemas } from './arguments.js';
|
|
14
15
|
import type { UserInteraction } from '@zhin.js/interaction';
|
|
15
16
|
|
|
16
17
|
const commandBrand = 'zhin.command/1' as const;
|
|
@@ -34,7 +35,7 @@ export type CommandParameterValue =
|
|
|
34
35
|
| string
|
|
35
36
|
| number
|
|
36
37
|
| boolean
|
|
37
|
-
| ReadonlyArray<string | number | boolean
|
|
38
|
+
| ReadonlyArray<string | number | boolean | Readonly<Record<string, unknown>>>
|
|
38
39
|
| Readonly<Record<string, unknown>>
|
|
39
40
|
| null;
|
|
40
41
|
|
|
@@ -88,8 +89,44 @@ export interface CommandParamSchema {
|
|
|
88
89
|
readonly type: CommandParameterType;
|
|
89
90
|
readonly default?: CommandDynamicValue;
|
|
90
91
|
readonly description?: string;
|
|
92
|
+
readonly optional?: boolean;
|
|
93
|
+
readonly rest?: boolean;
|
|
94
|
+
readonly min?: number;
|
|
95
|
+
readonly max?: number;
|
|
96
|
+
readonly choices?: readonly CommandParameterValue[];
|
|
97
|
+
readonly pattern?: RegExp;
|
|
98
|
+
readonly validate?: (value: CommandParameterValue, session: CommandSession) => boolean | string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface CommandOptionSchema extends Omit<CommandParamSchema, 'rest'> {
|
|
102
|
+
readonly alias?: string;
|
|
91
103
|
}
|
|
92
104
|
|
|
105
|
+
export type CommandParams = Readonly<Record<string, CommandParamSchema>>;
|
|
106
|
+
export type CommandOptions = Readonly<Record<string, CommandOptionSchema>>;
|
|
107
|
+
type ParameterScalar<S extends CommandParamSchema> = S['type'] extends 'number' | 'integer' | 'float' | 'dice' | 'rps'
|
|
108
|
+
? number : S['type'] extends 'boolean' ? boolean
|
|
109
|
+
: S['type'] extends 'image' ? Readonly<Record<string, unknown>> | string
|
|
110
|
+
: S['type'] extends 'mention' | 'face' | 'reply' | 'forward' ? string | number : string;
|
|
111
|
+
type ParameterResult<S extends CommandParamSchema> = S extends { readonly rest: true }
|
|
112
|
+
? readonly ParameterScalar<S>[] : ParameterScalar<S>;
|
|
113
|
+
type HasDefault<S extends CommandParamSchema> = S extends { readonly default: infer D }
|
|
114
|
+
? undefined extends D ? false : true : false;
|
|
115
|
+
type OptionalParam<S extends CommandParamSchema> = S extends { readonly rest: true } ? false
|
|
116
|
+
: HasDefault<S> extends true ? false : S extends { readonly optional: true } ? true : false;
|
|
117
|
+
type OptionalOption<S extends CommandOptionSchema> = HasDefault<S> extends true ? false
|
|
118
|
+
: S extends { readonly optional: false } ? false : true;
|
|
119
|
+
export type InferCommandParams<P extends CommandParams> = {
|
|
120
|
+
readonly [K in keyof P as OptionalParam<P[K]> extends true ? never : K]: ParameterResult<P[K]>;
|
|
121
|
+
} & {
|
|
122
|
+
readonly [K in keyof P as OptionalParam<P[K]> extends true ? K : never]?: ParameterResult<P[K]>;
|
|
123
|
+
};
|
|
124
|
+
export type InferCommandOptions<O extends CommandOptions> = {
|
|
125
|
+
readonly [K in keyof O as OptionalOption<O[K]> extends true ? never : K]: ParameterResult<O[K]>;
|
|
126
|
+
} & {
|
|
127
|
+
readonly [K in keyof O as OptionalOption<O[K]> extends true ? K : never]?: ParameterResult<O[K]>;
|
|
128
|
+
};
|
|
129
|
+
|
|
93
130
|
/** Minimal structural contract shared with canonical IM segments. */
|
|
94
131
|
export interface CommandSegment {
|
|
95
132
|
readonly type: string | { readonly name: string };
|
|
@@ -226,6 +263,7 @@ export interface CommandContext<
|
|
|
226
263
|
> extends CapabilityContext<TConfig>, CommandSession {
|
|
227
264
|
readonly args: readonly string[];
|
|
228
265
|
readonly params: Readonly<Record<string, CommandParameterValue>>;
|
|
266
|
+
readonly options: Readonly<Record<string, CommandParameterValue>>;
|
|
229
267
|
/** Structured arguments left after the command pattern was consumed. */
|
|
230
268
|
readonly segments: readonly Readonly<CommandSegment>[];
|
|
231
269
|
/**
|
|
@@ -259,7 +297,8 @@ export interface CommandDefinition<
|
|
|
259
297
|
* Next.js 风格参数声明:动态段文件名(`[name]` 等)的形态配合这里的
|
|
260
298
|
* 类型 / 默认值 / 描述使用。静态命令可忽略本字段。
|
|
261
299
|
*/
|
|
262
|
-
readonly params?:
|
|
300
|
+
readonly params?: CommandParams;
|
|
301
|
+
readonly options?: CommandOptions;
|
|
263
302
|
/**
|
|
264
303
|
* 本地静态段别名(可多词,如 `'gh issue'`)。Capability owner 不参与
|
|
265
304
|
* 用户路由;alias 只替换文件路径提供的静态段。
|
|
@@ -299,21 +338,31 @@ type CommandAuthoringDefinition<
|
|
|
299
338
|
TConfig,
|
|
300
339
|
TResult,
|
|
301
340
|
TInput extends CommandMessage,
|
|
341
|
+
P extends CommandParams,
|
|
342
|
+
O extends CommandOptions,
|
|
302
343
|
> =
|
|
303
|
-
| Omit<CommandDefinition<TConfig, TResult, TInput, undefined>, '$feature' | '$parameter'>
|
|
344
|
+
| Omit<CommandDefinition<TConfig, TResult, TInput, undefined>, '$feature' | '$parameter' | 'params' | 'options' | 'execute'> & { readonly params?: P; readonly options?: O; execute(context: TypedCommandContext<TConfig, TInput, undefined, P, O>): TResult | Promise<TResult> }
|
|
304
345
|
| {
|
|
305
346
|
[TAdapter in RegisteredAdapterName]: Omit<
|
|
306
347
|
CommandDefinition<TConfig, TResult, TInput, TAdapter>,
|
|
307
|
-
'$feature' | '$parameter'
|
|
308
|
-
> & { readonly adapter: TAdapter }
|
|
348
|
+
'$feature' | '$parameter' | 'params' | 'options' | 'execute'
|
|
349
|
+
> & { readonly adapter: TAdapter; readonly params?: P; readonly options?: O; execute(context: TypedCommandContext<TConfig, TInput, TAdapter, P, O>): TResult | Promise<TResult> }
|
|
309
350
|
}[RegisteredAdapterName];
|
|
310
351
|
|
|
352
|
+
type TypedCommandContext<TConfig, TInput extends CommandMessage, TAdapter extends string | undefined, P extends CommandParams, O extends CommandOptions> =
|
|
353
|
+
Omit<CommandContext<TConfig, TInput, TAdapter>, 'params' | 'options'> & {
|
|
354
|
+
readonly params: keyof P extends never ? Readonly<Record<string, CommandParameterValue>> : InferCommandParams<P>;
|
|
355
|
+
readonly options: InferCommandOptions<O>;
|
|
356
|
+
};
|
|
357
|
+
|
|
311
358
|
export function defineCommand<
|
|
312
359
|
TConfig = unknown,
|
|
313
360
|
TResult = unknown,
|
|
314
361
|
TInput extends CommandMessage = CommandMessage,
|
|
362
|
+
const P extends CommandParams = {},
|
|
363
|
+
const O extends CommandOptions = {},
|
|
315
364
|
>(
|
|
316
|
-
definition: CommandAuthoringDefinition<TConfig, TResult, TInput>,
|
|
365
|
+
definition: CommandAuthoringDefinition<TConfig, TResult, TInput, P, O>,
|
|
317
366
|
): Readonly<CommandDefinition<TConfig, TResult, TInput, string | undefined>> {
|
|
318
367
|
if (typeof definition.execute !== 'function') {
|
|
319
368
|
throw new TypeError('Command execute must be a function');
|
|
@@ -330,6 +379,7 @@ export function defineCommand<
|
|
|
330
379
|
}
|
|
331
380
|
}
|
|
332
381
|
}
|
|
382
|
+
validateArgumentSchemas(definition.params, definition.options);
|
|
333
383
|
validateCommandAlias(definition.alias);
|
|
334
384
|
validateCommandPermit(definition.permit);
|
|
335
385
|
validateCommandShortcutShape(definition.shortcut);
|
|
@@ -444,14 +494,18 @@ export function createCommandContext(
|
|
|
444
494
|
segments: readonly Readonly<CommandSegment>[] = Object.freeze([]),
|
|
445
495
|
interaction?: UserInteraction,
|
|
446
496
|
adapter?: string,
|
|
497
|
+
options: Readonly<Record<string, CommandParameterValue>> = Object.freeze({}),
|
|
447
498
|
): CommandContext {
|
|
448
499
|
const context = createCapabilityContext(snapshot, ownerId);
|
|
449
500
|
const session = resolveCommandSession(input);
|
|
501
|
+
const parameterValues: Record<string, CommandParameterValue> = { __proto__: null, ...params };
|
|
502
|
+
const optionValues: Record<string, CommandParameterValue> = { __proto__: null, ...options };
|
|
450
503
|
const result = {
|
|
451
504
|
...context,
|
|
452
505
|
...session,
|
|
453
506
|
args: Object.freeze([...args]),
|
|
454
|
-
params: Object.freeze(
|
|
507
|
+
params: Object.freeze(parameterValues),
|
|
508
|
+
options: Object.freeze(optionValues),
|
|
455
509
|
segments: freezeSegments(segments),
|
|
456
510
|
...(input !== undefined ? { input: input as CommandMessage } : {}),
|
|
457
511
|
...(interaction !== undefined ? { interaction } : {}),
|
package/src/index.ts
CHANGED
package/src/provider.ts
CHANGED
|
@@ -124,6 +124,13 @@ function resolveParameter(
|
|
|
124
124
|
`missing params.${hint.name} declaration in defineCommand({ params })`,
|
|
125
125
|
);
|
|
126
126
|
}
|
|
127
|
+
if (Object.keys(definition.params ?? {})[0] !== hint.name) {
|
|
128
|
+
throw new CommandPathSyntaxError(source, `params.${hint.name} must be the first parameter declaration`);
|
|
129
|
+
}
|
|
130
|
+
if (schema.rest !== undefined && schema.rest !== hint.rest
|
|
131
|
+
|| schema.optional !== undefined && schema.optional !== hint.optional) {
|
|
132
|
+
throw new CommandPathSyntaxError(source, `params.${hint.name} optional/rest declaration conflicts with the directory shape`);
|
|
133
|
+
}
|
|
127
134
|
if (!hint.optional && schema.default !== undefined) {
|
|
128
135
|
throw new CommandPathSyntaxError(
|
|
129
136
|
source,
|