@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.
@@ -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 = (key, source) => {
31
- const existing = occupancy.get(key);
32
- if (existing !== undefined) {
33
- throw new Error(`Duplicate Command route "${key}" (${source} vs ${existing})`);
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
- occupancy.set(key, source);
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
- parameters: Object.freeze(parameter ? [{
56
- ...parameter,
57
- required: isRequiredParameter(parameter),
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, true);
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
- // Host / 无 session:跳过 permit;无 source 时函数默认值得到空 session。
122
- return match.command.slot.definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner, args, resolveDynamicParams(match.params, undefined), undefined, Object.freeze([]), undefined, match.command.slot.definition.adapter));
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
- if (shortcut) {
139
- if (!commandAdapterMatches(shortcut.record, source)) {
140
- return Object.freeze({ matched: false });
141
- }
142
- if (!(await this.#permitAllows(shortcut.record, source, operationSnapshot))) {
143
- return Object.freeze({ matched: false });
144
- }
145
- const value = await shortcut.record.slot.definition.execute(createCommandContext(operationSnapshot, shortcut.record.slot.owner, Object.freeze([]), resolveDynamicParams(shortcut.params, source), source, Object.freeze([]), interaction, shortcut.record.slot.definition.adapter));
146
- return Object.freeze({
147
- matched: true,
148
- command: shortcut.record.name,
149
- owner: shortcut.record.slot.owner,
150
- value,
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
- const match = this.#match(input, false);
154
- if (!match)
155
- return Object.freeze({ matched: false });
156
- if (!commandAdapterMatches(match.command, source)) {
157
- return Object.freeze({ matched: false });
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
- if (!(await this.#permitAllows(match.command, source, operationSnapshot))) {
160
- return Object.freeze({ matched: false });
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
- const args = textArgs(match.remaining);
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 (!hasImSession(source))
176
- return true;
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 subject = toPermissionSubject(resolveCommandSession(source));
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
- const result = route.matcher.match(asMatcherSegments(segments));
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.optional === true
228
- && parameter.defaultValue === undefined
229
- && (params[parameter.name] === '' || params[parameter.name] === null)) {
230
- delete params[parameter.name];
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 keyword = this.#menu.keyword;
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.defaultValue === undefined
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
+ }
@@ -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> | Readonly<Record<string, unknown>> | null;
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?: Readonly<Record<string, CommandParamSchema>>;
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
- [TAdapter in RegisteredAdapterName]: Omit<CommandDefinition<TConfig, TResult, TInput, TAdapter>, '$feature' | '$parameter'> & {
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
- export declare function defineCommand<TConfig = unknown, TResult = unknown, TInput extends CommandMessage = CommandMessage>(definition: CommandAuthoringDefinition<TConfig, TResult, TInput>): Readonly<CommandDefinition<TConfig, TResult, TInput, string | undefined>>;
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({ ...params }),
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.4",
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.2",
22
- "@zhin.js/interaction": "1.1.0",
23
- "@zhin.js/permission": "1.1.3",
24
- "@zhin.js/plugin-runtime": "1.1.11"
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",