@zhin.js/command 1.0.7 → 1.0.10

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,17 @@ 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,
19
+ type CommandDynamicValue,
20
+ resolveDynamicParams,
18
21
  } from './definition.js';
22
+ import { permissionHostToken, type PermissionHost } from '@zhin.js/permission';
23
+ import { toPermissionSubject } from '@zhin.js/permission';
19
24
 
20
25
  export interface CommandParameterDescriptor extends CommandParameterDefinition {
21
26
  readonly required: boolean;
@@ -26,6 +31,10 @@ export interface CommandDescriptor {
26
31
  readonly description?: string;
27
32
  readonly source: string;
28
33
  readonly parameters: readonly CommandParameterDescriptor[];
34
+ readonly alias?: readonly string[];
35
+ readonly permit?: readonly string[];
36
+ /** shortcut 触发键列表(不含预填 params)。 */
37
+ readonly shortcut?: readonly string[];
29
38
  }
30
39
 
31
40
  export interface CommandDispatchResult {
@@ -39,12 +48,23 @@ interface CommandRecord extends CommandDescriptor {
39
48
  readonly slot: Readonly<CapabilitySlot<CommandDefinition>>;
40
49
  readonly segments: readonly string[];
41
50
  readonly parameter?: CommandParameterDefinition;
51
+ }
52
+
53
+ interface CommandRoute {
54
+ readonly record: CommandRecord;
55
+ readonly segments: readonly string[];
42
56
  readonly matcher: SegmentMatcher;
57
+ readonly kind: 'primary' | 'alias';
58
+ }
59
+
60
+ interface ShortcutEntry {
61
+ readonly record: CommandRecord;
62
+ readonly params: Readonly<Record<string, CommandDynamicValue>>;
43
63
  }
44
64
 
45
65
  interface CommandMatch {
46
66
  readonly command: CommandRecord;
47
- readonly params: Readonly<Record<string, CommandParameterValue>>;
67
+ readonly params: Readonly<Record<string, CommandDynamicValue>>;
48
68
  readonly remaining: readonly Readonly<CommandSegment>[];
49
69
  }
50
70
 
@@ -66,19 +86,39 @@ const segmentFields = {
66
86
  export class CommandIndex {
67
87
  readonly $projection = 'zhin.command-index/1' as const;
68
88
  readonly #commands: readonly CommandRecord[];
89
+ readonly #routes: readonly CommandRoute[];
90
+ readonly #shortcuts: ReadonlyMap<string, ShortcutEntry>;
69
91
 
70
92
  constructor(
71
93
  slots: readonly Readonly<CapabilitySlot<CommandDefinition>>[],
72
94
  private readonly snapshot: RuntimeSnapshot,
73
95
  ) {
74
96
  const commands: CommandRecord[] = [];
75
- const staticCommands = new Map<string, CommandRecord>();
76
- const dynamicCommands = new Map<string, CommandRecord>();
97
+ const routes: CommandRoute[] = [];
98
+ const occupancy = new Map<string, string>();
99
+ const shortcuts = new Map<string, ShortcutEntry>();
100
+
101
+ const claim = (key: string, source: string): void => {
102
+ const existing = occupancy.get(key);
103
+ if (existing !== undefined) {
104
+ throw new Error(`Duplicate Command route "${key}" (${source} vs ${existing})`);
105
+ }
106
+ occupancy.set(key, source);
107
+ };
108
+
77
109
  for (const slot of slots) {
78
- const segments = runtimeSegments(slot.owner, slot.localName);
110
+ const primarySegments = runtimeSegments(slot.owner, slot.localName);
79
111
  const parameter = slot.definition.$parameter;
80
- assertParameterSegment(segments, parameter, slot.source);
81
- const name = displayName(segments, parameter);
112
+ assertParameterSegment(primarySegments, parameter, slot.source);
113
+ const name = displayName(primarySegments, parameter);
114
+ const alias = normalizeAliasList(slot.definition.alias);
115
+ const permit = slot.definition.permit
116
+ ? Object.freeze([...slot.definition.permit])
117
+ : undefined;
118
+ const shortcutKeys = slot.definition.shortcut
119
+ ? Object.freeze(Object.keys(slot.definition.shortcut).map((key) => key.trim()))
120
+ : undefined;
121
+
82
122
  const record: CommandRecord = Object.freeze({
83
123
  name,
84
124
  description: slot.definition.description,
@@ -87,23 +127,57 @@ export class CommandIndex {
87
127
  ...parameter,
88
128
  required: isRequiredParameter(parameter),
89
129
  }] : []),
130
+ ...(alias ? { alias } : {}),
131
+ ...(permit ? { permit } : {}),
132
+ ...(shortcutKeys && shortcutKeys.length > 0 ? { shortcut: shortcutKeys } : {}),
90
133
  slot,
91
- segments: Object.freeze(segments),
134
+ segments: Object.freeze(primarySegments),
92
135
  parameter,
93
- matcher: new SegmentMatcher(matcherPattern(segments, parameter), segmentFields),
94
136
  });
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);
137
+
138
+ claim(occupancyKey(primarySegments, parameter), slot.source);
139
+ routes.push({
140
+ record,
141
+ segments: primarySegments,
142
+ matcher: new SegmentMatcher(matcherPattern(primarySegments, parameter), segmentFields),
143
+ kind: 'primary',
144
+ });
145
+
146
+ if (alias) {
147
+ for (const entry of alias) {
148
+ const aliasSegments = aliasRuntimeSegments(slot.owner, entry, primarySegments);
149
+ assertParameterSegment(aliasSegments, parameter, `${slot.source} alias ${JSON.stringify(entry)}`);
150
+ claim(occupancyKey(aliasSegments, parameter), `${slot.source} alias ${JSON.stringify(entry)}`);
151
+ routes.push({
152
+ record,
153
+ segments: Object.freeze(aliasSegments),
154
+ matcher: new SegmentMatcher(matcherPattern(aliasSegments, parameter), segmentFields),
155
+ kind: 'alias',
156
+ });
157
+ }
103
158
  }
159
+
160
+ if (slot.definition.shortcut) {
161
+ for (const [rawTrigger, prefill] of Object.entries(slot.definition.shortcut)) {
162
+ const trigger = rawTrigger.trim();
163
+ claim(trigger, `${slot.source} shortcut ${JSON.stringify(trigger)}`);
164
+ shortcuts.set(trigger, {
165
+ record,
166
+ params: Object.freeze(resolveShortcutParams(
167
+ slot.definition,
168
+ prefill,
169
+ `${slot.source} shortcut ${JSON.stringify(trigger)}`,
170
+ )),
171
+ });
172
+ }
173
+ }
174
+
104
175
  commands.push(record);
105
176
  }
106
- this.#commands = Object.freeze(commands.sort(compareCommands));
177
+
178
+ this.#commands = Object.freeze(commands.sort(compareRecords));
179
+ this.#routes = Object.freeze(routes.sort(compareRoutes));
180
+ this.#shortcuts = shortcuts;
107
181
  }
108
182
 
109
183
  list(): readonly CommandDescriptor[] {
@@ -125,12 +199,13 @@ export class CommandIndex {
125
199
  this.#diagnoseParameter(name);
126
200
  throw new Error(`Unknown Command: ${name}`);
127
201
  }
202
+ // Host / 无 session:跳过 permit;无 source 时函数默认值得到空 session。
128
203
  return match.command.slot.definition.execute(
129
204
  createCommandContext(
130
205
  this.snapshot,
131
206
  match.command.slot.owner,
132
207
  args,
133
- match.params,
208
+ resolveDynamicParams(match.params, undefined),
134
209
  ),
135
210
  );
136
211
  }
@@ -139,15 +214,41 @@ export class CommandIndex {
139
214
  input: CommandMatchInput,
140
215
  source: unknown = undefined,
141
216
  ): Promise<CommandDispatchResult> {
217
+ const shortcut = this.#matchShortcut(input);
218
+ if (shortcut) {
219
+ if (!(await this.#permitAllows(shortcut.record, source))) {
220
+ return Object.freeze({ matched: false });
221
+ }
222
+ const value = await shortcut.record.slot.definition.execute(
223
+ createCommandContext(
224
+ this.snapshot,
225
+ shortcut.record.slot.owner,
226
+ Object.freeze([]),
227
+ resolveDynamicParams(shortcut.params, source),
228
+ source,
229
+ Object.freeze([]),
230
+ ),
231
+ );
232
+ return Object.freeze({
233
+ matched: true,
234
+ command: shortcut.record.name,
235
+ owner: shortcut.record.slot.owner,
236
+ value,
237
+ });
238
+ }
239
+
142
240
  const match = this.#match(input, false);
143
241
  if (!match) return Object.freeze({ matched: false });
242
+ if (!(await this.#permitAllows(match.command, source))) {
243
+ return Object.freeze({ matched: false });
244
+ }
144
245
  const args = textArgs(match.remaining);
145
246
  const value = await match.command.slot.definition.execute(
146
247
  createCommandContext(
147
248
  this.snapshot,
148
249
  match.command.slot.owner,
149
250
  args,
150
- match.params,
251
+ resolveDynamicParams(match.params, source),
151
252
  source,
152
253
  match.remaining,
153
254
  ),
@@ -160,6 +261,35 @@ export class CommandIndex {
160
261
  });
161
262
  }
162
263
 
264
+ async #permitAllows(record: CommandRecord, source: unknown): Promise<boolean> {
265
+ const permits = record.permit;
266
+ if (!permits || permits.length === 0) return true;
267
+ if (!hasImSession(source)) return true;
268
+ const host = this.#resolveHost();
269
+ if (!host) return false;
270
+ const subject = toPermissionSubject(resolveCommandSession(source));
271
+ return host.checkAll(permits, subject);
272
+ }
273
+
274
+ #resolveHost(): PermissionHost | undefined {
275
+ try {
276
+ const resources = this.snapshot.resources.get(this.snapshot.root);
277
+ if (!resources) return undefined;
278
+ const host = resources.get(permissionHostToken.id);
279
+ return host && typeof (host as PermissionHost).check === 'function'
280
+ ? host as PermissionHost
281
+ : undefined;
282
+ } catch {
283
+ return undefined;
284
+ }
285
+ }
286
+
287
+ #matchShortcut(input: CommandMatchInput): ShortcutEntry | undefined {
288
+ const text = exactMessageText(input);
289
+ if (text === undefined) return undefined;
290
+ return this.#shortcuts.get(text);
291
+ }
292
+
163
293
  #match(input: CommandMatchInput, exact: boolean): CommandMatch | undefined {
