@zhin.js/command 1.1.5 → 1.1.7
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 +184 -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 +6 -7
- package/src/arguments.ts +280 -0
- package/src/command-index.ts +166 -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
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,6 +146,7 @@ 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,
|
|
@@ -134,51 +154,70 @@ export class CommandIndex {
|
|
|
134
154
|
}
|
|
135
155
|
}
|
|
136
156
|
const shortcut = this.#matchShortcut(input);
|
|
137
|
-
|
|
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 authorSource = bindOperationInput(source, match.command.slot.owner, operationSnapshot);
|
|
164
|
-
const value = await match.command.slot.definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner, args, resolveDynamicParams(match.params, authorSource), authorSource, match.remaining, interactionFactory?.(authorSource), match.command.slot.definition.adapter));
|
|
165
|
-
return Object.freeze({
|
|
166
|
-
matched: true,
|
|
167
|
-
command: match.command.name,
|
|
168
|
-
owner: match.command.slot.owner,
|
|
169
|
-
value,
|
|
170
|
-
});
|
|
205
|
+
return undefined;
|
|
171
206
|
}
|
|
172
207
|
async #permitAllows(record, source, operationSnapshot) {
|
|
173
208
|
const permits = record.permit;
|
|
174
209
|
if (!permits || permits.length === 0)
|
|
175
210
|
return true;
|
|
176
|
-
if (!
|
|
177
|
-
return
|
|
211
|
+
if (!source || typeof source !== 'object')
|
|
212
|
+
return false;
|
|
178
213
|
const host = this.#resolveHost(operationSnapshot);
|
|
179
214
|
if (!host)
|
|
180
215
|
return false;
|
|
181
|
-
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;
|
|
182
221
|
return host.checkAll(permits, subject);
|
|
183
222
|
}
|
|
184
223
|
#resolveHost(operationSnapshot) {
|
|
@@ -210,12 +249,18 @@ export class CommandIndex {
|
|
|
210
249
|
if (segments.length === 0)
|
|
211
250
|
return undefined;
|
|
212
251
|
for (const route of this.#routes) {
|
|
213
|
-
|
|
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));
|
|
214
259
|
if (!result || !hasCommandBoundary(result.remaining))
|
|
215
260
|
continue;
|
|
216
261
|
const parameter = route.record.parameter;
|
|
217
262
|
const params = { ...result.params };
|
|
218
|
-
if (parameter?.rest) {
|
|
263
|
+
if (parameter?.rest && !parseDeclaredArguments) {
|
|
219
264
|
const raw = result.params[parameter.name];
|
|
220
265
|
const coerced = coerceRestValues(parameter, Array.isArray(raw) ? raw : []);
|
|
221
266
|
if (!coerced || (isRequiredParameter(parameter) && coerced.length === 0))
|
|
@@ -225,15 +270,18 @@ export class CommandIndex {
|
|
|
225
270
|
const remaining = normalizeSegments(result.remaining);
|
|
226
271
|
if (exact && remaining.length > 0)
|
|
227
272
|
continue;
|
|
228
|
-
if (parameter && !parameter.rest && parameter
|
|
229
|
-
&& parameter.
|
|
230
|
-
|
|
231
|
-
|
|
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];
|
|
232
279
|
}
|
|
233
280
|
return {
|
|
234
281
|
command: route.record,
|
|
235
282
|
params: Object.freeze(params),
|
|
236
283
|
remaining,
|
|
284
|
+
helpRequested: literalResult ? !!helpRequested : requestsCommandHelp(remaining),
|
|
237
285
|
};
|
|
238
286
|
}
|
|
239
287
|
return undefined;
|
|
@@ -262,18 +310,21 @@ export class CommandIndex {
|
|
|
262
310
|
const text = typeof input === 'string' ? input.trim() : exactMessageText(input);
|
|
263
311
|
if (text === undefined)
|
|
264
312
|
return undefined;
|
|
265
|
-
const
|
|
313
|
+
const menu = this.#menu;
|
|
314
|
+
if (!menu)
|
|
315
|
+
return undefined;
|
|
316
|
+
const keyword = menu.keyword;
|
|
266
317
|
if (text === keyword)
|
|
267
|
-
return this.#buildMenu(undefined, commandPrefix);
|
|
318
|
+
return this.#buildMenu(undefined, commandPrefix, keyword);
|
|
268
319
|
if (text.startsWith(keyword + ' ')) {
|
|
269
320
|
const key = text.slice(keyword.length + 1).trim();
|
|
270
321
|
if (key)
|
|
271
|
-
return this.#buildMenu(key, commandPrefix);
|
|
272
|
-
return this.#buildMenu(undefined, commandPrefix);
|
|
322
|
+
return this.#buildMenu(key, commandPrefix, keyword);
|
|
323
|
+
return this.#buildMenu(undefined, commandPrefix, keyword);
|
|
273
324
|
}
|
|
274
325
|
return undefined;
|
|
275
326
|
}
|
|
276
|
-
#buildMenu(pluginKey, commandPrefix
|
|
327
|
+
#buildMenu(pluginKey, commandPrefix, keyword) {
|
|
277
328
|
const root = this.snapshot.root;
|
|
278
329
|
const targetId = pluginKey
|
|
279
330
|
? `${root}/${pluginKey.split('.').join('/')}`
|
|
@@ -285,7 +336,6 @@ export class CommandIndex {
|
|
|
285
336
|
const children = node.children
|
|
286
337
|
.map((childId) => this.snapshot.tree.get(childId))
|
|
287
338
|
.filter((child) => !!child);
|
|
288
|
-
const keyword = this.#menu.keyword;
|
|
289
339
|
const displayKey = pluginKey ?? node.instanceKey;
|
|
290
340
|
const lines = [`=== ${displayKey} 指令菜单 ===`];
|
|
291
341
|
if (directCommands.length > 0) {
|
|
@@ -354,12 +404,68 @@ function aliasRuntimeSegments(alias, primarySegments) {
|
|
|
354
404
|
function occupancyKey(segments, parameter) {
|
|
355
405
|
return parameter ? routeShape(segments) : segments.join(' ');
|
|
356
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
|
+
}
|
|
357
458
|
function normalizeAliasList(alias) {
|
|
358
459
|
if (!alias || alias.length === 0)
|
|
359
460
|
return undefined;
|
|
360
461
|
return Object.freeze(alias.map((entry) => entry.trim().split(/\s+/u).filter(Boolean).join(' ')));
|
|
361
462
|
}
|
|
362
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
|
+
}
|
|
363
469
|
const allowed = new Set();
|
|
364
470
|
const parameter = definition.$parameter;
|
|
365
471
|
if (parameter)
|
|
@@ -396,12 +502,6 @@ function resolveShortcutParams(definition, prefill, source) {
|
|
|
396
502
|
}
|
|
397
503
|
return result;
|
|
398
504
|
}
|
|
399
|
-
function hasImSession(source) {
|
|
400
|
-
if (!source || typeof source !== 'object')
|
|
401
|
-
return false;
|
|
402
|
-
const conversation = source.conversation;
|
|
403
|
-
return !!conversation && typeof conversation === 'object';
|
|
404
|
-
}
|
|
405
505
|
function exactMessageText(input) {
|
|
406
506
|
if (typeof input === 'string') {
|
|
407
507
|
const trimmed = input.trim();
|
|
@@ -445,6 +545,12 @@ function displayName(segments, parameter) {
|
|
|
445
545
|
return required ? `<${label}>` : `[${label}]`;
|
|
446
546
|
}).join(' ');
|
|
447
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
|
+
}
|
|
448
554
|
function matcherPattern(segments, parameter) {
|
|
449
555
|
return segments.map((segment) => {
|
|
450
556
|
if (!segment.startsWith('$'))
|
|
@@ -457,9 +563,7 @@ function matcherPattern(segments, parameter) {
|
|
|
457
563
|
}
|
|
458
564
|
if (isRequiredParameter(parameter))
|
|
459
565
|
return `<${parameter.name}:${type}>`;
|
|
460
|
-
return parameter.
|
|
461
|
-
? `[${parameter.name}:${type}]`
|
|
462
|
-
: `[${parameter.name}:${type}=${typeof parameter.defaultValue === 'function' ? '<dynamic>' : String(parameter.defaultValue)}]`;
|
|
566
|
+
return `[${parameter.name}:${type}]`;
|
|
463
567
|
}).join(' ');
|
|
464
568
|
}
|
|
465
569
|
function matcherType(type) {
|
|
@@ -590,3 +694,13 @@ export class CommandParameterValueError extends TypeError {
|
|
|
590
694
|
this.name = 'CommandParameterValueError';
|
|
591
695
|
}
|
|
592
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.7",
|
|
4
4
|
"description": "Convention-based Command Feature for Zhin Plugin Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./lib/index.d.ts",
|
|
11
|
-
"development": "./src/index.ts",
|
|
12
11
|
"import": "./lib/index.js"
|
|
13
12
|
}
|
|
14
13
|
},
|
|
@@ -18,10 +17,10 @@
|
|
|
18
17
|
],
|
|
19
18
|
"dependencies": {
|
|
20
19
|
"segment-matcher": "^1.0.5",
|
|
21
|
-
"@zhin.js/feature-kit": "1.1.
|
|
22
|
-
"@zhin.js/
|
|
23
|
-
"@zhin.js/
|
|
24
|
-
"@zhin.js/
|
|
20
|
+
"@zhin.js/feature-kit": "1.1.5",
|
|
21
|
+
"@zhin.js/plugin-runtime": "1.1.14",
|
|
22
|
+
"@zhin.js/interaction": "1.1.2",
|
|
23
|
+
"@zhin.js/permission": "1.1.6"
|
|
25
24
|
},
|
|
26
25
|
"devDependencies": {
|
|
27
26
|
"@types/node": "^26.1.2",
|
|
@@ -35,7 +34,7 @@
|
|
|
35
34
|
"featureApi": "1.0.0"
|
|
36
35
|
},
|
|
37
36
|
"engines": {
|
|
38
|
-
"node": "
|
|
37
|
+
"node": ">=22.19.0"
|
|
39
38
|
},
|
|
40
39
|
"repository": {
|
|
41
40
|
"type": "git",
|