@zhin.js/command 1.2.0 → 1.2.1

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.
@@ -25,6 +25,8 @@ export interface CommandDescriptor {
25
25
  readonly execution?: CommandDefinition['execution'];
26
26
  /** shortcut 触发键列表(不含预填 params)。 */
27
27
  readonly shortcut?: readonly string[];
28
+ /** `match` 正则的字面形式(如 `/^demo$/i`)。 */
29
+ readonly match?: string;
28
30
  }
29
31
  /** One dispatch reads the caller's exact operation generation and presentation. */
30
32
  export interface CommandDispatchRequest {
@@ -90,6 +90,7 @@ export class CommandIndex {
90
90
  ...(alias ? { alias } : {}),
91
91
  ...(permit ? { permit } : {}),
92
92
  ...(shortcutKeys && shortcutKeys.length > 0 ? { shortcut: shortcutKeys } : {}),
93
+ ...(slot.definition.match ? { match: String(slot.definition.match), pattern: slot.definition.match } : {}),
93
94
  slot,
94
95
  segments: Object.freeze(primarySegments),
95
96
  preferredSegments: Object.freeze(Object.fromEntries(Object.entries(slot.definition.preferredAliasI18n ?? {}).map(([locale, entry]) => [locale, Object.freeze([...namespace, ...aliasRuntimeSegments(entry, primarySegments.slice(namespace.length))])]))),
@@ -213,9 +214,10 @@ export class CommandIndex {
213
214
  }
214
215
  }
215
216
  const shortcut = this.#matchShortcut(input);
216
- const match = shortcut
217
+ const routed = shortcut
217
218
  ? { command: shortcut.record, params: shortcut.params, remaining: Object.freeze([]), helpRequested: false }
218
219
  : this.#match(input, false);
220
+ const match = routed ? withPattern(routed, input) : this.#matchPattern(input);
219
221
  if (!match) {
220
222
  const candidate = this.#invalidRoute(input);
221
223
  if (!candidate)
@@ -257,7 +259,8 @@ export class CommandIndex {
257
259
  operation,
258
260
  interaction: interactionFactory?.(authorSource), adapter: definition.adapter,
259
261
  presentation: { locale, formatCommand: (localPath, owner = match.command.owner) => this.formatCommand(owner, localPath, source && typeof source === 'object' && 'conversation' in source ? commandPrefix : undefined, locale) },
260
- }, { args: textArgs(parsed.remaining), params: parsed.params, segments: parsed.remaining, options: parsed.options }));
262
+ }, { args: textArgs(parsed.remaining), params: parsed.params, segments: parsed.remaining, options: parsed.options,
263
+ ...(match.regex ? { match: match.regex } : {}) }));
261
264
  return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value });
262
265
  }
263
266
  finally {
@@ -336,6 +339,18 @@ export class CommandIndex {
336
339
  return undefined;
337
340
  }
338
341
  }
342
+ /** `match` 正则:路由不中时,按指令列表顺序取第一条正文符合的指令(只看纯文本消息)。 */
343
+ #matchPattern(input) {
344
+ const text = exactMessageText(input);
345
+ if (text === undefined)
346
+ return undefined;
347
+ for (const command of this.#commands) {
348
+ const regex = command.pattern?.exec(text);
349
+ if (regex)
350
+ return { command, params: Object.freeze({}), remaining: Object.freeze([]), helpRequested: false, regex };
351
+ }
352
+ return undefined;
353
+ }
339
354
  #matchShortcut(input) {
340
355
  const text = exactMessageText(input);
341
356
  if (text === undefined)
@@ -881,7 +896,14 @@ function textArgs(segments) {
881
896
  return splitCommand(segment.data.text);
882
897
  }));
883
898
  }