164
294
  const segments = normalizeSegments(
165
295
  typeof input === 'string'
@@ -170,29 +300,26 @@ export class CommandIndex {
170
300
  );
171
301
  if (segments.length === 0) return undefined;
172
302
 
173
- for (const command of this.#commands) {
174
- const result = command.matcher.match(asMatcherSegments(segments));
303
+ for (const route of this.#routes) {
304
+ const result = route.matcher.match(asMatcherSegments(segments));
175
305
  if (!result || !hasCommandBoundary(result.remaining)) continue;
176
- const parameter = command.parameter;
177
- const params: Record<string, CommandParameterValue> = { ...result.params };
306
+ const parameter = route.record.parameter;
307
+ const params: Record<string, CommandDynamicValue> = { ...result.params };
178
308
  if (parameter?.rest) {
179
309
  const raw = result.params[parameter.name];
180
310
  const coerced = coerceRestValues(parameter, Array.isArray(raw) ? raw : []);
181
- // 必需 `[...name]` 捕获所有:零元素视为不匹配;标量逐词转换失败同样不匹配。
182
311
  if (!coerced || (isRequiredParameter(parameter) && coerced.length === 0)) continue;
183
312
  params[parameter.name] = coerced;
184
313
  }
185
314
  const remaining = normalizeSegments(result.remaining);
186
315
  if (exact && remaining.length > 0) continue;
187
- // `[[name]]` 无 default 且未命中时,matcher 对 text 回退 ''、其他类型回退 null;
188
- // 按契约(省略 default 时未匹配为 undefined)删除该键。
189
316
  if (parameter && !parameter.rest && parameter.optional === true
190
317
  && parameter.defaultValue === undefined
191
318
  && (params[parameter.name] === '' || params[parameter.name] === null)) {
192
319
  delete params[parameter.name];
193
320
  }
194
321
  return {
195
- command,
322
+ command: route.record,
196
323
  params: Object.freeze(params),
197
324
  remaining,
198
325
  };
@@ -202,12 +329,12 @@ export class CommandIndex {
202
329
 
203
330
  #diagnoseParameter(name: string): void {
204
331
  const words = splitCommand(name);
205
- for (const command of this.#commands) {
206
- const parameter = command.parameter;
332
+ for (const route of this.#routes) {
333
+ const parameter = route.record.parameter;
207
334
  if (!parameter || parameter.rest) continue;
208
- const parameterIndex = command.segments.findIndex((segment) => segment.startsWith('$'));
209
- if (words.length !== command.segments.length) continue;
210
- if (!command.segments.every((segment, index) =>
335
+ const parameterIndex = route.segments.findIndex((segment) => segment.startsWith('$'));
336
+ if (words.length !== route.segments.length) continue;
337
+ if (!route.segments.every((segment, index) =>
211
338
  index === parameterIndex || segment === words[index])) continue;
212
339
  const value = words[parameterIndex];
213
340
  if (value === undefined || matchesParameter(parameter.type, value)) continue;
@@ -234,6 +361,103 @@ function runtimeSegments(owner: string, localName: string): string[] {
234
361
  return [`${prefix}.${localSegments[0]}`, ...localSegments.slice(1)];
235
362
  }
236
363
 
364
+ /**
365
+ * 用 alias 词序列替换全部本地静态段,再按 owner 规则重挂前缀;动态段保留。
366
+ */
367
+ function aliasRuntimeSegments(
368
+ owner: string,
369
+ alias: string,
370
+ primarySegments: readonly string[],
371
+ ): string[] {
372
+ const aliasTokens = alias.trim().split(/\s+/u).filter(Boolean);
373
+ const dynamicTail = primarySegments.filter((segment) => segment.startsWith('$'));
374
+ if (owner === 'root') return [...aliasTokens, ...dynamicTail];
375
+ const prefix = owner.slice('root/'.length).split('/').join('.');
376
+ return [`${prefix}.${aliasTokens[0]}`, ...aliasTokens.slice(1), ...dynamicTail];
377
+ }
378
+
379
+ function occupancyKey(
380
+ segments: readonly string[],
381
+ parameter: CommandParameterDefinition | undefined,
382
+ ): string {
383
+ return parameter ? routeShape(segments) : segments.join(' ');
384
+ }
385
+
386
+ function normalizeAliasList(
387
+ alias: readonly string[] | undefined,
388
+ ): readonly string[] | undefined {
389
+ if (!alias || alias.length === 0) return undefined;
390
+ return Object.freeze(alias.map((entry) => entry.trim().split(/\s+/u).filter(Boolean).join(' ')));
391
+ }
392
+
393
+ function resolveShortcutParams(
394
+ definition: CommandDefinition,
395
+ prefill: Readonly<Record<string, CommandDynamicValue>>,
396
+ source: string,
397
+ ): Record<string, CommandDynamicValue> {
398
+ const allowed = new Set<string>();
399
+ const parameter = definition.$parameter;
400
+ if (parameter) allowed.add(parameter.name);
401
+ if (definition.params) {
402
+ for (const key of Object.keys(definition.params)) allowed.add(key);
403
+ }
404
+
405
+ for (const key of Object.keys(prefill)) {
406
+ if (!allowed.has(key)) {
407
+ throw new TypeError(
408
+ `Invalid shortcut params for ${source}: unknown key ${JSON.stringify(key)}`,
409
+ );
410
+ }
411
+ }
412
+
413
+ const result: Record<string, CommandDynamicValue> = { ...prefill };
414
+
415
+ if (parameter) {
416
+ if (result[parameter.name] === undefined) {
417
+ if (parameter.defaultValue !== undefined) {
418
+ result[parameter.name] = parameter.defaultValue;
419
+ } else if (isRequiredParameter(parameter)) {
420
+ throw new TypeError(
421
+ `Invalid shortcut params for ${source}: missing required ${parameter.name}`,
422
+ );
423
+ }
424
+ }
425
+ } else if (allowed.size === 0 && Object.keys(prefill).length > 0) {
426
+ throw new TypeError(
427
+ `Invalid shortcut params for ${source}: command has no params declaration`,
428
+ );
429
+ }
430
+
431
+ if (definition.params) {
432
+ for (const [name, schema] of Object.entries(definition.params)) {
433
+ if (result[name] === undefined && schema.default !== undefined) {
434
+ result[name] = schema.default;
435
+ }
436
+ }
437
+ }
438
+
439
+ return result;
440
+ }
441
+
442
+ function hasImSession(source: unknown): boolean {
443
+ if (!source || typeof source !== 'object') return false;
444
+ const conversation = (source as { conversation?: unknown }).conversation;
445
+ return !!conversation && typeof conversation === 'object';
446
+ }
447
+
448
+ function exactMessageText(input: CommandMatchInput): string | undefined {
449
+ if (typeof input === 'string') {
450
+ const trimmed = input.trim();
451
+ return trimmed || undefined;
452
+ }
453
+ // 仅纯单 text 段可作整句 shortcut;含 mention/image 等则不走 shortcut。
454
+ if (input.length !== 1) return undefined;
455
+ const only = input[0];
456
+ if (!only || only.type !== 'text' || typeof only.data.text !== 'string') return undefined;
457
+ const trimmed = only.data.text.trim();
458
+ return trimmed || undefined;
459
+ }
460
+
237
461
  function assertParameterSegment(
238
462
  segments: readonly string[],
239
463
  parameter: CommandParameterDefinition | undefined,
@@ -280,14 +504,13 @@ function matcherPattern(
280
504
  if (!segment.startsWith('$')) return segment;
281
505
  if (!parameter) throw new Error(`Missing Command parameter metadata: ${segment}`);
282
506
  const type = matcherType(parameter.type);
283
- // rest:结构化类型按消息段收集;标量类型先按 text 段收集,再在 #match 里逐词切分转换。
284
507
  if (parameter.rest) {
285
508
  return `[...${parameter.name}:${isStructuredRestType(parameter.type) ? type : 'text'}]`;
286
509
  }
287
510
  if (isRequiredParameter(parameter)) return `<${parameter.name}:${type}>`;
288
511
  return parameter.defaultValue === undefined
289
512
  ? `[${parameter.name}:${type}]`
290
- : `[${parameter.name}:${type}=${String(parameter.defaultValue)}]`;
513
+ : `[${parameter.name}:${type}=${typeof parameter.defaultValue === 'function' ? '<dynamic>' : String(parameter.defaultValue)}]`;
291
514
  }).join(' ');
292
515
  }
293
516
 
@@ -295,7 +518,6 @@ function matcherType(type: CommandParameterType): string {
295
518
  return type === 'string' ? 'word' : type;
296
519
  }
297
520
 
298
- /** rest 参数中按消息段收集(matcher 原生行为)的结构化类型。 */
299
521
  function isStructuredRestType(type: CommandParameterType): boolean {
300
522
  return type === 'mention'
301
523
  || type === 'image'
@@ -306,19 +528,12 @@ function isStructuredRestType(type: CommandParameterType): boolean {
306
528
  || type === 'rps';
307
529
  }
308
530
 
309
- /**
310
- * 捕获所有参数的取值粒度由类型决定:
311
- * - `text` / 结构化类型:逐消息段(matcher 原生结果);
312
- * - `word` / `string`:逐词(空白切分);
313
- * - `number` / `integer` / `float` / `boolean`:逐词切分后逐个转换,任一失败返回 undefined(不匹配)。
314
- */
315
531
  function coerceRestValues(
316
532
  parameter: CommandParameterDefinition,
317
533
  values: readonly unknown[],
318
534
  ): readonly (string | number | boolean)[] | undefined {
319
535
  const type = parameter.type;
320
536
  if (type === 'text' || isStructuredRestType(type)) {
321
- // 逐消息段,保持 matcher 原生提取值(text 为 string,结构化类型可能是 number 等)。
322
537
  return values as readonly (string | number | boolean)[];
323
538
  }
324
539
  const words = values.flatMap((value) =>
@@ -346,18 +561,21 @@ function routeShape(segments: readonly string[]): string {
346
561
  return segments.map((segment) => segment.startsWith('$') ? '$' : segment).join(' ');
347
562
  }
348
563
 
349
- function compareCommands(left: CommandRecord, right: CommandRecord): number {
564
+ function compareRecords(left: CommandRecord, right: CommandRecord): number {
565
+ return left.name.localeCompare(right.name);
566
+ }
567
+
568
+ function compareRoutes(left: CommandRoute, right: CommandRoute): number {
350
569
  return dynamicWeight(left) - dynamicWeight(right)
351
570
  || staticSegmentCount(right.segments) - staticSegmentCount(left.segments)
352
571
  || right.segments.length - left.segments.length
353
- || right.name.length - left.name.length
354
- || left.name.localeCompare(right.name);
572
+ || right.record.name.length - left.record.name.length
573
+ || left.record.name.localeCompare(right.record.name);
355
574
  }
356
575
 
357
- /** 静态 < 单参数 < 捕获所有:更具体的形状优先匹配。 */
358
- function dynamicWeight(command: CommandRecord): number {
359
- if (!command.parameter) return 0;
360
- return command.parameter.rest ? 2 : 1;
576
+ function dynamicWeight(route: CommandRoute): number {
577
+ if (!route.record.parameter) return 0;
578
+ return route.record.parameter.rest ? 2 : 1;
361
579
  }
362
580
 
363
581
  function staticSegmentCount(segments: readonly string[]): number {
@@ -435,16 +653,11 @@ function toDescriptor({
435
653
  slot: _slot,
436
654
  segments: _segments,
437
655
  parameter: _parameter,
438
- matcher: _matcher,
439
656
  ...descriptor
440
657
  }: CommandRecord): CommandDescriptor {
441
658
  return descriptor;
442
659
  }
443
660
 
444
- function duplicateCommand(name: string): Error {
445
- return new Error(`Duplicate runtime Command: ${name}`);
446
- }
447
-
448
661
  export class CommandParameterValueError extends TypeError {
449
662
  constructor(name: string, type: CommandParameterType, value: string) {
450
663
  super(`Invalid value for Command parameter ${name}:${type}: ${value}`);