@zhin.js/command 1.0.2 → 1.0.3

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/lib/provider.d.ts CHANGED
@@ -3,5 +3,5 @@ export declare const commandFeatureId: import("@zhin.js/plugin-runtime").Feature
3
3
  export declare class CommandPathSyntaxError extends TypeError {
4
4
  constructor(file: string, detail?: string);
5
5
  }
6
- declare const commandFeature: Readonly<import("@zhin.js/feature-kit").FeatureProvider<import("./definition.js").CommandDefinition<unknown, unknown, unknown>, CommandIndex>>;
6
+ declare const commandFeature: Readonly<import("@zhin.js/feature-kit").FeatureProvider<import("./definition.js").CommandDefinition<unknown, unknown, import("./definition.js").CommandMessage>, CommandIndex>>;
7
7
  export default commandFeature;
package/lib/provider.js CHANGED
@@ -40,14 +40,34 @@ async function* discoverCommandDirectory(context, directory, ancestors) {
40
40
  function isCommandSegment(value) {
41
41
  return /^[a-z0-9][a-z0-9-]*$/.test(value);
42
42
  }
43
- const dynamicCommandFilePattern = /^\[([a-z][a-zA-Z0-9]*):(string|number|boolean)(?:=([^\]]*))?\]\.tsx?$/;
43
+ const dynamicCommandFilePattern = /^\[([a-z][a-zA-Z0-9]*):([a-z][a-z0-9-]*)(?:=([^\]]*))?\]\.tsx?$/;
44
+ const commandParameterTypes = new Set([
45
+ 'string',
46
+ 'number',
47
+ 'integer',
48
+ 'float',
49
+ 'boolean',
50
+ 'word',
51
+ 'text',
52
+ 'mention',
53
+ 'image',
54
+ 'face',
55
+ 'reply',
56
+ 'forward',
57
+ 'dice',
58
+ 'rps',
59
+ ]);
44
60
  function parseCommandFile(value) {
45
61
  if (/^[a-z0-9][a-z0-9-]*\.tsx?$/.test(value)) {
46
62
  return { localSegment: parse(value).name };
47
63
  }
48
64
  const match = dynamicCommandFilePattern.exec(value);
49
65
  if (match) {
50
- const [, name, type, rawDefault] = match;
66
+ const [, name, rawType, rawDefault] = match;
67
+ if (!name || !rawType || !commandParameterTypes.has(rawType)) {
68
+ throw new CommandPathSyntaxError(value, `unsupported parameter type: ${rawType ?? ''}`);
69
+ }
70
+ const type = rawType;
51
71
  // Metadata can change during HMR while $name keeps the Capability identity stable.
52
72
  const parameter = rawDefault === undefined
53
73
  ? { name, type }
@@ -60,20 +80,38 @@ function parseCommandFile(value) {
60
80
  return undefined;
61
81
  }
62
82
  function parseParameterValue(name, type, value, source) {
63
- if (type === 'string')
83
+ if (type === 'string' || type === 'word' || type === 'text')
64
84
  return value;
65
- if (type === 'number') {
85
+ if (type === 'number' || type === 'integer' || type === 'float') {
66
86
  const number = Number(value);
67
- if (value.trim().length > 0 && Number.isFinite(number))
87
+ if (value.trim().length > 0
88
+ && Number.isFinite(number)
89
+ && (type !== 'integer' || Number.isInteger(number))
90
+ && (type !== 'float' || value.includes('.')))
68
91
  return number;
92
+ throw new CommandPathSyntaxError(source, `default for ${name}:${type} is invalid`);
93
+ }
94
+ if (type === 'boolean') {
95
+ if (value === 'true' || value === 'false')
96
+ return value === 'true';
97
+ throw new CommandPathSyntaxError(source, `default for ${name}:${type} is invalid`);
69
98
  }
70
- else if (value === 'true' || value === 'false') {
71
- return value === 'true';
99
+ if (isStructuredParameter(type)) {
100
+ throw new CommandPathSyntaxError(source, `default for structured parameter ${name}:${type} is not supported`);
72
101
  }
73
102
  throw new CommandPathSyntaxError(source, `default for ${name}:${type} is invalid`);
74
103
  }
104
+ function isStructuredParameter(type) {
105
+ return type === 'mention'
106
+ || type === 'image'
107
+ || type === 'face'
108
+ || type === 'reply'
109
+ || type === 'forward'
110
+ || type === 'dice'
111
+ || type === 'rps';
112
+ }
75
113
  export class CommandPathSyntaxError extends TypeError {
76
- constructor(file, detail = 'expected [name:string|number|boolean=default].ts(x)') {
114
+ constructor(file, detail = 'expected [name:type=default].ts(x)') {
77
115
  super(`Invalid Command path ${file}: ${detail}`);
78
116
  this.name = 'CommandPathSyntaxError';
79
117
  }
@@ -82,6 +120,7 @@ const commandFeature = defineFeatureProvider({
82
120
  protocol: 1,
83
121
  id: commandFeatureId,
84
122
  authoring: {
123
+ setupMethod: 'addCommand',
85
124
  conventions: [commandFiles],
86
125
  validate: parseCommandDefinition,
87
126
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/command",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Convention-based Command Feature for Zhin Plugin Runtime",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -17,8 +17,9 @@
17
17
  "src"
18
18
  ],
19
19
  "dependencies": {
20
- "@zhin.js/feature-kit": "1.0.2",
21
- "@zhin.js/plugin-runtime": "1.1.0"
20
+ "segment-matcher": "^1.0.5",
21
+ "@zhin.js/feature-kit": "1.0.3",
22
+ "@zhin.js/plugin-runtime": "1.1.1"
22
23
  },
23
24
  "devDependencies": {
24
25
  "@types/node": "^26.1.0",
@@ -1,3 +1,8 @@
1
+ import {
2
+ SegmentMatcher,
3
+ TypeMatcherRegistry,
4
+ type MessageSegment,
5
+ } from 'segment-matcher';
1
6
  import type {
2
7
  CapabilitySlot,
3
8
  PluginId,
@@ -9,6 +14,7 @@ import {
9
14
  type CommandParameterDefinition,
10
15
  type CommandParameterType,
11
16
  type CommandParameterValue,
17
+ type CommandSegment,
12
18
  } from './definition.js';
13
19
 
14
20
  export interface CommandParameterDescriptor extends CommandParameterDefinition {
@@ -33,30 +39,47 @@ interface CommandRecord extends CommandDescriptor {
33
39
  readonly slot: Readonly<CapabilitySlot<CommandDefinition>>;
34
40
  readonly segments: readonly string[];
35
41
  readonly parameter?: CommandParameterDefinition;
42
+ readonly matcher: SegmentMatcher;
36
43
  }
37
44
 
38
45
  interface CommandMatch {
39
46
  readonly command: CommandRecord;
40
47
  readonly params: Readonly<Record<string, CommandParameterValue>>;
48
+ readonly remaining: readonly Readonly<CommandSegment>[];
41
49
  }
42
50
 
51
+ export type CommandMatchInput = string | readonly Readonly<CommandSegment>[];
52
+
53
+ const segmentFields = {
54
+ text: 'text',
55
+ mention: 'target',
56
+ at: ['target', 'user_id', 'qq'],
57
+ face: 'id',
58
+ image: (segment: MessageSegment) =>
59
+ segment.data.media ?? segment.data.file ?? segment.data.url ?? segment.data.src,
60
+ reply: ['message_id', 'reply_id', 'id'],
61
+ forward: ['forward_id', 'res_id', 'message_id', 'id'],
62
+ dice: 'result',
63
+ rps: 'result',
64
+ };
65
+
43
66
  export class CommandIndex {
44
67
  readonly $projection = 'zhin.command-index/1' as const;
45
68
  readonly #commands: readonly CommandRecord[];
46
- readonly #staticCommands = new Map<string, CommandRecord>();
47
- readonly #dynamicCommands = new Map<string, CommandRecord>();
48
69
 
49
70
  constructor(
50
71
  slots: readonly Readonly<CapabilitySlot<CommandDefinition>>[],
51
72
  private readonly snapshot: RuntimeSnapshot,
52
73
  ) {
53
74
  const commands: CommandRecord[] = [];
75
+ const staticCommands = new Map<string, CommandRecord>();
76
+ const dynamicCommands = new Map<string, CommandRecord>();
54
77
  for (const slot of slots) {
55
78
  const segments = runtimeSegments(slot.owner, slot.localName);
56
79
  const parameter = slot.definition.$parameter;
57
80
  assertParameterSegment(segments, parameter, slot.source);
58
81
  const name = displayName(segments, parameter);
59
- const record = Object.freeze({
82
+ const record: CommandRecord = Object.freeze({
60
83
  name,
61
84
  description: slot.definition.description,
62
85
  source: slot.source,
@@ -67,19 +90,20 @@ export class CommandIndex {
67
90
  slot,
68
91
  segments: Object.freeze(segments),
69
92
  parameter,
93
+ matcher: new SegmentMatcher(matcherPattern(segments, parameter), segmentFields),
70
94
  });
71
95
  if (!parameter) {
72
96
  const key = segments.join(' ');
73
- if (this.#staticCommands.has(key)) throw duplicateCommand(key);
74
- this.#staticCommands.set(key, record);
97
+ if (staticCommands.has(key)) throw duplicateCommand(key);
98
+ staticCommands.set(key, record);
75
99
  } else {
76
100
  const shape = routeShape(segments);
77
- if (this.#dynamicCommands.has(shape)) throw duplicateCommand(name);
78
- this.#dynamicCommands.set(shape, record);
101
+ if (dynamicCommands.has(shape)) throw duplicateCommand(name);
102
+ dynamicCommands.set(shape, record);
79
103
  }
80
104
  commands.push(record);
81
105
  }
82
- this.#commands = Object.freeze(commands);
106
+ this.#commands = Object.freeze(commands.sort(compareCommands));
83
107
  }
84
108
 
85
109
  list(): readonly CommandDescriptor[] {
@@ -88,7 +112,7 @@ export class CommandIndex {
88
112
 
89
113
  has(name: string): boolean {
90
114
  try {
91
- return this.#match(name) !== undefined;
115
+ return this.#match(name, true) !== undefined;
92
116
  } catch (error) {
93
117
  if (error instanceof CommandParameterValueError) return false;
94
118
  throw error;
@@ -96,8 +120,11 @@ export class CommandIndex {
96
120
  }
97
121
 
98
122
  async execute(name: string, args: readonly string[] = []): Promise<unknown> {
99
- const match = this.#match(name);
100
- if (!match) throw new Error(`Unknown Command: ${name}`);
123
+ const match = this.#match(name, true);
124
+ if (!match) {
125
+ this.#diagnoseParameter(name);
126
+ throw new Error(`Unknown Command: ${name}`);
127
+ }
101
128
  return match.command.slot.definition.execute(
102
129
  createCommandContext(
103
130
  this.snapshot,
@@ -108,55 +135,70 @@ export class CommandIndex {
108
135
  );
109
136
  }
110
137
 
111
- async dispatch(input: string, source: unknown = undefined): Promise<CommandDispatchResult> {
112
- const words = splitCommand(input);
113
- for (let consumed = words.length; consumed > 0; consumed -= 1) {
114
- const match = this.#match(words.slice(0, consumed).join(' '));
115
- if (!match) continue;
116
- const args = words.slice(consumed);
117
- const value = await match.command.slot.definition.execute(
118
- createCommandContext(
119
- this.snapshot,
120
- match.command.slot.owner,
121
- args,
122
- match.params,
123
- source,
124
- ),
125
- );
126
- return Object.freeze({
127
- matched: true,
128
- command: match.command.name,
129
- owner: match.command.slot.owner,
130
- value,
131
- });
138
+ async dispatch(
139
+ input: CommandMatchInput,
140
+ source: unknown = undefined,
141
+ ): Promise<CommandDispatchResult> {
142
+ const match = this.#match(input, false);
143
+ if (!match) return Object.freeze({ matched: false });
144
+ const args = textArgs(match.remaining);
145
+ const value = await match.command.slot.definition.execute(
146
+ createCommandContext(
147
+ this.snapshot,
148
+ match.command.slot.owner,
149
+ args,
150
+ match.params,
151
+ source,
152
+ match.remaining,
153
+ ),
154
+ );
155
+ return Object.freeze({
156
+ matched: true,
157
+ command: match.command.name,
158
+ owner: match.command.slot.owner,
159
+ value,
160
+ });
161
+ }
162
+
163
+ #match(input: CommandMatchInput, exact: boolean): CommandMatch | undefined {
164
+ const segments = normalizeSegments(
165
+ typeof input === 'string'
166
+ ? input.trim()
167
+ ? [{ type: 'text', data: { text: input.trim() } }]
168
+ : []
169
+ : input,
170
+ );
171
+ if (segments.length === 0) return undefined;
172
+
173
+ for (const command of this.#commands) {
174
+ const result = command.matcher.match(asMatcherSegments(segments));
175
+ if (!result || !hasCommandBoundary(result.remaining)) continue;
176
+ const remaining = normalizeSegments(result.remaining);
177
+ if (exact && remaining.length > 0) continue;
178
+ return {
179
+ command,
180
+ params: Object.freeze({ ...result.params }) as Readonly<
181
+ Record<string, CommandParameterValue>
182
+ >,
183
+ remaining,
184
+ };
132
185
  }
133
- return Object.freeze({ matched: false });
186
+ return undefined;
134
187
  }
135
188
 
136
- #match(name: string): CommandMatch | undefined {
189
+ #diagnoseParameter(name: string): void {
137
190
  const words = splitCommand(name);
138
- // A literal file such as list.ts always wins over [name:string].ts.
139
- const staticCommand = this.#staticCommands.get(words.join(' '));
140
- if (staticCommand) return { command: staticCommand, params: Object.freeze({}) };
141
-
142
- for (const command of this.#dynamicCommands.values()) {
143
- const parameter = command.parameter as CommandParameterDefinition;
144
- const optional = parameter.defaultValue !== undefined;
145
- if (words.length !== command.segments.length &&
146
- !(optional && words.length === command.segments.length - 1)) continue;
191
+ for (const command of this.#commands) {
192
+ const parameter = command.parameter;
193
+ if (!parameter) continue;
147
194
  const parameterIndex = command.segments.findIndex((segment) => segment.startsWith('$'));
195
+ if (words.length !== command.segments.length) continue;
148
196
  if (!command.segments.every((segment, index) =>
149
197
  index === parameterIndex || segment === words[index])) continue;
150
- const rawValue = words[parameterIndex];
151
- const value = rawValue === undefined
152
- ? parameter.defaultValue as CommandParameterValue
153
- : parseRuntimeValue(parameter, rawValue);
154
- return {
155
- command,
156
- params: Object.freeze({ [parameter.name]: value }),
157
- };
198
+ const value = words[parameterIndex];
199
+ if (value === undefined || matchesParameter(parameter.type, value)) continue;
200
+ throw new CommandParameterValueError(parameter.name, parameter.type, value);
158
201
  }
159
- return undefined;
160
202
  }
161
203
  }
162
204
 
@@ -179,9 +221,9 @@ function assertParameterSegment(
179
221
  ): void {
180
222
  const dynamicSegments = segments.filter((segment) => segment.startsWith('$'));
181
223
  if (!parameter && dynamicSegments.length === 0) return;
182
- if (parameter && dynamicSegments.length === 1 &&
183
- dynamicSegments[0] === `$${parameter.name}` &&
184
- segments.at(-1) === dynamicSegments[0]) return;
224
+ if (parameter && dynamicSegments.length === 1
225
+ && dynamicSegments[0] === `$${parameter.name}`
226
+ && segments.at(-1) === dynamicSegments[0]) return;
185
227
  throw new Error(`Broken dynamic Command identity for ${source}`);
186
228
  }
187
229
 
@@ -197,33 +239,114 @@ function displayName(
197
239
  }).join(' ');
198
240
  }
199
241
 
242
+ function matcherPattern(
243
+ segments: readonly string[],
244
+ parameter: CommandParameterDefinition | undefined,
245
+ ): string {
246
+ return segments.map((segment) => {
247
+ if (!segment.startsWith('$')) return segment;
248
+ if (!parameter) throw new Error(`Missing Command parameter metadata: ${segment}`);
249
+ const type = matcherType(parameter.type);
250
+ return parameter.defaultValue === undefined
251
+ ? `<${parameter.name}:${type}>`
252
+ : `[${parameter.name}:${type}=${String(parameter.defaultValue)}]`;
253
+ }).join(' ');
254
+ }
255
+
256
+ function matcherType(type: CommandParameterType): string {
257
+ return type === 'string' ? 'word' : type;
258
+ }
259
+
200
260
  function routeShape(segments: readonly string[]): string {
201
261
  return segments.map((segment) => segment.startsWith('$') ? '$' : segment).join(' ');
202
262
  }
203
263
 
264
+ function compareCommands(left: CommandRecord, right: CommandRecord): number {
265
+ const leftDynamic = left.parameter ? 1 : 0;
266
+ const rightDynamic = right.parameter ? 1 : 0;
267
+ return leftDynamic - rightDynamic
268
+ || staticSegmentCount(right.segments) - staticSegmentCount(left.segments)
269
+ || right.segments.length - left.segments.length
270
+ || right.name.length - left.name.length
271
+ || left.name.localeCompare(right.name);
272
+ }
273
+
274
+ function staticSegmentCount(segments: readonly string[]): number {
275
+ return segments.filter((segment) => !segment.startsWith('$')).length;
276
+ }
277
+
204
278
  function splitCommand(value: string): readonly string[] {
205
279
  const normalized = value.trim();
206
280
  return normalized ? normalized.split(/\s+/) : [];
207
281
  }
208
282
 
209
- function parseRuntimeValue(
210
- parameter: CommandParameterDefinition,
211
- value: string,
212
- ): CommandParameterValue {
213
- if (parameter.type === 'string') return value;
214
- if (parameter.type === 'number') {
215
- const number = Number(value);
216
- if (value.trim().length > 0 && Number.isFinite(number)) return number;
217
- } else if (value === 'true' || value === 'false') {
218
- return value === 'true';
283
+ function matchesParameter(type: CommandParameterType, value: string): boolean {
284
+ const matcher = TypeMatcherRegistry.getMatcher(matcherType(type));
285
+ return matcher ? matcher.match(value).success : false;
286
+ }
287
+
288
+ function asMatcherSegments(
289
+ segments: readonly Readonly<CommandSegment>[],
290
+ ): MessageSegment[] {
291
+ return segments.map((segment) => ({
292
+ type: typeof segment.type === 'string' ? segment.type : { name: segment.type.name },
293
+ data: { ...segment.data },
294
+ }));
295
+ }
296
+
297
+ function hasCommandBoundary(segments: readonly MessageSegment[]): boolean {
298
+ const first = segments[0];
299
+ if (!first || first.type !== 'text') return true;
300
+ const text = first.data.text;
301
+ return typeof text !== 'string' || text.length === 0 || /^\s/u.test(text);
302
+ }
303
+
304
+ function normalizeSegments(
305
+ input: readonly Readonly<CommandSegment>[],
306
+ ): readonly Readonly<CommandSegment>[] {
307
+ const segments = input.map((segment) => ({
308
+ type: typeof segment.type === 'string' ? segment.type : { name: segment.type.name },
309
+ data: { ...segment.data },
310
+ }));
311
+ trimBoundary(segments, 'start');
312
+ trimBoundary(segments, 'end');
313
+ return Object.freeze(segments.map((segment) => Object.freeze({
314
+ type: typeof segment.type === 'string'
315
+ ? segment.type
316
+ : Object.freeze({ name: segment.type.name }),
317
+ data: Object.freeze(segment.data),
318
+ })));
319
+ }
320
+
321
+ function trimBoundary(
322
+ segments: Array<{ type: string | { name: string }; data: Record<string, unknown> }>,
323
+ side: 'start' | 'end',
324
+ ): void {
325
+ while (segments.length > 0) {
326
+ const index = side === 'start' ? 0 : segments.length - 1;
327
+ const segment = segments[index];
328
+ if (!segment || segment.type !== 'text' || typeof segment.data.text !== 'string') return;
329
+ const text = side === 'start' ? segment.data.text.trimStart() : segment.data.text.trimEnd();
330
+ if (text) {
331
+ segment.data.text = text;
332
+ return;
333
+ }
334
+ segments.splice(index, 1);
219
335
  }
220
- throw new CommandParameterValueError(parameter.name, parameter.type, value);
336
+ }
337
+
338
+ function textArgs(segments: readonly Readonly<CommandSegment>[]): readonly string[] {
339
+ return Object.freeze(segments.flatMap((segment) => {
340
+ if (segment.type !== 'text' || typeof segment.data.text !== 'string') return [];
341
+ return splitCommand(segment.data.text);
342
+ }));
221
343
  }
222
344
 
223
345
  function toDescriptor({
224
346
  slot: _slot,
225
347
  segments: _segments,
226
348
  parameter: _parameter,
349
+ matcher: _matcher,
227
350
  ...descriptor
228
351
  }: CommandRecord): CommandDescriptor {
229
352
  return descriptor;