884
- function toDescriptor({ slot: _slot, segments: _segments, parameter: _parameter, ...descriptor }) {
899
+ /** A routed command whose `match` also fits the text exposes the match result too. */
900
+ function withPattern(match, input) {
901
+ const pattern = match.command.pattern;
902
+ const text = pattern ? exactMessageText(input) : undefined;
903
+ const regex = pattern && text !== undefined ? pattern.exec(text) : null;
904
+ return regex ? { ...match, regex } : match;
905
+ }
906
+ function toDescriptor({ slot: _slot, segments: _segments, parameter: _parameter, pattern: _pattern, ...descriptor }) {
885
907
  return descriptor;
886
908
  }
887
909
  export class CommandParameterValueError extends TypeError {
@@ -254,6 +254,11 @@ export interface CommandContext<TConfig = unknown, TInput extends CommandMessage
254
254
  */
255
255
  /** Canonical user-facing input/confirmation/selection module. */
256
256
  readonly interaction?: UserInteraction;
257
+ /**
258
+ * 由 `match` 正则触发时的匹配结果(捕获组、具名组 `match.groups`)。
259
+ * 走指令名路由且正文同样符合 `match` 时也会填入;其余情况为 `undefined`。
260
+ */
261
+ readonly match?: Readonly<RegExpExecArray>;
257
262
  }
258
263
  export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput extends CommandMessage = CommandMessage, TAdapter extends string | undefined = string | undefined> {
259
264
  /** @internal Runtime feature brand. */
@@ -289,6 +294,17 @@ export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput
289
294
  * 与普通路由一样是全局用户输入,冲突会在 generation 构建时拒绝。
290
295
  */
291
296
  readonly shortcut?: Readonly<Record<string, Readonly<Record<string, CommandDynamicValue>>>>;
297
+ /**
298
+ * 正则触发:消息正文(去掉指令前缀并 trim 后、只含文本时)符合此正则即执行本指令,
299
+ * 匹配结果见 `context.match`。指令名路由照常可用,先按路由匹配,路由不中再试正则;
300
+ * 多条指令的正则都符合时取指令列表顺序中的第一条。不允许 `g` / `y` 标志(有状态)。
301
+ */
302
+ readonly match?: RegExp;
303
+ /**
304
+ * 固定回复:与 `execute` 二选一,等价于 `execute: () => reply`。
305
+ * 可以是文本、消息段或 JSX 等任何 `execute` 可返回的内容。
306
+ */
307
+ readonly reply?: TResult;
292
308
  execute(context: CommandContext<TConfig, TInput, TAdapter>): TResult | Promise<TResult>;
293
309
  }
294
310
  declare module '@zhin.js/plugin-runtime' {
@@ -300,18 +316,23 @@ declare module '@zhin.js/plugin-runtime' {
300
316
  * 定义一个命令模块(`commands/` 约定目录下默认导出)。
301
317
  * @public 用户侧创作面,承诺 semver(见 docs/contributing/public-api-surface.md)。
302
318
  */
303
- type CommandAuthoringDefinition<TConfig, TResult, TInput extends CommandMessage, P extends CommandParams, O extends CommandOptions> = (Omit<CommandDefinition<TConfig, TResult, TInput, undefined>, '$feature' | '$parameter' | 'params' | 'options' | 'execute'> & {
319
+ type CommandAuthoringDefinition<TConfig, TResult, TInput extends CommandMessage, P extends CommandParams, O extends CommandOptions> = Omit<CommandDefinition<TConfig, TResult, TInput, undefined>, '$feature' | '$parameter' | 'params' | 'options' | 'execute' | 'reply'> & {
304
320
  readonly params?: P;
305
321
  readonly options?: O;
306
- execute(context: TypedCommandContext<TConfig, TInput, undefined, P, O>): TResult | Promise<TResult>;
307
- }) | {
308
- [TAdapter in RegisteredAdapterName]: Omit<CommandDefinition<TConfig, TResult, TInput, TAdapter>, '$feature' | '$parameter' | 'params' | 'options' | 'execute'> & {
322
+ } & CommandBody<TConfig, TResult, TInput, undefined, P, O> | {
323
+ [TAdapter in RegisteredAdapterName]: Omit<CommandDefinition<TConfig, TResult, TInput, TAdapter>, '$feature' | '$parameter' | 'params' | 'options' | 'execute' | 'reply'> & {
309
324
  readonly adapter: TAdapter;
310
325
  readonly params?: P;
311
326
  readonly options?: O;
312
- execute(context: TypedCommandContext<TConfig, TInput, TAdapter, P, O>): TResult | Promise<TResult>;
313
- };
327
+ } & CommandBody<TConfig, TResult, TInput, TAdapter, P, O>;
314
328
  }[RegisteredAdapterName];
329
+ /** `execute` 与固定回复 `reply` 二选一。 */
330
+ type CommandBody<TConfig, TResult, TInput extends CommandMessage, TAdapter extends string | undefined, P extends CommandParams, O extends CommandOptions> = {
331
+ execute(context: TypedCommandContext<TConfig, TInput, TAdapter, P, O>): TResult | Promise<TResult>;
332
+ } | {
333
+ readonly reply: TResult;
334
+ readonly execute?: undefined;
335
+ };
315
336
  type TypedCommandContext<TConfig, TInput extends CommandMessage, TAdapter extends string | undefined, P extends CommandParams, O extends CommandOptions> = Omit<CommandContext<TConfig, TInput, TAdapter>, 'params' | 'options'> & {
316
337
  readonly params: keyof P extends never ? Readonly<Record<string, CommandParameterValue>> : InferCommandParams<P>;
317
338
  readonly options: InferCommandOptions<O>;
@@ -339,6 +360,8 @@ export declare namespace defineCommand {
339
360
  }
340
361
  /** @internal Validate display-only translations without changing their fallback text. */
341
362
  export declare function validateDescriptionI18n(value: CommandDefinition['descriptionI18n']): void;
363
+ /** @internal `match` is tested many times per generation, so stateful flags would skip matches. */
364
+ export declare function validateCommandMatch(match: unknown): void;
342
365
  /** @internal Convention-loader assembly helper. */
343
366
  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>>;
344
367
  /** @internal Runtime validation for convention-discovered modules. */
@@ -355,6 +378,7 @@ export interface CommandInvocationArguments {
355
378
  readonly params?: Readonly<Record<string, CommandParameterValue>>;
356
379
  readonly options?: Readonly<Record<string, CommandParameterValue>>;
357
380
  readonly segments?: readonly Readonly<CommandSegment>[];
381
+ readonly match?: RegExpExecArray;
358
382
  }
359
383
  /** Author and lifetime authority captured by the dispatcher for this invocation. */
360
384
  export interface CommandInvocationOperation {
package/lib/definition.js CHANGED
@@ -26,9 +26,7 @@ export const commandParameterTypes = new Set([
26
26
  'rps',
27
27
  ]);
28
28
  export function defineCommand(definition) {
29
- if (typeof definition.execute !== 'function') {
30
- throw new TypeError('Command execute must be a function');
31
- }
29
+ const body = normalizeCommandBody(definition);
32
30
  validateAdapterName(definition.adapter);
33
31
  if (definition.params !== undefined) {
34
32
  if (!definition.params || typeof definition.params !== 'object') {
@@ -47,8 +45,9 @@ export function defineCommand(definition) {
47
45
  validatePreferredAliases(definition);
48
46
  validateCommandPermit(definition.permit);
49
47
  validateCommandShortcutShape(definition.shortcut);
48
+ validateCommandMatch(definition.match);
50
49
  const execution = normalizeExecutionPolicy(definition.execution);
51
- return Object.freeze({ $feature: commandBrand, ...definition, ...(execution ? { execution } : {}) });
50
+ return Object.freeze({ $feature: commandBrand, ...definition, ...body, ...(execution ? { execution } : {}) });
52
51
  }
53
52
  /** @internal Validate display-only translations without changing their fallback text. */
54
53
  export function validateDescriptionI18n(value) {
@@ -77,6 +76,32 @@ export function validateDescriptionI18n(value) {
77
76
  defineCommand.withConfig = function withConfig() {
78
77
  return (definition) => defineCommand(definition);
79
78
  };
79
+ function normalizeCommandBody(definition) {
80
+ const hasReply = Object.hasOwn(definition, 'reply') && definition.reply !== undefined;
81
+ if (typeof definition.execute === 'function') {
82
+ if (hasReply)
83
+ throw new TypeError('Command declares both execute and reply; use one of them');
84
+ return { execute: definition.execute };
85
+ }
86
+ if (definition.execute !== undefined)
87
+ throw new TypeError('Command execute must be a function');
88
+ if (!hasReply)
89
+ throw new TypeError('Command needs execute or reply');
90
+ if (typeof definition.reply === 'function') {
91
+ throw new TypeError('Command reply is a fixed value; pass a function as execute instead');
92
+ }
93
+ const reply = definition.reply;
94
+ return { execute: () => reply };
95
+ }
96
+ /** @internal `match` is tested many times per generation, so stateful flags would skip matches. */
97
+ export function validateCommandMatch(match) {
98
+ if (match === undefined)
99
+ return;
100
+ if (!(match instanceof RegExp))
101
+ throw new TypeError('Command match must be a RegExp');
102
+ if (match.global || match.sticky)
103
+ throw new TypeError('Command match must not use the g or y flag');
104
+ }
80
105
  function validateAdapterName(adapter) {
81
106
  if (adapter !== undefined && (typeof adapter !== 'string' || adapter.trim() === '')) {
82
107
  throw new TypeError('Command adapter must be a non-empty string');
@@ -176,7 +201,7 @@ export function resolveDynamicParams(params, source) {
176
201
  /** @internal Command dispatcher assembly helper; each value has one owner. */
177
202
  export function createCommandContext(operation, invocation) {
178
203
  const { snapshot, ownerId, input, interaction, adapter, presentation } = operation;
179
- const { args, params = {}, options = {}, segments = [] } = invocation;
204
+ const { args, params = {}, options = {}, segments = [], match } = invocation;
180
205
  const context = createCapabilityContext({ snapshot, ownerId, signal: readOperationInputSignal(input), operation: operation.operation });
181
206
  const session = resolveCommandSession(input);
182
207
  const parameterValues = { __proto__: null, ...params };
@@ -192,6 +217,7 @@ export function createCommandContext(operation, invocation) {
192
217
  segments: freezeSegments(segments),
193
218
  ...(input !== undefined ? { input: input } : {}),
194
219
  ...(interaction !== undefined ? { interaction } : {}),
220
+ ...(match !== undefined ? { match: Object.freeze(match) } : {}),
195
221
  };
196
222
  Object.defineProperty(result, 'session', {
197
223
  enumerable: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/command",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Convention-based Command Feature for Zhin Plugin Runtime",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -21,9 +21,9 @@
21
21
  "dependencies": {
22
22
  "segment-matcher": "^1.0.5",
23
23
  "@zhin.js/feature-kit": "1.1.16",
24
- "@zhin.js/plugin-runtime": "1.2.0",
24
+ "@zhin.js/im-contract": "1.1.14",
25
25
  "@zhin.js/permission": "1.2.0",
26
- "@zhin.js/im-contract": "1.1.14"
26
+ "@zhin.js/plugin-runtime": "1.2.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^26.1.2",