@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/lib/command-index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SegmentMatcher, TypeMatcherRegistry, } from 'segment-matcher';
|
|
2
|
-
import { operationClientAdapter } from '@zhin.js/feature-kit';
|
|
2
|
+
import { bindOperationInput, operationClientAdapter } from '@zhin.js/feature-kit';
|
|
3
3
|
import { permissionHostToken, toPermissionSubject, } from '@zhin.js/permission';
|
|
4
|
+
import { commandUsage, commandHelp, parseCommandArguments, requestsCommandHelp } from './arguments.js';
|
|
4
5
|
import { createCommandContext, resolveCommandSession, resolveDynamicParams, } from './definition.js';
|
|
5
6
|
const segmentFields = {
|
|
6
7
|
text: 'text',
|
|
@@ -27,12 +28,16 @@ export class CommandIndex {
|
|
|
27
28
|
const routes = [];
|
|
28
29
|
const occupancy = new Map();
|
|
29
30
|
const shortcuts = new Map();
|
|
30
|
-
const claim = (
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
const claim = (keys, source) => {
|
|
32
|
+
for (const key of keys) {
|
|
33
|
+
for (const [occupied, existing] of occupancy) {
|
|
34
|
+
if (routeShapesOverlap(key, occupied)) {
|
|
35
|
+
throw new Error(`Duplicate Command route "${key}" (${source} vs ${existing})`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
34
38
|
}
|
|
35
|
-
|
|
39
|
+
for (const key of keys)
|
|
40
|
+
occupancy.set(key, source);
|
|
36
41
|
};
|
|
37
42
|
for (const slot of slots) {
|
|
38
43
|
const namespace = commandNamespace(this.snapshot, slot.owner);
|
|
@@ -52,10 +57,16 @@ export class CommandIndex {
|
|
|
52
57
|
description: slot.definition.description,
|
|
53
58
|
...(slot.definition.adapter ? { adapter: slot.definition.adapter } : {}),
|
|
54
59
|
source: slot.source,
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
usage: commandUsage(name, slot.definition),
|
|
61
|
+
options: Object.freeze({ ...slot.definition.options }),
|
|
62
|
+
parameters: Object.freeze([
|
|
63
|
+
...Object.entries(slot.definition.params ?? {}).map(([key, schema]) => ({
|
|
64
|
+
name: key, ...schema, ...(parameter?.name === key ? parameter : {}), defaultValue: schema.default,
|
|
65
|
+
required: parameter?.name === key ? isRequiredParameter(parameter) : !schema.optional && schema.default === undefined,
|
|
66
|
+
})),
|
|
67
|
+
...(parameter && !Object.hasOwn(slot.definition.params ?? {}, parameter.name)
|
|
68
|
+
? [{ ...parameter, required: isRequiredParameter(parameter) }] : []),
|
|
69
|
+
]),
|
|
59
70
|
...(alias ? { alias } : {}),
|
|
60
71
|
...(permit ? { permit } : {}),
|
|
61
72
|
...(shortcutKeys && shortcutKeys.length > 0 ? { shortcut: shortcutKeys } : {}),
|
|
@@ -63,22 +74,24 @@ export class CommandIndex {
|
|
|
63
74
|
segments: Object.freeze(primarySegments),
|
|
64
75
|
parameter,
|
|
65
76
|
});
|
|
66
|
-
claim(occupancyKey(primarySegments, parameter), slot.source);
|
|
77
|
+
claim([...new Set([occupancyKey(primarySegments, parameter), ...declaredRouteShapes(primarySegments, slot.definition)])], slot.source);
|
|
67
78
|
routes.push({
|
|
68
79
|
record,
|
|
69
80
|
segments: primarySegments,
|
|
70
81
|
matcher: new SegmentMatcher(matcherPattern(primarySegments, parameter), segmentFields),
|
|
82
|
+
literalMatcher: compileLiteralMatcher(primarySegments, parameter),
|
|
71
83
|
kind: 'primary',
|
|
72
84
|
});
|
|
73
85
|
if (alias) {
|
|
74
86
|
for (const entry of alias) {
|
|
75
87
|
const aliasSegments = [...namespace, ...aliasRuntimeSegments(entry, primarySegments.slice(namespace.length))];
|
|
76
88
|
assertParameterSegment(aliasSegments, parameter, `${slot.source} alias ${JSON.stringify(entry)}`);
|
|
77
|
-
claim(occupancyKey(aliasSegments, parameter), `${slot.source} alias ${JSON.stringify(entry)}`);
|
|
89
|
+
claim([...new Set([occupancyKey(aliasSegments, parameter), ...declaredRouteShapes(aliasSegments, slot.definition)])], `${slot.source} alias ${JSON.stringify(entry)}`);
|
|
78
90
|
routes.push({
|
|
79
91
|
record,
|
|
80
92
|
segments: Object.freeze(aliasSegments),
|
|
81
93
|
matcher: new SegmentMatcher(matcherPattern(aliasSegments, parameter), segmentFields),
|
|
94
|
+
literalMatcher: compileLiteralMatcher(aliasSegments, parameter),
|
|
82
95
|
kind: 'alias',
|
|
83
96
|
});
|
|
84
97
|
}
|
|
@@ -86,7 +99,7 @@ export class CommandIndex {
|
|
|
86
99
|
if (slot.definition.shortcut) {
|
|
87
100
|
for (const [rawTrigger, prefill] of Object.entries(slot.definition.shortcut)) {
|
|
88
101
|
const trigger = [...namespace, ...splitCommand(rawTrigger)].join(' ');
|
|
89
|
-
claim(trigger, `${slot.source} shortcut ${JSON.stringify(trigger)}`);
|
|
102
|
+
claim([trigger], `${slot.source} shortcut ${JSON.stringify(trigger)}`);
|
|
90
103
|
shortcuts.set(trigger, {
|
|
91
104
|
record,
|
|
92
105
|
params: Object.freeze(resolveShortcutParams(slot.definition, prefill, `${slot.source} shortcut ${JSON.stringify(trigger)}`)),
|
|
@@ -113,13 +126,19 @@ export class CommandIndex {
|
|
|
113
126
|
}
|
|
114
127
|
}
|
|
115
128
|
async execute(name, args = [], operationSnapshot = this.snapshot) {
|
|
116
|
-
const match = this.#match(name,
|
|
129
|
+
const match = this.#match(name, false);
|
|
117
130
|
if (!match) {
|
|
118
131
|
this.#diagnoseParameter(name);
|
|
119
132
|
throw new Error(`Unknown Command: ${name}`);
|
|
120
133
|
}
|
|
121
|
-
|
|
122
|
-
|
|
134
|
+
if (!(await this.#permitAllows(match.command, undefined, operationSnapshot)))
|
|
135
|
+
throw new Error(`Permission denied for Command: ${match.command.name}`);
|
|
136
|
+
const definition = match.command.slot.definition;
|
|
137
|
+
const supplied = [...match.remaining, ...(args.length > 0 ? [{ type: 'text', data: { text: args.join(' ') } }] : [])];
|
|
138
|
+
const parsed = parseCommandArguments(definition, supplied, resolveDynamicParams(match.params, undefined), {});
|
|
139
|
+
if (parsed.issues.length > 0)
|
|
140
|
+
throw new CommandArgumentValidationError(parsed.issues, commandHelp(match.command.name, definition));
|
|
141
|
+
return definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner, textArgs(parsed.remaining), parsed.params, undefined, parsed.remaining, undefined, definition.adapter, parsed.options));
|
|
123
142
|
}
|
|
124
143
|
async dispatch(input, source = undefined, interactionFactory, commandPrefix = '', operationSnapshot = this.snapshot) {
|
|
125
144
|
if (this.#menu) {
|
|
@@ -127,57 +146,78 @@ export class CommandIndex {
|
|
|
127
146
|
if (menuValue !== undefined) {
|
|
128
147
|
return Object.freeze({
|
|
129
148
|
matched: true,
|
|
149
|
+
status: 'executed',
|
|
130
150
|
command: this.#menu.keyword,
|
|
131
151
|
owner: operationSnapshot.root,
|
|
132
152
|
value: menuValue,
|
|
133
153
|
});
|
|
134
154
|
}
|
|
135
155
|
}
|
|
136
|
-
const interaction = interactionFactory?.(source);
|
|
137
156
|
const shortcut = this.#matchShortcut(input);
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
157
|
+
const match = shortcut
|
|
158
|
+
? { command: shortcut.record, params: shortcut.params, remaining: Object.freeze([]), helpRequested: false }
|
|
159
|
+
: this.#match(input, false);
|
|
160
|
+
if (!match) {
|
|
161
|
+
const candidate = this.#invalidRoute(input);
|
|
162
|
+
if (!candidate)
|
|
163
|
+
return Object.freeze({ matched: false, status: 'unmatched' });
|
|
164
|
+
if (!commandAdapterMatches(candidate, source))
|
|
165
|
+
return Object.freeze({ matched: false, status: 'unmatched' });
|
|
166
|
+
if (!(await this.#permitAllows(candidate, source, operationSnapshot)))
|
|
167
|
+
return this.#denied(candidate);
|
|
168
|
+
const help = commandHelp(candidate.name, candidate.slot.definition, commandPrefix);
|
|
169
|
+
const segments = normalizeSegments(typeof input === 'string' ? [{ type: 'text', data: { text: input } }] : input);
|
|
170
|
+
if (requestsCommandHelp(segments))
|
|
171
|
+
return Object.freeze({ matched: true, status: 'executed', command: candidate.name, owner: candidate.slot.owner, value: help });
|
|
172
|
+
return Object.freeze({ matched: true, status: 'invalid', command: candidate.name, owner: candidate.slot.owner,
|
|
173
|
+
issues: Object.freeze([{ field: candidate.parameter?.name ?? 'arguments', message: 'Missing or invalid command parameter' }]), help });
|
|
152
174
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
175
|
+
if (!commandAdapterMatches(match.command, source))
|
|
176
|
+
return Object.freeze({ matched: false, status: 'unmatched' });
|
|
177
|
+
if (!(await this.#permitAllows(match.command, source, operationSnapshot)))
|
|
178
|
+
return this.#denied(match.command);
|
|
179
|
+
const definition = match.command.slot.definition;
|
|
180
|
+
const authorSource = bindOperationInput(source, match.command.slot.owner, operationSnapshot);
|
|
181
|
+
const help = commandHelp(match.command.name, definition, commandPrefix);
|
|
182
|
+
if (match.helpRequested) {
|
|
183
|
+
return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value: help });
|
|
158
184
|
}
|
|
159
|
-
|
|
160
|
-
|
|
185
|
+
const parsed = parseCommandArguments(definition, match.remaining, resolveDynamicParams(match.params, authorSource), resolveCommandSession(authorSource));
|
|
186
|
+
if (parsed.issues.length > 0)
|
|
187
|
+
return Object.freeze({ matched: true, status: 'invalid', command: match.command.name,
|
|
188
|
+
owner: match.command.slot.owner, issues: parsed.issues, help });
|
|
189
|
+
const value = await definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner, textArgs(parsed.remaining), parsed.params, authorSource, parsed.remaining, interactionFactory?.(authorSource), definition.adapter, parsed.options));
|
|
190
|
+
return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value });
|
|
191
|
+
}
|
|
192
|
+
#denied(record) {
|
|
193
|
+
return Object.freeze({ matched: true, status: 'denied', command: record.name, owner: record.slot.owner });
|
|
194
|
+
}
|
|
195
|
+
/** Diagnose only a reliable static route prefix; fully dynamic routes are ordinary chat candidates. */
|
|
196
|
+
#invalidRoute(input) {
|
|
197
|
+
const segments = normalizeSegments(typeof input === 'string' ? [{ type: 'text', data: { text: input } }] : input);
|
|
198
|
+
for (const route of this.#routes) {
|
|
199
|
+
if (!route.record.parameter)
|
|
200
|
+
continue;
|
|
201
|
+
const result = route.literalMatcher?.match(asMatcherSegments(segments));
|
|
202
|
+
if (result && hasCommandBoundary(result.remaining))
|
|
203
|
+
return route.record;
|
|
161
204
|
}
|
|
162
|
-
|
|
163
|
-
const value = await match.command.slot.definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner, args, resolveDynamicParams(match.params, source), source, match.remaining, interaction, match.command.slot.definition.adapter));
|
|
164
|
-
return Object.freeze({
|
|
165
|
-
matched: true,
|
|
166
|
-
command: match.command.name,
|
|
167
|
-
owner: match.command.slot.owner,
|
|
168
|
-
value,
|
|
169
|
-
});
|
|
205
|
+
return undefined;
|
|
170
206
|
}
|
|
171
207
|
async #permitAllows(record, source, operationSnapshot) {
|
|
172
208
|
const permits = record.permit;
|
|
173
209
|
if (!permits || permits.length === 0)
|
|
174
210
|
return true;
|
|
175
|
-
if (!
|
|
176
|
-
return
|
|
211
|
+
if (!source || typeof source !== 'object')
|
|
212
|
+
return false;
|
|
177
213
|
const host = this.#resolveHost(operationSnapshot);
|
|
178
214
|
if (!host)
|
|
179
215
|
return false;
|
|
180
|
-
const
|
|
216
|
+
const projected = toPermissionSubject(source);
|
|
217
|
+
const session = resolveCommandSession(source);
|
|
218
|
+
const subject = { ...projected, ...session, ...(session.sender ? { sender: { ...projected.sender, ...session.sender } } : {}) };
|
|
219
|
+
if (!subject.sender?.id && !subject.scene?.id && !subject.adapter && !subject.endpoint)
|
|
220
|
+
return false;
|
|
181
221
|
return host.checkAll(permits, subject);
|
|
182
222
|
}
|
|
183
223
|
#resolveHost(operationSnapshot) {
|
|
@@ -209,12 +249,18 @@ export class CommandIndex {
|
|
|
209
249
|
if (segments.length === 0)
|
|
210
250
|
return undefined;
|
|
211
251
|
for (const route of this.#routes) {
|
|
212
|
-
|
|
252
|
+
// Options may appear between the literal route and a directory-bound parameter.
|
|
253
|
+
const literalResult = !exact ? route.literalMatcher?.match(asMatcherSegments(segments)) : undefined;
|
|
254
|
+
const helpRequested = literalResult && hasCommandBoundary(literalResult.remaining)
|
|
255
|
+
&& requestsCommandHelp(normalizeSegments(literalResult.remaining));
|
|
256
|
+
const parseDeclaredArguments = !exact && route.record.parameter && route.literalMatcher !== undefined
|
|
257
|
+
&& (Object.hasOwn(route.record.slot.definition.params ?? {}, route.record.parameter.name) || helpRequested);
|
|
258
|
+
const result = parseDeclaredArguments ? literalResult : route.matcher.match(asMatcherSegments(segments));
|
|
213
259
|
if (!result || !hasCommandBoundary(result.remaining))
|
|
214
260
|
continue;
|
|
215
261
|
const parameter = route.record.parameter;
|
|
216
262
|
const params = { ...result.params };
|
|
217
|
-
if (parameter?.rest) {
|
|
263
|
+
if (parameter?.rest && !parseDeclaredArguments) {
|
|
218
264
|
const raw = result.params[parameter.name];
|
|
219
265
|
const coerced = coerceRestValues(parameter, Array.isArray(raw) ? raw : []);
|
|
220
266
|
if (!coerced || (isRequiredParameter(parameter) && coerced.length === 0))
|
|
@@ -224,15 +270,18 @@ export class CommandIndex {
|
|
|
224
270
|
const remaining = normalizeSegments(result.remaining);
|
|
225
271
|
if (exact && remaining.length > 0)
|
|
226
272
|
continue;
|
|
227
|
-
if (parameter && !parameter.rest && parameter
|
|
228
|
-
&& parameter.
|
|
229
|
-
|
|
230
|
-
|
|
273
|
+
if (parameter && !parseDeclaredArguments && !parameter.rest && !isRequiredParameter(parameter)
|
|
274
|
+
&& (params[parameter.name] === '' || params[parameter.name] === null || params[parameter.name] === undefined)) {
|
|
275
|
+
if (parameter.defaultValue !== undefined)
|
|
276
|
+
params[parameter.name] = parameter.defaultValue;
|
|
277
|
+
else
|
|
278
|
+
delete params[parameter.name];
|
|
231
279
|
}
|
|
232
280
|
return {
|
|
233
281
|
command: route.record,
|
|
234
282
|
params: Object.freeze(params),
|
|
235
283
|
remaining,
|
|
284
|
+
helpRequested: literalResult ? !!helpRequested : requestsCommandHelp(remaining),
|
|
236
285
|
};
|
|
237
286
|
}
|
|
238
287
|
return undefined;
|
|
@@ -261,18 +310,21 @@ export class CommandIndex {
|
|
|
261
310
|
const text = typeof input === 'string' ? input.trim() : exactMessageText(input);
|
|
262
311
|
if (text === undefined)
|
|
263
312
|
return undefined;
|
|
264
|
-
const
|
|
313
|
+
const menu = this.#menu;
|
|
314
|
+
if (!menu)
|
|
315
|
+
return undefined;
|
|
316
|
+
const keyword = menu.keyword;
|
|
265
317
|
if (text === keyword)
|
|
266
|
-
return this.#buildMenu(undefined, commandPrefix);
|
|
318
|
+
return this.#buildMenu(undefined, commandPrefix, keyword);
|
|
267
319
|
if (text.startsWith(keyword + ' ')) {
|
|
268
320
|
const key = text.slice(keyword.length + 1).trim();
|
|
269
321
|
if (key)
|
|
270
|
-
return this.#buildMenu(key, commandPrefix);
|
|
271
|
-
return this.#buildMenu(undefined, commandPrefix);
|
|
322
|
+
return this.#buildMenu(key, commandPrefix, keyword);
|
|
323
|
+
return this.#buildMenu(undefined, commandPrefix, keyword);
|
|
272
324
|
}
|
|
273
325
|
return undefined;
|
|
274
326
|
}
|
|
275
|
-
#buildMenu(pluginKey, commandPrefix
|
|
327
|
+
#buildMenu(pluginKey, commandPrefix, keyword) {
|
|
276
328
|
const root = this.snapshot.root;
|
|
277
329
|
const targetId = pluginKey
|
|
278
330
|
? `${root}/${pluginKey.split('.').join('/')}`
|
|
@@ -284,7 +336,6 @@ export class CommandIndex {
|
|
|
284
336
|
const children = node.children
|
|
285
337
|
.map((childId) => this.snapshot.tree.get(childId))
|
|
286
338
|
.filter((child) => !!child);
|
|
287
|
-
const keyword = this.#menu.keyword;
|
|
288
339
|
const displayKey = pluginKey ?? node.instanceKey;
|
|
289
340
|
const lines = [`=== ${displayKey} 指令菜单 ===`];
|
|
290
341
|
if (directCommands.length > 0) {
|
|
@@ -353,12 +404,68 @@ function aliasRuntimeSegments(alias, primarySegments) {
|
|
|
353
404
|
function occupancyKey(segments, parameter) {
|
|
354
405
|
return parameter ? routeShape(segments) : segments.join(' ');
|
|
355
406
|
}
|
|
407
|
+
/** Accepted token arities, including optional tails and an unbounded rest tail. */
|
|
408
|
+
function declaredRouteShapes(segments, definition) {
|
|
409
|
+
let variants = [[]];
|
|
410
|
+
const append = (token, optional = false) => {
|
|
411
|
+
const withToken = variants.map((variant) => [...variant, token]);
|
|
412
|
+
variants = [...new Map((optional ? [...variants, ...withToken] : withToken)
|
|
413
|
+
.map((variant) => [variant.join(' '), variant])).values()];
|
|
414
|
+
};
|
|
415
|
+
for (const segment of segments) {
|
|
416
|
+
if (!segment.startsWith('$')) {
|
|
417
|
+
append(segment);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const parameter = definition.$parameter;
|
|
421
|
+
if (!parameter)
|
|
422
|
+
continue;
|
|
423
|
+
if (parameter.rest)
|
|
424
|
+
append(isRequiredParameter(parameter) ? '$+' : '$*');
|
|
425
|
+
else
|
|
426
|
+
append('$', !isRequiredParameter(parameter));
|
|
427
|
+
}
|
|
428
|
+
for (const [name, schema] of Object.entries(definition.params ?? {})) {
|
|
429
|
+
if (name === definition.$parameter?.name)
|
|
430
|
+
continue;
|
|
431
|
+
const optional = schema.optional === true || schema.default !== undefined;
|
|
432
|
+
if (schema.rest)
|
|
433
|
+
append(optional ? '$*' : '$+');
|
|
434
|
+
else
|
|
435
|
+
append('$', optional);
|
|
436
|
+
}
|
|
437
|
+
return [...new Set(variants.map((variant) => variant.join(' ')))];
|
|
438
|
+
}
|
|
439
|
+
function routeShapesOverlap(left, right) {
|
|
440
|
+
const shape = (key) => {
|
|
441
|
+
const tokens = key.split(' ');
|
|
442
|
+
const repeat = tokens.at(-1);
|
|
443
|
+
const rest = repeat === '$*' || repeat === '$+';
|
|
444
|
+
const prefix = rest ? tokens.slice(0, -1) : tokens;
|
|
445
|
+
return { prefix, min: prefix.length + (repeat === '$+' ? 1 : 0), max: rest ? Infinity : prefix.length };
|
|
446
|
+
};
|
|
447
|
+
const a = shape(left);
|
|
448
|
+
const b = shape(right);
|
|
449
|
+
const length = Math.max(a.min, b.min);
|
|
450
|
+
if (length > Math.min(a.max, b.max))
|
|
451
|
+
return false;
|
|
452
|
+
for (let index = 0; index < length; index++) {
|
|
453
|
+
if ((a.prefix[index] ?? '$') !== (b.prefix[index] ?? '$'))
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
356
458
|
function normalizeAliasList(alias) {
|
|
357
459
|
if (!alias || alias.length === 0)
|
|
358
460
|
return undefined;
|
|
359
461
|
return Object.freeze(alias.map((entry) => entry.trim().split(/\s+/u).filter(Boolean).join(' ')));
|
|
360
462
|
}
|
|
361
463
|
function resolveShortcutParams(definition, prefill, source) {
|
|
464
|
+
for (const [name, schema] of Object.entries(definition.options ?? {})) {
|
|
465
|
+
if (schema.optional === false && schema.default === undefined) {
|
|
466
|
+
throw new TypeError(`Invalid shortcut for ${source}: cannot supply required option --${name}`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
362
469
|
const allowed = new Set();
|
|
363
470
|
const parameter = definition.$parameter;
|
|
364
471
|
if (parameter)
|
|
@@ -395,12 +502,6 @@ function resolveShortcutParams(definition, prefill, source) {
|
|
|
395
502
|
}
|
|
396
503
|
return result;
|
|
397
504
|
}
|
|
398
|
-
function hasImSession(source) {
|
|
399
|
-
if (!source || typeof source !== 'object')
|
|
400
|
-
return false;
|
|
401
|
-
const conversation = source.conversation;
|
|
402
|
-
return !!conversation && typeof conversation === 'object';
|
|
403
|
-
}
|
|
404
505
|
function exactMessageText(input) {
|
|
405
506
|
if (typeof input === 'string') {
|
|
406
507
|
const trimmed = input.trim();
|
|
@@ -444,6 +545,12 @@ function displayName(segments, parameter) {
|
|
|
444
545
|
return required ? `<${label}>` : `[${label}]`;
|
|
445
546
|
}).join(' ');
|
|
446
547
|
}
|
|
548
|
+
function compileLiteralMatcher(segments, parameter) {
|
|
549
|
+
if (!parameter)
|
|
550
|
+
return undefined;
|
|
551
|
+
const literals = segments.filter((segment) => !segment.startsWith('$'));
|
|
552
|
+
return literals.length > 0 ? new SegmentMatcher(literals.join(' '), segmentFields) : undefined;
|
|
553
|
+
}
|
|
447
554
|
function matcherPattern(segments, parameter) {
|
|
448
555
|
return segments.map((segment) => {
|
|
449
556
|
if (!segment.startsWith('$'))
|
|
@@ -456,9 +563,7 @@ function matcherPattern(segments, parameter) {
|
|
|
456
563
|
}
|
|
457
564
|
if (isRequiredParameter(parameter))
|
|
458
565
|
return `<${parameter.name}:${type}>`;
|
|
459
|
-
return parameter.
|
|
460
|
-
? `[${parameter.name}:${type}]`
|
|
461
|
-
: `[${parameter.name}:${type}=${typeof parameter.defaultValue === 'function' ? '<dynamic>' : String(parameter.defaultValue)}]`;
|
|
566
|
+
return `[${parameter.name}:${type}]`;
|
|
462
567
|
}).join(' ');
|
|
463
568
|
}
|
|
464
569
|
function matcherType(type) {
|
|
@@ -589,3 +694,13 @@ export class CommandParameterValueError extends TypeError {
|
|
|
589
694
|
this.name = 'CommandParameterValueError';
|
|
590
695
|
}
|
|
591
696
|
}
|
|
697
|
+
export class CommandArgumentValidationError extends TypeError {
|
|
698
|
+
issues;
|
|
699
|
+
help;
|
|
700
|
+
constructor(issues, help) {
|
|
701
|
+
super(issues.map((issue) => issue.message).join('\n') + '\n' + help);
|
|
702
|
+
this.issues = issues;
|
|
703
|
+
this.help = help;
|
|
704
|
+
this.name = 'CommandArgumentValidationError';
|
|
705
|
+
}
|
|
706
|
+
}
|
package/lib/definition.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { type AdapterClient, type RegisteredAdapterName, type CapabilityContext
|
|
|
7
7
|
import type { UserInteraction } from '@zhin.js/interaction';
|
|
8
8
|
declare const commandBrand: "zhin.command/1";
|
|
9
9
|
export type CommandParameterType = 'string' | 'number' | 'integer' | 'float' | 'boolean' | 'word' | 'text' | 'mention' | 'image' | 'face' | 'reply' | 'forward' | 'dice' | 'rps';
|
|
10
|
-
export type CommandParameterValue = string | number | boolean | ReadonlyArray<string | number | boolean
|
|
10
|
+
export type CommandParameterValue = string | number | boolean | ReadonlyArray<string | number | boolean | Readonly<Record<string, unknown>>> | Readonly<Record<string, unknown>> | null;
|
|
11
11
|
/**
|
|
12
12
|
* 可从运行时会话上下文动态解析的参数值。
|
|
13
13
|
*
|
|
@@ -39,7 +39,44 @@ export interface CommandParamSchema {
|
|
|
39
39
|
readonly type: CommandParameterType;
|
|
40
40
|
readonly default?: CommandDynamicValue;
|
|
41
41
|
readonly description?: string;
|
|
42
|
+
readonly optional?: boolean;
|
|
43
|
+
readonly rest?: boolean;
|
|
44
|
+
readonly min?: number;
|
|
45
|
+
readonly max?: number;
|
|
46
|
+
readonly choices?: readonly CommandParameterValue[];
|
|
47
|
+
readonly pattern?: RegExp;
|
|
48
|
+
readonly validate?: (value: CommandParameterValue, session: CommandSession) => boolean | string;
|
|
49
|
+
}
|
|
50
|
+
export interface CommandOptionSchema extends Omit<CommandParamSchema, 'rest'> {
|
|
51
|
+
readonly alias?: string;
|
|
42
52
|
}
|
|
53
|
+
export type CommandParams = Readonly<Record<string, CommandParamSchema>>;
|
|
54
|
+
export type CommandOptions = Readonly<Record<string, CommandOptionSchema>>;
|
|
55
|
+
type ParameterScalar<S extends CommandParamSchema> = S['type'] extends 'number' | 'integer' | 'float' | 'dice' | 'rps' ? number : S['type'] extends 'boolean' ? boolean : S['type'] extends 'image' ? Readonly<Record<string, unknown>> | string : S['type'] extends 'mention' | 'face' | 'reply' | 'forward' ? string | number : string;
|
|
56
|
+
type ParameterResult<S extends CommandParamSchema> = S extends {
|
|
57
|
+
readonly rest: true;
|
|
58
|
+
} ? readonly ParameterScalar<S>[] : ParameterScalar<S>;
|
|
59
|
+
type HasDefault<S extends CommandParamSchema> = S extends {
|
|
60
|
+
readonly default: infer D;
|
|
61
|
+
} ? undefined extends D ? false : true : false;
|
|
62
|
+
type OptionalParam<S extends CommandParamSchema> = S extends {
|
|
63
|
+
readonly rest: true;
|
|
64
|
+
} ? false : HasDefault<S> extends true ? false : S extends {
|
|
65
|
+
readonly optional: true;
|
|
66
|
+
} ? true : false;
|
|
67
|
+
type OptionalOption<S extends CommandOptionSchema> = HasDefault<S> extends true ? false : S extends {
|
|
68
|
+
readonly optional: false;
|
|
69
|
+
} ? false : true;
|
|
70
|
+
export type InferCommandParams<P extends CommandParams> = {
|
|
71
|
+
readonly [K in keyof P as OptionalParam<P[K]> extends true ? never : K]: ParameterResult<P[K]>;
|
|
72
|
+
} & {
|
|
73
|
+
readonly [K in keyof P as OptionalParam<P[K]> extends true ? K : never]?: ParameterResult<P[K]>;
|
|
74
|
+
};
|
|
75
|
+
export type InferCommandOptions<O extends CommandOptions> = {
|
|
76
|
+
readonly [K in keyof O as OptionalOption<O[K]> extends true ? never : K]: ParameterResult<O[K]>;
|
|
77
|
+
} & {
|
|
78
|
+
readonly [K in keyof O as OptionalOption<O[K]> extends true ? K : never]?: ParameterResult<O[K]>;
|
|
79
|
+
};
|
|
43
80
|
/** Minimal structural contract shared with canonical IM segments. */
|
|
44
81
|
export interface CommandSegment {
|
|
45
82
|
readonly type: string | {
|
|
@@ -169,6 +206,7 @@ export interface CommandSession {
|
|
|
169
206
|
export interface CommandContext<TConfig = unknown, TInput extends CommandMessage = CommandMessage, TAdapter extends string | undefined = undefined> extends CapabilityContext<TConfig>, CommandSession {
|
|
170
207
|
readonly args: readonly string[];
|
|
171
208
|
readonly params: Readonly<Record<string, CommandParameterValue>>;
|
|
209
|
+
readonly options: Readonly<Record<string, CommandParameterValue>>;
|
|
172
210
|
/** Structured arguments left after the command pattern was consumed. */
|
|
173
211
|
readonly segments: readonly Readonly<CommandSegment>[];
|
|
174
212
|
/**
|
|
@@ -196,7 +234,8 @@ export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput
|
|
|
196
234
|
* Next.js 风格参数声明:动态段文件名(`[name]` 等)的形态配合这里的
|
|
197
235
|
* 类型 / 默认值 / 描述使用。静态命令可忽略本字段。
|
|
198
236
|
*/
|
|
199
|
-
readonly params?:
|
|
237
|
+
readonly params?: CommandParams;
|
|
238
|
+
readonly options?: CommandOptions;
|
|
200
239
|
/**
|
|
201
240
|
* 本地静态段别名(可多词,如 `'gh issue'`)。Capability owner 不参与
|
|
202
241
|
* 用户路由;alias 只替换文件路径提供的静态段。
|
|
@@ -223,12 +262,23 @@ declare module '@zhin.js/plugin-runtime' {
|
|
|
223
262
|
* 定义一个命令模块(`commands/` 约定目录下默认导出)。
|
|
224
263
|
* @public 用户侧创作面,承诺 semver(见 docs/contributing/public-api-surface.md)。
|
|
225
264
|
*/
|
|
226
|
-
type CommandAuthoringDefinition<TConfig, TResult, TInput extends CommandMessage> = Omit<CommandDefinition<TConfig, TResult, TInput, undefined>, '$feature' | '$parameter'
|
|
227
|
-
|
|
265
|
+
type CommandAuthoringDefinition<TConfig, TResult, TInput extends CommandMessage, P extends CommandParams, O extends CommandOptions> = (Omit<CommandDefinition<TConfig, TResult, TInput, undefined>, '$feature' | '$parameter' | 'params' | 'options' | 'execute'> & {
|
|
266
|
+
readonly params?: P;
|
|
267
|
+
readonly options?: O;
|
|
268
|
+
execute(context: TypedCommandContext<TConfig, TInput, undefined, P, O>): TResult | Promise<TResult>;
|
|
269
|
+
}) | {
|
|
270
|
+
[TAdapter in RegisteredAdapterName]: Omit<CommandDefinition<TConfig, TResult, TInput, TAdapter>, '$feature' | '$parameter' | 'params' | 'options' | 'execute'> & {
|
|
228
271
|
readonly adapter: TAdapter;
|
|
272
|
+
readonly params?: P;
|
|
273
|
+
readonly options?: O;
|
|
274
|
+
execute(context: TypedCommandContext<TConfig, TInput, TAdapter, P, O>): TResult | Promise<TResult>;
|
|
229
275
|
};
|
|
230
276
|
}[RegisteredAdapterName];
|
|
231
|
-
|
|
277
|
+
type TypedCommandContext<TConfig, TInput extends CommandMessage, TAdapter extends string | undefined, P extends CommandParams, O extends CommandOptions> = Omit<CommandContext<TConfig, TInput, TAdapter>, 'params' | 'options'> & {
|
|
278
|
+
readonly params: keyof P extends never ? Readonly<Record<string, CommandParameterValue>> : InferCommandParams<P>;
|
|
279
|
+
readonly options: InferCommandOptions<O>;
|
|
280
|
+
};
|
|
281
|
+
export declare function defineCommand<TConfig = unknown, TResult = unknown, TInput extends CommandMessage = CommandMessage, const P extends CommandParams = {}, const O extends CommandOptions = {}>(definition: CommandAuthoringDefinition<TConfig, TResult, TInput, P, O>): Readonly<CommandDefinition<TConfig, TResult, TInput, string | undefined>>;
|
|
232
282
|
/** @internal Convention-loader assembly helper. */
|
|
233
283
|
export declare function bindCommandParameter<TConfig, TResult, TInput extends CommandMessage, TAdapter extends string | undefined>(definition: CommandDefinition<TConfig, TResult, TInput, TAdapter>, parameter: CommandParameterDefinition | undefined): Readonly<CommandDefinition<TConfig, TResult, TInput, TAdapter>>;
|
|
234
284
|
/** @internal Runtime validation for convention-discovered modules. */
|
|
@@ -240,7 +290,7 @@ export declare function parseCommandDefinition(value: unknown): CommandDefinitio
|
|
|
240
290
|
*/
|
|
241
291
|
export declare function resolveDynamicParams(params: Readonly<Record<string, CommandDynamicValue>>, source: unknown): Readonly<Record<string, CommandParameterValue>>;
|
|
242
292
|
/** @internal Command dispatcher assembly helper. */
|
|
243
|
-
export declare function createCommandContext(snapshot: RuntimeSnapshot, ownerId: PluginId, args: readonly string[], params?: Readonly<Record<string, CommandParameterValue>>, input?: unknown, segments?: readonly Readonly<CommandSegment>[], interaction?: UserInteraction, adapter?: string): CommandContext;
|
|
293
|
+
export declare function createCommandContext(snapshot: RuntimeSnapshot, ownerId: PluginId, args: readonly string[], params?: Readonly<Record<string, CommandParameterValue>>, input?: unknown, segments?: readonly Readonly<CommandSegment>[], interaction?: UserInteraction, adapter?: string, options?: Readonly<Record<string, CommandParameterValue>>): CommandContext;
|
|
244
294
|
/**
|
|
245
295
|
* 从派发来源(通常是 Runtime `Message`)解析入站快捷字段。
|
|
246
296
|
* 不依赖 `@zhin.js/core`,按 {@link CommandMessage} 结构鸭式识别。
|
package/lib/definition.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createCapabilityContext, readOperationClient, } from '@zhin.js/feature-kit';
|
|
2
2
|
import { assertPermitSyntax } from '@zhin.js/permission';
|
|
3
|
+
import { validateArgumentSchemas } from './arguments.js';
|
|
3
4
|
const commandBrand = 'zhin.command/1';
|
|
4
5
|
/** @internal Runtime validator lookup. */
|
|
5
6
|
export const commandParameterTypes = new Set([
|
|
@@ -34,6 +35,7 @@ export function defineCommand(definition) {
|
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
}
|
|
38
|
+
validateArgumentSchemas(definition.params, definition.options);
|
|
37
39
|
validateCommandAlias(definition.alias);
|
|
38
40
|
validateCommandPermit(definition.permit);
|
|
39
41
|
validateCommandShortcutShape(definition.shortcut);
|
|
@@ -120,14 +122,17 @@ export function resolveDynamicParams(params, source) {
|
|
|
120
122
|
return Object.freeze(resolved);
|
|
121
123
|
}
|
|
122
124
|
/** @internal Command dispatcher assembly helper. */
|
|
123
|
-
export function createCommandContext(snapshot, ownerId, args, params = Object.freeze({}), input = undefined, segments = Object.freeze([]), interaction, adapter) {
|
|
125
|
+
export function createCommandContext(snapshot, ownerId, args, params = Object.freeze({}), input = undefined, segments = Object.freeze([]), interaction, adapter, options = Object.freeze({})) {
|
|
124
126
|
const context = createCapabilityContext(snapshot, ownerId);
|
|
125
127
|
const session = resolveCommandSession(input);
|
|
128
|
+
const parameterValues = { __proto__: null, ...params };
|
|
129
|
+
const optionValues = { __proto__: null, ...options };
|
|
126
130
|
const result = {
|
|
127
131
|
...context,
|
|
128
132
|
...session,
|
|
129
133
|
args: Object.freeze([...args]),
|
|
130
|
-
params: Object.freeze(
|
|
134
|
+
params: Object.freeze(parameterValues),
|
|
135
|
+
options: Object.freeze(optionValues),
|
|
131
136
|
segments: freezeSegments(segments),
|
|
132
137
|
...(input !== undefined ? { input: input } : {}),
|
|
133
138
|
...(interaction !== undefined ? { interaction } : {}),
|
package/lib/index.d.ts
CHANGED
|
@@ -9,3 +9,4 @@ export * from './definition.js';
|
|
|
9
9
|
export { assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, isPlatformPermit, parsePermitName, type ParsedPermit, type PermitKind, } from './permit.js';
|
|
10
10
|
export { CommandPathSyntaxError, commandFeatureId, default as commandFeature, } from './provider.js';
|
|
11
11
|
export { default } from './provider.js';
|
|
12
|
+
export type { CommandValidationIssue } from './arguments.js';
|
package/lib/provider.js
CHANGED
|
@@ -86,6 +86,13 @@ function resolveParameter(definition, file, source) {
|
|
|
86
86
|
if (!schema) {
|
|
87
87
|
throw new CommandPathSyntaxError(source, `missing params.${hint.name} declaration in defineCommand({ params })`);
|
|
88
88
|
}
|
|
89
|
+
if (Object.keys(definition.params ?? {})[0] !== hint.name) {
|
|
90
|
+
throw new CommandPathSyntaxError(source, `params.${hint.name} must be the first parameter declaration`);
|
|
91
|
+
}
|
|
92
|
+
if (schema.rest !== undefined && schema.rest !== hint.rest
|
|
93
|
+
|| schema.optional !== undefined && schema.optional !== hint.optional) {
|
|
94
|
+
throw new CommandPathSyntaxError(source, `params.${hint.name} optional/rest declaration conflicts with the directory shape`);
|
|
95
|
+
}
|
|
89
96
|
if (!hint.optional && schema.default !== undefined) {
|
|
90
97
|
throw new CommandPathSyntaxError(source, `params.${hint.name} has a default but the directory is required: rename it to [[${hint.name}]]`);
|
|
91
98
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/command",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.6",
|
|
4
4
|
"description": "Convention-based Command Feature for Zhin Plugin Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"segment-matcher": "^1.0.5",
|
|
21
|
-
"@zhin.js/feature-kit": "1.1.
|
|
22
|
-
"@zhin.js/interaction": "1.1.
|
|
23
|
-
"@zhin.js/permission": "1.1.
|
|
24
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
21
|
+
"@zhin.js/feature-kit": "1.1.4",
|
|
22
|
+
"@zhin.js/interaction": "1.1.1",
|
|
23
|
+
"@zhin.js/permission": "1.1.5",
|
|
24
|
+
"@zhin.js/plugin-runtime": "1.1.13"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^26.1.2",
|