@zhin.js/command 1.0.6 → 1.0.9

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