@zhin.js/command 1.0.6 → 1.0.9
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 +18 -4
- package/lib/command-index.d.ts +4 -0
- package/lib/command-index.js +280 -45
- package/lib/definition.d.ts +83 -7
- package/lib/definition.js +90 -40
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/permit.d.ts +5 -0
- package/lib/permit.js +5 -0
- package/lib/provider.d.ts +2 -1
- package/lib/provider.js +47 -68
- package/package.json +4 -3
- package/src/command-index.ts +339 -45
- package/src/definition.ts +175 -43
- package/src/index.ts +10 -0
- package/src/permit.ts +14 -0
- package/src/provider.ts +68 -87
package/src/command-index.ts
CHANGED
|
@@ -10,12 +10,15 @@ import type {
|
|
|
10
10
|
} from '@zhin.js/plugin-runtime';
|
|
11
11
|
import {
|
|
12
12
|
createCommandContext,
|
|
13
|
+
resolveCommandSession,
|
|
13
14
|
type CommandDefinition,
|
|
14
15
|
type CommandParameterDefinition,
|
|
15
16
|
type CommandParameterType,
|
|
16
17
|
type CommandParameterValue,
|
|
17
18
|
type CommandSegment,
|
|
18
19
|
} from './definition.js';
|
|
20
|
+
import { permissionHostToken, type PermissionHost } from '@zhin.js/permission';
|
|
21
|
+
import { toPermissionSubject } from '@zhin.js/permission';
|
|
19
22
|
|
|
20
23
|
export interface CommandParameterDescriptor extends CommandParameterDefinition {
|
|
21
24
|
readonly required: boolean;
|
|
@@ -26,6 +29,10 @@ export interface CommandDescriptor {
|
|
|
26
29
|
readonly description?: string;
|
|
27
30
|
readonly source: string;
|
|
28
31
|
readonly parameters: readonly CommandParameterDescriptor[];
|
|
32
|
+
readonly alias?: readonly string[];
|
|
33
|
+
readonly permit?: readonly string[];
|
|
34
|
+
/** shortcut 触发键列表(不含预填 params)。 */
|
|
35
|
+
readonly shortcut?: readonly string[];
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
export interface CommandDispatchResult {
|
|
@@ -39,7 +46,18 @@ interface CommandRecord extends CommandDescriptor {
|
|
|
39
46
|
readonly slot: Readonly<CapabilitySlot<CommandDefinition>>;
|
|
40
47
|
readonly segments: readonly string[];
|
|
41
48
|
readonly parameter?: CommandParameterDefinition;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface CommandRoute {
|
|
52
|
+
readonly record: CommandRecord;
|
|
53
|
+
readonly segments: readonly string[];
|
|
42
54
|
readonly matcher: SegmentMatcher;
|
|
55
|
+
readonly kind: 'primary' | 'alias';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface ShortcutEntry {
|
|
59
|
+
readonly record: CommandRecord;
|
|
60
|
+
readonly params: Readonly<Record<string, CommandParameterValue>>;
|
|
43
61
|
}
|
|
44
62
|
|
|
45
63
|
interface CommandMatch {
|
|
@@ -66,44 +84,98 @@ const segmentFields = {
|
|
|
66
84
|
export class CommandIndex {
|
|
67
85
|
readonly $projection = 'zhin.command-index/1' as const;
|
|
68
86
|
readonly #commands: readonly CommandRecord[];
|
|
87
|
+
readonly #routes: readonly CommandRoute[];
|
|
88
|
+
readonly #shortcuts: ReadonlyMap<string, ShortcutEntry>;
|
|
69
89
|
|
|
70
90
|
constructor(
|
|
71
91
|
slots: readonly Readonly<CapabilitySlot<CommandDefinition>>[],
|
|
72
92
|
private readonly snapshot: RuntimeSnapshot,
|
|
73
93
|
) {
|
|
74
94
|
const commands: CommandRecord[] = [];
|
|
75
|
-
const
|
|
76
|
-
const
|
|
95
|
+
const routes: CommandRoute[] = [];
|
|
96
|
+
const occupancy = new Map<string, string>();
|
|
97
|
+
const shortcuts = new Map<string, ShortcutEntry>();
|
|
98
|
+
|
|
99
|
+
const claim = (key: string, source: string): void => {
|
|
100
|
+
const existing = occupancy.get(key);
|
|
101
|
+
if (existing !== undefined) {
|
|
102
|
+
throw new Error(`Duplicate Command route "${key}" (${source} vs ${existing})`);
|
|
103
|
+
}
|
|
104
|
+
occupancy.set(key, source);
|
|
105
|
+
};
|
|
106
|
+
|
|
77
107
|
for (const slot of slots) {
|
|
78
|
-
const
|
|
108
|
+
const primarySegments = runtimeSegments(slot.owner, slot.localName);
|
|
79
109
|
const parameter = slot.definition.$parameter;
|
|
80
|
-
assertParameterSegment(
|
|
81
|
-
const name = displayName(
|
|
110
|
+
assertParameterSegment(primarySegments, parameter, slot.source);
|
|
111
|
+
const name = displayName(primarySegments, parameter);
|
|
112
|
+
const alias = normalizeAliasList(slot.definition.alias);
|
|
113
|
+
const permit = slot.definition.permit
|
|
114
|
+
? Object.freeze([...slot.definition.permit])
|
|
115
|
+
: undefined;
|
|
116
|
+
const shortcutKeys = slot.definition.shortcut
|
|
117
|
+
? Object.freeze(Object.keys(slot.definition.shortcut).map((key) => key.trim()))
|
|
118
|
+
: undefined;
|
|
119
|
+
|
|
82
120
|
const record: CommandRecord = Object.freeze({
|
|
83
121
|
name,
|
|
84
122
|
description: slot.definition.description,
|
|
85
123
|
source: slot.source,
|
|
86
124
|
parameters: Object.freeze(parameter ? [{
|
|
87
125
|
...parameter,
|
|
88
|
-
required: parameter
|
|
126
|
+
required: isRequiredParameter(parameter),
|
|
89
127
|
}] : []),
|
|
128
|
+
...(alias ? { alias } : {}),
|
|
129
|
+
...(permit ? { permit } : {}),
|
|
130
|
+
...(shortcutKeys && shortcutKeys.length > 0 ? { shortcut: shortcutKeys } : {}),
|
|
90
131
|
slot,
|
|
91
|
-
segments: Object.freeze(
|
|
132
|
+
segments: Object.freeze(primarySegments),
|
|
92
133
|
parameter,
|
|
93
|
-
matcher: new SegmentMatcher(matcherPattern(segments, parameter), segmentFields),
|
|
94
134
|
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
135
|
+
|
|
136
|
+
claim(occupancyKey(primarySegments, parameter), slot.source);
|
|
137
|
+
routes.push({
|
|
138
|
+
record,
|
|
139
|
+
segments: primarySegments,
|
|
140
|
+
matcher: new SegmentMatcher(matcherPattern(primarySegments, parameter), segmentFields),
|
|
141
|
+
kind: 'primary',
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
if (alias) {
|
|
145
|
+
for (const entry of alias) {
|
|
146
|
+
const aliasSegments = aliasRuntimeSegments(slot.owner, entry, primarySegments);
|
|
147
|
+
assertParameterSegment(aliasSegments, parameter, `${slot.source} alias ${JSON.stringify(entry)}`);
|
|
148
|
+
claim(occupancyKey(aliasSegments, parameter), `${slot.source} alias ${JSON.stringify(entry)}`);
|
|
149
|
+
routes.push({
|
|
150
|
+
record,
|
|
151
|
+
segments: Object.freeze(aliasSegments),
|
|
152
|
+
matcher: new SegmentMatcher(matcherPattern(aliasSegments, parameter), segmentFields),
|
|
153
|
+
kind: 'alias',
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (slot.definition.shortcut) {
|
|
159
|
+
for (const [rawTrigger, prefill] of Object.entries(slot.definition.shortcut)) {
|
|
160
|
+
const trigger = rawTrigger.trim();
|
|
161
|
+
claim(trigger, `${slot.source} shortcut ${JSON.stringify(trigger)}`);
|
|
162
|
+
shortcuts.set(trigger, {
|
|
163
|
+
record,
|
|
164
|
+
params: Object.freeze(resolveShortcutParams(
|
|
165
|
+
slot.definition,
|
|
166
|
+
prefill,
|
|
167
|
+
`${slot.source} shortcut ${JSON.stringify(trigger)}`,
|
|
168
|
+
)),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
103
171
|
}
|
|
172
|
+
|
|
104
173
|
commands.push(record);
|
|
105
174
|
}
|
|
106
|
-
|
|
175
|
+
|
|
176
|
+
this.#commands = Object.freeze(commands.sort(compareRecords));
|
|
177
|
+
this.#routes = Object.freeze(routes.sort(compareRoutes));
|
|
178
|
+
this.#shortcuts = shortcuts;
|
|
107
179
|
}
|
|
108
180
|
|
|
109
181
|
list(): readonly CommandDescriptor[] {
|
|
@@ -125,6 +197,7 @@ export class CommandIndex {
|
|
|
125
197
|
this.#diagnoseParameter(name);
|
|
126
198
|
throw new Error(`Unknown Command: ${name}`);
|
|
127
199
|
}
|
|
200
|
+
// Host / 无 session:跳过 permit。
|
|
128
201
|
return match.command.slot.definition.execute(
|
|
129
202
|
createCommandContext(
|
|
130
203
|
this.snapshot,
|
|
@@ -139,8 +212,34 @@ export class CommandIndex {
|
|
|
139
212
|
input: CommandMatchInput,
|
|
140
213
|
source: unknown = undefined,
|
|
141
214
|
): Promise<CommandDispatchResult> {
|
|
215
|
+
const shortcut = this.#matchShortcut(input);
|
|
216
|
+
if (shortcut) {
|
|
217
|
+
if (!(await this.#permitAllows(shortcut.record, source))) {
|
|
218
|
+
return Object.freeze({ matched: false });
|
|
219
|
+
}
|
|
220
|
+
const value = await shortcut.record.slot.definition.execute(
|
|
221
|
+
createCommandContext(
|
|
222
|
+
this.snapshot,
|
|
223
|
+
shortcut.record.slot.owner,
|
|
224
|
+
Object.freeze([]),
|
|
225
|
+
shortcut.params,
|
|
226
|
+
source,
|
|
227
|
+
Object.freeze([]),
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
return Object.freeze({
|
|
231
|
+
matched: true,
|
|
232
|
+
command: shortcut.record.name,
|
|
233
|
+
owner: shortcut.record.slot.owner,
|
|
234
|
+
value,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
142
238
|
const match = this.#match(input, false);
|
|
143
239
|
if (!match) return Object.freeze({ matched: false });
|
|
240
|
+
if (!(await this.#permitAllows(match.command, source))) {
|
|
241
|
+
return Object.freeze({ matched: false });
|
|
242
|
+
}
|
|
144
243
|
const args = textArgs(match.remaining);
|
|
145
244
|
const value = await match.command.slot.definition.execute(
|
|
146
245
|
createCommandContext(
|
|
@@ -160,6 +259,35 @@ export class CommandIndex {
|
|
|
160
259
|
});
|
|
161
260
|
}
|
|
162
261
|
|
|
262
|
+
async #permitAllows(record: CommandRecord, source: unknown): Promise<boolean> {
|
|
263
|
+
const permits = record.permit;
|
|
264
|
+
if (!permits || permits.length === 0) return true;
|
|
265
|
+
if (!hasImSession(source)) return true;
|
|
266
|
+
const host = this.#resolveHost();
|
|
267
|
+
if (!host) return false;
|
|
268
|
+
const subject = toPermissionSubject(resolveCommandSession(source));
|
|
269
|
+
return host.checkAll(permits, subject);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#resolveHost(): PermissionHost | undefined {
|
|
273
|
+
try {
|
|
274
|
+
const resources = this.snapshot.resources.get(this.snapshot.root);
|
|
275
|
+
if (!resources) return undefined;
|
|
276
|
+
const host = resources.get(permissionHostToken.id);
|
|
277
|
+
return host && typeof (host as PermissionHost).check === 'function'
|
|
278
|
+
? host as PermissionHost
|
|
279
|
+
: undefined;
|
|
280
|
+
} catch {
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
#matchShortcut(input: CommandMatchInput): ShortcutEntry | undefined {
|
|
286
|
+
const text = exactMessageText(input);
|
|
287
|
+
if (text === undefined) return undefined;
|
|
288
|
+
return this.#shortcuts.get(text);
|
|
289
|
+
}
|
|
290
|
+
|
|
163
291
|
#match(input: CommandMatchInput, exact: boolean): CommandMatch | undefined {
|
|
164
292
|
const segments = normalizeSegments(
|
|
165
293
|
typeof input === 'string'
|
|
@@ -170,16 +298,27 @@ export class CommandIndex {
|
|
|
170
298
|
);
|
|
171
299
|
if (segments.length === 0) return undefined;
|
|
172
300
|
|
|
173
|
-
for (const
|
|
174
|
-
const result =
|
|
301
|
+
for (const route of this.#routes) {
|
|
302
|
+
const result = route.matcher.match(asMatcherSegments(segments));
|
|
175
303
|
if (!result || !hasCommandBoundary(result.remaining)) continue;
|
|
304
|
+
const parameter = route.record.parameter;
|
|
305
|
+
const params: Record<string, CommandParameterValue> = { ...result.params };
|
|
306
|
+
if (parameter?.rest) {
|
|
307
|
+
const raw = result.params[parameter.name];
|
|
308
|
+
const coerced = coerceRestValues(parameter, Array.isArray(raw) ? raw : []);
|
|
309
|
+
if (!coerced || (isRequiredParameter(parameter) && coerced.length === 0)) continue;
|
|
310
|
+
params[parameter.name] = coerced;
|
|
311
|
+
}
|
|
176
312
|
const remaining = normalizeSegments(result.remaining);
|
|
177
313
|
if (exact && remaining.length > 0) continue;
|
|
314
|
+
if (parameter && !parameter.rest && parameter.optional === true
|
|
315
|
+
&& parameter.defaultValue === undefined
|
|
316
|
+
&& (params[parameter.name] === '' || params[parameter.name] === null)) {
|
|
317
|
+
delete params[parameter.name];
|
|
318
|
+
}
|
|
178
319
|
return {
|
|
179
|
-
command,
|
|
180
|
-
params: Object.freeze(
|
|
181
|
-
Record<string, CommandParameterValue>
|
|
182
|
-
>,
|
|
320
|
+
command: route.record,
|
|
321
|
+
params: Object.freeze(params),
|
|
183
322
|
remaining,
|
|
184
323
|
};
|
|
185
324
|
}
|
|
@@ -188,12 +327,12 @@ export class CommandIndex {
|
|
|
188
327
|
|
|
189
328
|
#diagnoseParameter(name: string): void {
|
|
190
329
|
const words = splitCommand(name);
|
|
191
|
-
for (const
|
|
192
|
-
const parameter =
|
|
193
|
-
if (!parameter) continue;
|
|
194
|
-
const parameterIndex =
|
|
195
|
-
if (words.length !==
|
|
196
|
-
if (!
|
|
330
|
+
for (const route of this.#routes) {
|
|
331
|
+
const parameter = route.record.parameter;
|
|
332
|
+
if (!parameter || parameter.rest) continue;
|
|
333
|
+
const parameterIndex = route.segments.findIndex((segment) => segment.startsWith('$'));
|
|
334
|
+
if (words.length !== route.segments.length) continue;
|
|
335
|
+
if (!route.segments.every((segment, index) =>
|
|
197
336
|
index === parameterIndex || segment === words[index])) continue;
|
|
198
337
|
const value = words[parameterIndex];
|
|
199
338
|
if (value === undefined || matchesParameter(parameter.type, value)) continue;
|
|
@@ -220,6 +359,103 @@ function runtimeSegments(owner: string, localName: string): string[] {
|
|
|
220
359
|
return [`${prefix}.${localSegments[0]}`, ...localSegments.slice(1)];
|
|
221
360
|
}
|
|
222
361
|
|
|
362
|
+
/**
|
|
363
|
+
* 用 alias 词序列替换全部本地静态段,再按 owner 规则重挂前缀;动态段保留。
|
|
364
|
+
*/
|
|
365
|
+
function aliasRuntimeSegments(
|
|
366
|
+
owner: string,
|
|
367
|
+
alias: string,
|
|
368
|
+
primarySegments: readonly string[],
|
|
369
|
+
): string[] {
|
|
370
|
+
const aliasTokens = alias.trim().split(/\s+/u).filter(Boolean);
|
|
371
|
+
const dynamicTail = primarySegments.filter((segment) => segment.startsWith('$'));
|
|
372
|
+
if (owner === 'root') return [...aliasTokens, ...dynamicTail];
|
|
373
|
+
const prefix = owner.slice('root/'.length).split('/').join('.');
|
|
374
|
+
return [`${prefix}.${aliasTokens[0]}`, ...aliasTokens.slice(1), ...dynamicTail];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function occupancyKey(
|
|
378
|
+
segments: readonly string[],
|
|
379
|
+
parameter: CommandParameterDefinition | undefined,
|
|
380
|
+
): string {
|
|
381
|
+
return parameter ? routeShape(segments) : segments.join(' ');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function normalizeAliasList(
|
|
385
|
+
alias: readonly string[] | undefined,
|
|
386
|
+
): readonly string[] | undefined {
|
|
387
|
+
if (!alias || alias.length === 0) return undefined;
|
|
388
|
+
return Object.freeze(alias.map((entry) => entry.trim().split(/\s+/u).filter(Boolean).join(' ')));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function resolveShortcutParams(
|
|
392
|
+
definition: CommandDefinition,
|
|
393
|
+
prefill: Readonly<Record<string, CommandParameterValue>>,
|
|
394
|
+
source: string,
|
|
395
|
+
): Record<string, CommandParameterValue> {
|
|
396
|
+
const allowed = new Set<string>();
|
|
397
|
+
const parameter = definition.$parameter;
|
|
398
|
+
if (parameter) allowed.add(parameter.name);
|
|
399
|
+
if (definition.params) {
|
|
400
|
+
for (const key of Object.keys(definition.params)) allowed.add(key);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
for (const key of Object.keys(prefill)) {
|
|
404
|
+
if (!allowed.has(key)) {
|
|
405
|
+
throw new TypeError(
|
|
406
|
+
`Invalid shortcut params for ${source}: unknown key ${JSON.stringify(key)}`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const result: Record<string, CommandParameterValue> = { ...prefill };
|
|
412
|
+
|
|
413
|
+
if (parameter) {
|
|
414
|
+
if (result[parameter.name] === undefined) {
|
|
415
|
+
if (parameter.defaultValue !== undefined) {
|
|
416
|
+
result[parameter.name] = parameter.defaultValue;
|
|
417
|
+
} else if (isRequiredParameter(parameter)) {
|
|
418
|
+
throw new TypeError(
|
|
419
|
+
`Invalid shortcut params for ${source}: missing required ${parameter.name}`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
} else if (allowed.size === 0 && Object.keys(prefill).length > 0) {
|
|
424
|
+
throw new TypeError(
|
|
425
|
+
`Invalid shortcut params for ${source}: command has no params declaration`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (definition.params) {
|
|
430
|
+
for (const [name, schema] of Object.entries(definition.params)) {
|
|
431
|
+
if (result[name] === undefined && schema.default !== undefined) {
|
|
432
|
+
result[name] = schema.default;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return result;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function hasImSession(source: unknown): boolean {
|
|
441
|
+
if (!source || typeof source !== 'object') return false;
|
|
442
|
+
const conversation = (source as { conversation?: unknown }).conversation;
|
|
443
|
+
return !!conversation && typeof conversation === 'object';
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function exactMessageText(input: CommandMatchInput): string | undefined {
|
|
447
|
+
if (typeof input === 'string') {
|
|
448
|
+
const trimmed = input.trim();
|
|
449
|
+
return trimmed || undefined;
|
|
450
|
+
}
|
|
451
|
+
// 仅纯单 text 段可作整句 shortcut;含 mention/image 等则不走 shortcut。
|
|
452
|
+
if (input.length !== 1) return undefined;
|
|
453
|
+
const only = input[0];
|
|
454
|
+
if (!only || only.type !== 'text' || typeof only.data.text !== 'string') return undefined;
|
|
455
|
+
const trimmed = only.data.text.trim();
|
|
456
|
+
return trimmed || undefined;
|
|
457
|
+
}
|
|
458
|
+
|
|
223
459
|
function assertParameterSegment(
|
|
224
460
|
segments: readonly string[],
|
|
225
461
|
parameter: CommandParameterDefinition | undefined,
|
|
@@ -230,7 +466,19 @@ function assertParameterSegment(
|
|
|
230
466
|
if (parameter && dynamicSegments.length === 1
|
|
231
467
|
&& dynamicSegments[0] === `$${parameter.name}`
|
|
232
468
|
&& segments.at(-1) === dynamicSegments[0]) return;
|
|
233
|
-
|
|
469
|
+
const dynamic = dynamicSegments[0] ?? (parameter ? `$${parameter.name}` : '$?');
|
|
470
|
+
throw new Error(
|
|
471
|
+
`Invalid Command path for ${source}: the dynamic segment "${dynamic}" must be the only dynamic `
|
|
472
|
+
+ `segment and come after a static segment (child plugin commands are prefixed by the plugin `
|
|
473
|
+
+ `path, so a dynamic first segment is never reachable). `
|
|
474
|
+
+ (parameter
|
|
475
|
+
? `Hint: move the file under a static directory, e.g. "commands/add/[${parameter.name}:${parameter.type}].ts".`
|
|
476
|
+
: 'Hint: put the file under a static directory, e.g. "commands/add/<file>.ts".'),
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function isRequiredParameter(parameter: CommandParameterDefinition): boolean {
|
|
481
|
+
return parameter.optional === true ? false : parameter.defaultValue === undefined;
|
|
234
482
|
}
|
|
235
483
|
|
|
236
484
|
function displayName(
|
|
@@ -239,9 +487,10 @@ function displayName(
|
|
|
239
487
|
): string {
|
|
240
488
|
return segments.map((segment) => {
|
|
241
489
|
if (!segment.startsWith('$')) return segment;
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
490
|
+
const label = segment.slice(1);
|
|
491
|
+
const required = !parameter || isRequiredParameter(parameter);
|
|
492
|
+
if (parameter?.rest) return required ? `<...${label}>` : `[...${label}]`;
|
|
493
|
+
return required ? `<${label}>` : `[${label}]`;
|
|
245
494
|
}).join(' ');
|
|
246
495
|
}
|
|
247
496
|
|
|
@@ -253,8 +502,12 @@ function matcherPattern(
|
|
|
253
502
|
if (!segment.startsWith('$')) return segment;
|
|
254
503
|
if (!parameter) throw new Error(`Missing Command parameter metadata: ${segment}`);
|
|
255
504
|
const type = matcherType(parameter.type);
|
|
505
|
+
if (parameter.rest) {
|
|
506
|
+
return `[...${parameter.name}:${isStructuredRestType(parameter.type) ? type : 'text'}]`;
|
|
507
|
+
}
|
|
508
|
+
if (isRequiredParameter(parameter)) return `<${parameter.name}:${type}>`;
|
|
256
509
|
return parameter.defaultValue === undefined
|
|
257
|
-
?
|
|
510
|
+
? `[${parameter.name}:${type}]`
|
|
258
511
|
: `[${parameter.name}:${type}=${String(parameter.defaultValue)}]`;
|
|
259
512
|
}).join(' ');
|
|
260
513
|
}
|
|
@@ -263,18 +516,64 @@ function matcherType(type: CommandParameterType): string {
|
|
|
263
516
|
return type === 'string' ? 'word' : type;
|
|
264
517
|
}
|
|
265
518
|
|
|
519
|
+
function isStructuredRestType(type: CommandParameterType): boolean {
|
|
520
|
+
return type === 'mention'
|
|
521
|
+
|| type === 'image'
|
|
522
|
+
|| type === 'face'
|
|
523
|
+
|| type === 'reply'
|
|
524
|
+
|| type === 'forward'
|
|
525
|
+
|| type === 'dice'
|
|
526
|
+
|| type === 'rps';
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function coerceRestValues(
|
|
530
|
+
parameter: CommandParameterDefinition,
|
|
531
|
+
values: readonly unknown[],
|
|
532
|
+
): readonly (string | number | boolean)[] | undefined {
|
|
533
|
+
const type = parameter.type;
|
|
534
|
+
if (type === 'text' || isStructuredRestType(type)) {
|
|
535
|
+
return values as readonly (string | number | boolean)[];
|
|
536
|
+
}
|
|
537
|
+
const words = values.flatMap((value) =>
|
|
538
|
+
typeof value === 'string' ? value.split(/\s+/u).filter(Boolean) : []);
|
|
539
|
+
if (type === 'string' || type === 'word') return words;
|
|
540
|
+
if (type === 'number' || type === 'integer' || type === 'float') {
|
|
541
|
+
const numbers: number[] = [];
|
|
542
|
+
for (const [index, word] of words.entries()) {
|
|
543
|
+
const number = Number(word);
|
|
544
|
+
if (!Number.isFinite(number)
|
|
545
|
+
|| (type === 'integer' && !Number.isInteger(number))
|
|
546
|
+
|| (type === 'float' && !word.includes('.'))) return undefined;
|
|
547
|
+
numbers[index] = number;
|
|
548
|
+
}
|
|
549
|
+
return numbers;
|
|
550
|
+
}
|
|
551
|
+
if (type === 'boolean') {
|
|
552
|
+
if (!words.every((word) => word === 'true' || word === 'false')) return undefined;
|
|
553
|
+
return words.map((word) => word === 'true');
|
|
554
|
+
}
|
|
555
|
+
return undefined;
|
|
556
|
+
}
|
|
557
|
+
|
|
266
558
|
function routeShape(segments: readonly string[]): string {
|
|
267
559
|
return segments.map((segment) => segment.startsWith('$') ? '$' : segment).join(' ');
|
|
268
560
|
}
|
|
269
561
|
|
|
270
|
-
function
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
562
|
+
function compareRecords(left: CommandRecord, right: CommandRecord): number {
|
|
563
|
+
return left.name.localeCompare(right.name);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function compareRoutes(left: CommandRoute, right: CommandRoute): number {
|
|
567
|
+
return dynamicWeight(left) - dynamicWeight(right)
|
|
274
568
|
|| staticSegmentCount(right.segments) - staticSegmentCount(left.segments)
|
|
275
569
|
|| right.segments.length - left.segments.length
|
|
276
|
-
|| right.name.length - left.name.length
|
|
277
|
-
|| left.name.localeCompare(right.name);
|
|
570
|
+
|| right.record.name.length - left.record.name.length
|
|
571
|
+
|| left.record.name.localeCompare(right.record.name);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function dynamicWeight(route: CommandRoute): number {
|
|
575
|
+
if (!route.record.parameter) return 0;
|
|
576
|
+
return route.record.parameter.rest ? 2 : 1;
|
|
278
577
|
}
|
|
279
578
|
|
|
280
579
|
function staticSegmentCount(segments: readonly string[]): number {
|
|
@@ -352,16 +651,11 @@ function toDescriptor({
|
|
|
352
651
|
slot: _slot,
|
|
353
652
|
segments: _segments,
|
|
354
653
|
parameter: _parameter,
|
|
355
|
-
matcher: _matcher,
|
|
356
654
|
...descriptor
|
|
357
655
|
}: CommandRecord): CommandDescriptor {
|
|
358
656
|
return descriptor;
|
|
359
657
|
}
|
|
360
658
|
|
|
361
|
-
function duplicateCommand(name: string): Error {
|
|
362
|
-
return new Error(`Duplicate runtime Command: ${name}`);
|
|
363
|
-
}
|
|
364
|
-
|
|
365
659
|
export class CommandParameterValueError extends TypeError {
|
|
366
660
|
constructor(name: string, type: CommandParameterType, value: string) {
|
|
367
661
|
super(`Invalid value for Command parameter ${name}:${type}: ${value}`);
|