@zhin.js/command 1.1.17 → 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.
package/README.md CHANGED
@@ -16,7 +16,7 @@ import { defineCommand } from 'zhin.js/command';
16
16
  export default defineCommand({
17
17
  description: 'List GitHub issues',
18
18
  alias: ['issues'], // 可多词;owner 不参与用户路由
19
- permit: ['adapter(icqq)', 'role(master)'], // 数组 AND;失败返回 denied
19
+ permit: ['adapter(icqq)', 'owner'], // 数组 AND;失败返回 denied
20
20
  // shortcut: { '列 issue': {} }, // 全局整句
21
21
  execute: ({ args }) => `issues:${args.join(',')}`,
22
22
  });
@@ -154,7 +154,7 @@ parent 和 thread 独立计算。没有完整可信身份或真实 IM operation
154
154
  新策略的并发数用于新准入,原来已开始的冷却保留到原到期时间。移除策略即停止新请求的预算检查。
155
155
  状态只保留在当前进程,进程重启后重置;容量满时拒绝新身份,不驱逐活跃或仍在冷却的记录。
156
156
 
157
- 进度默认关闭,只在业务等待阶段发送一次“正在处理中,请稍候。”,走当前平台统一发送链路。
157
+ 进度默认关闭,只在业务等待阶段发送一次“正在处理中,请稍候。”(按当前操作的语言本地化),走当前平台统一发送链路。
158
158
  开始自动最终回复前停止尚未开始的提示,并等待已开始的提示完成;已开始的 I/O 随原 lease 排空。
159
159
  执行内显式 `$reply()` 不会停止进度计时。提示失败不重试,也不表示任务取消或远端已经展示。
160
160
  `@zhin.js/command/internal/execution` 是 Core 装配使用的内部入口,不是插件创作 API。
@@ -173,7 +173,7 @@ pnpm --filter @zhin.js/command test
173
173
  ```
174
174
 
175
175
  完整用户契约见[命令创作指南](../../../docs/authoring/commands.md),架构迁移背景见
176
- [Plugin Runtime 原位迁移](../../../docs/architecture/target-implementation/in-place-migration.md)。
176
+ [Legacy 概念迁移](../../../docs/contributing/legacy-concepts.md)。
177
177
 
178
178
 
179
179
  命令可声明 `descriptionI18n: { en: 'Create a reminder' }` 翻译描述。若静态目录名是中文,可沿用 `alias` 提供英文入口,并用 `preferredAliasI18n` 显式选择英文展示:
@@ -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 {
@@ -46,6 +48,14 @@ export interface CommandDispatchResult {
46
48
  readonly owner?: PluginId;
47
49
  readonly value?: unknown;
48
50
  readonly menu?: CommandMenuPage;
51
+ /**
52
+ * `denied` only: the first unmet permit and a one-sentence reason for the sender (e.g. 「机器人不是本群管理员」).
53
+ * Undefined when no Permission host / source answered.
54
+ */
55
+ readonly denial?: Readonly<{
56
+ permit: string;
57
+ reason: string;
58
+ }>;
49
59
  }
50
60
  /** Semantic menu navigation; the IM composition layer supplies platform controls. */
51
61
  export interface CommandMenuNavigation {
@@ -1,7 +1,7 @@
1
1
  import { SegmentMatcher, TypeMatcherRegistry, } from 'segment-matcher';
2
2
  import { htmlRendererToken, resolveLocale, } from '@zhin.js/plugin-runtime';
3
3
  import { bindOperationInput } from '@zhin.js/feature-kit';
4
- import { permissionHostToken, } from '@zhin.js/permission';
4
+ import { describeDeniedPermit, permissionHostToken, } from '@zhin.js/permission';
5
5
  import { commandAdapterMatches, toCommandPermissionSubject } from './permission-explanation.js';
6
6
  import { commandUsage, commandHelp, parseCommandArguments, requestsCommandHelp, commandValidationIssue } from './arguments.js';
7
7
  import { commandDescription, commandMessages } from './messages.js';
@@ -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))])]))),
@@ -175,7 +176,7 @@ export class CommandIndex {
175
176
  this.#diagnoseParameter(name);
176
177
  throw new Error(`Unknown Command: ${name}`);
177
178
  }
178
- if (!(await this.#permitAllows(match.command, undefined, operationSnapshot)))
179
+ if ((await this.#permitDenial(match.command, undefined, operationSnapshot)) !== undefined)
179
180
  throw new Error(`Permission denied for Command: ${match.command.name}`);
180
181
  const definition = match.command.slot.definition;
181
182
  const supplied = [...match.remaining, ...(args.length > 0 ? [{ type: 'text', data: { text: args.join(' ') } }] : [])];
@@ -201,7 +202,7 @@ export class CommandIndex {
201
202
  const prefix = commandPrefix ?? '';
202
203
  if (this.#menu) {
203
204
  const keyword = this.#menu.keyword ?? commandMessages(locale, 'menuKeyword');
204
- const menuResult = this.#dispatchMenu(input, { commandPrefix: prefix, snapshot: operationSnapshot, locale, keyword });
205
+ const menuResult = await this.#dispatchMenu(input, { commandPrefix: prefix, snapshot: operationSnapshot, locale, keyword, source });
205
206
  if (menuResult !== undefined) {
206
207
  return Object.freeze({
207
208
  matched: true,
@@ -213,17 +214,19 @@ 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)
222
224
  return Object.freeze({ matched: false, status: 'unmatched' });
223
225
  if (!commandAdapterMatches(candidate, source))
224
226
  return Object.freeze({ matched: false, status: 'unmatched' });
225
- if (!(await this.#permitAllows(candidate, source, operationSnapshot)))
226
- return this.#denied(candidate);
227
+ const candidateDenial = await this.#permitDenial(candidate, source, operationSnapshot);
228
+ if (candidateDenial !== undefined)
229
+ return this.#denied(candidate, candidateDenial, locale);
227
230
  const help = commandHelp(commandDisplayName(candidate, locale), candidate.slot.definition, prefix, locale);
228
231
  const segments = normalizeSegments(typeof input === 'string' ? [{ type: 'text', data: { text: input } }] : input);
229
232
  if (requestsCommandHelp(segments))
@@ -233,8 +236,9 @@ export class CommandIndex {
233
236
  }
234
237
  if (!commandAdapterMatches(match.command, source))
235
238
  return Object.freeze({ matched: false, status: 'unmatched' });
236
- if (!(await this.#permitAllows(match.command, source, operationSnapshot)))
237
- return this.#denied(match.command);
239
+ const denial = await this.#permitDenial(match.command, source, operationSnapshot);
240
+ if (denial !== undefined)
241
+ return this.#denied(match.command, denial, locale);
238
242
  const definition = match.command.slot.definition;
239
243
  const authorSource = bindOperationInput(source, match.command.slot.owner, operationSnapshot);
240
244
  const help = commandHelp(commandDisplayName(match.command, locale), definition, prefix, locale);
@@ -255,7 +259,8 @@ export class CommandIndex {
255
259
  operation,
256
260
  interaction: interactionFactory?.(authorSource), adapter: definition.adapter,
257
261
  presentation: { locale, formatCommand: (localPath, owner = match.command.owner) => this.formatCommand(owner, localPath, source && typeof source === 'object' && 'conversation' in source ? commandPrefix : undefined, locale) },
258
- }, { 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 } : {}) }));
259
264
  return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value });
260
265
  }
261
266
  finally {
@@ -265,34 +270,60 @@ export class CommandIndex {
265
270
  catch { /* Cleanup never replaces the command's actual outcome. */ }
266
271
  }
267
272
  }
268
- #denied(record) {
269
- return Object.freeze({ matched: true, status: 'denied', command: record.name, owner: record.slot.owner });
273
+ #denied(record, permit, locale) {
274
+ return Object.freeze({ matched: true, status: 'denied', command: record.name, owner: record.slot.owner,
275
+ ...(permit ? { denial: Object.freeze({ permit, reason: describeDeniedPermit(permit, locale) }) } : {}) });
270
276
  }
271
277
  /** Diagnose only a reliable static route prefix; fully dynamic routes are ordinary chat candidates. */
272
278
  #invalidRoute(input) {
273
279
  const segments = normalizeSegments(typeof input === 'string' ? [{ type: 'text', data: { text: input } }] : input);
280
+ // segment-matcher clones its input before matching, so one copy serves every route.
281
+ const matcherSegments = asMatcherSegments(segments);
274
282
  for (const route of this.#routes) {
275
283
  if (!route.record.parameter)
276
284
  continue;
277
- const result = route.literalMatcher?.match(asMatcherSegments(segments));
285
+ const result = route.literalMatcher?.match(matcherSegments);
278
286
  if (result && hasCommandBoundary(result.remaining))
279
287
  return route.record;
280
288
  }
281
289
  return undefined;
282
290
  }
283
- async #permitAllows(record, source, operationSnapshot) {
291
+ /**
292
+ * ADR 0056 §5: the shared evaluator (PermissionHost) over the three dimensions — sender zhin role,
293
+ * sender platform standing and the Bot's own standing (resolved from the Adapter index cache).
294
+ * `undefined` = allowed; a permit string = the first unmet one; `null` = denied without a reason
295
+ * (no source or no Permission host).
296
+ */
297
+ async #permitDenial(record, source, operationSnapshot, cachedOnly = false) {
284
298
  const permits = record.permit;
285
299
  if (!permits || permits.length === 0)
286
- return true;
300
+ return undefined;
287
301
  if (!source || typeof source !== 'object')
288
- return false;
302
+ return null;
289
303
  const host = this.#resolveHost(operationSnapshot);
290
304
  if (!host)
291
- return false;
305
+ return null;
292
306
  const subject = toCommandPermissionSubject(source);
293
307
  if (!subject.sender?.id && !subject.scene?.id && !subject.adapter && !subject.endpoint)
308
+ return null;
309
+ const options = { source, ...(cachedOnly ? { cachedOnly } : {}) };
310
+ if (await host.checkAll(permits, subject, options))
311
+ return undefined;
312
+ // Denied: name the first unmet permit for the reply (same evaluator; Bot standing is cached now).
313
+ try {
314
+ return (await host.firstDenied?.(permits, subject, options)) ?? null;
315
+ }
316
+ catch {
317
+ return null;
318
+ }
319
+ }
320
+ /** Whether the menu should list `record` for `source` (adapter restriction + permits). */
321
+ async #listable(record, source, operationSnapshot) {
322
+ if (source === undefined)
323
+ return true;
324
+ if (!commandAdapterMatches(record, source))
294
325
  return false;
295
- return host.checkAll(permits, subject);
326
+ return (await this.#permitDenial(record, source, operationSnapshot)) === undefined;
296
327
  }
297
328
  #resolveHost(operationSnapshot) {
298
329
  try {
@@ -308,6 +339,18 @@ export class CommandIndex {
308
339
  return undefined;
309
340
  }
310
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
+ }
311
354
  #matchShortcut(input) {
312
355
  const text = exactMessageText(input);
313
356
  if (text === undefined)
@@ -322,14 +365,16 @@ export class CommandIndex {
322
365
  : input);
323
366
  if (segments.length === 0)
324
367
  return undefined;
368
+ // segment-matcher clones its input before matching, so one copy serves every route.
369
+ const matcherSegments = asMatcherSegments(segments);
325
370
  for (const route of this.#routes) {
326
371
  // Options may appear between the literal route and a directory-bound parameter.
327
- const literalResult = !exact ? route.literalMatcher?.match(asMatcherSegments(segments)) : undefined;
372
+ const literalResult = !exact ? route.literalMatcher?.match(matcherSegments) : undefined;
328
373
  const helpRequested = literalResult && hasCommandBoundary(literalResult.remaining)
329
374
  && requestsCommandHelp(normalizeSegments(literalResult.remaining));
330
375
  const parseDeclaredArguments = !exact && route.record.parameter && route.literalMatcher !== undefined
331
376
  && (Object.hasOwn(route.record.slot.definition.params ?? {}, route.record.parameter.name) || helpRequested);
332
- const result = parseDeclaredArguments ? literalResult : route.matcher.match(asMatcherSegments(segments));
377
+ const result = parseDeclaredArguments ? literalResult : route.matcher.match(matcherSegments);
333
378
  if (!result || !hasCommandBoundary(result.remaining))
334
379
  continue;
335
380
  const parameter = route.record.parameter;
@@ -380,7 +425,7 @@ export class CommandIndex {
380
425
  // ==========================================================================
381
426
  // 内置菜单命令
382
427
  // ==========================================================================
383
- #dispatchMenu(input, context) {
428
+ async #dispatchMenu(input, context) {
384
429
  const { commandPrefix, keyword, locale } = context;
385
430
  const text = typeof input === 'string' ? input.trim() : exactMessageText(input);
386
431
  if (text === undefined)
@@ -414,7 +459,7 @@ export class CommandIndex {
414
459
  }
415
460
  return undefined;
416
461
  }
417
- #buildMenu({ pluginKey, page = 1 }, { commandPrefix, keyword, snapshot, locale }) {
462
+ async #buildMenu({ pluginKey, page = 1 }, { commandPrefix, keyword, snapshot, locale, source }) {
418
463
  const root = snapshot.root;
419
464
  const targetId = pluginKey
420
465
  ? `${root}/${pluginKey.split('.').join('/')}`
@@ -422,15 +467,17 @@ export class CommandIndex {
422
467
  const node = snapshot.tree.get(targetId);
423
468
  if (!node)
424
469
  return { value: commandMessages(locale, 'missingPlugin', { plugin: pluginKey ?? '' }) };
425
- const directCommands = this.#commands.filter((cmd) => cmd.slot.owner === targetId);
470
+ const listable = await Promise.all(this.#commands.map(async (cmd) => (await this.#listable(cmd, source, snapshot)) ? cmd : undefined));
471
+ const available = listable.filter((cmd) => cmd !== undefined);
472
+ const directCommands = available.filter((cmd) => cmd.slot.owner === targetId);
426
473
  const children = node.children
427
474
  .map((childId) => snapshot.tree.get(childId))
428
475
  .filter((child) => !!child);
429
476
  const effectiveCommands = targetId === root
430
477
  && directCommands.length === 0
431
- && this.#commands.length > 0
432
- && this.#commands.length <= MENU_PAGE_SIZE
433
- ? this.#commands
478
+ && available.length > 0
479
+ && available.length <= MENU_PAGE_SIZE
480
+ ? available
434
481
  : directCommands;
435
482
  const pages = Math.max(1, Math.ceil((effectiveCommands.length + children.length) / MENU_PAGE_SIZE));
436
483
  const invocation = `${commandPrefix}${keyword}${pluginKey ? ` ${pluginKey}` : ''}`;
@@ -849,7 +896,14 @@ function textArgs(segments) {
849
896
  return splitCommand(segment.data.text);
850
897
  }));
851
898
  }
852
- 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 }) {
853
907
  return descriptor;
854
908
  }
855
909
  export class CommandParameterValueError extends TypeError {
@@ -112,7 +112,19 @@ export interface CommandScene {
112
112
  export interface CommandSender {
113
113
  readonly id: string;
114
114
  readonly name?: string;
115
- readonly role: readonly string[];
115
+ /** Highest zhin chat role effective in this conversation (ADR 0056), set by the Runtime. */
116
+ readonly chatRole?: 'owner' | 'admin' | 'trusted';
117
+ /** Per role, the topmost level it is held at on this conversation's chain, set by the Runtime. */
118
+ readonly chatRoleLevels?: Readonly<Partial<Record<'owner' | 'admin' | 'trusted', 'global' | 'adapter' | 'endpoint' | 'conversation'>>>;
119
+ /** Normalized platform standing; absent in private chats. */
120
+ readonly platform?: Readonly<{
121
+ standing: 'owner' | 'admin' | 'member' | 'guest';
122
+ roles?: readonly Readonly<{
123
+ id: string;
124
+ name?: string;
125
+ }>[];
126
+ permissions?: readonly string[];
127
+ }>;
116
128
  }
117
129
  /**
118
130
  * 命令侧入站会话契约(结构对齐 `@zhin.js/im-contract` 的 ConversationRef;
@@ -147,7 +159,9 @@ export interface CommandMessage {
147
159
  readonly sender?: {
148
160
  readonly id: string;
149
161
  readonly name?: string;
150
- readonly roles?: readonly string[];
162
+ readonly chatRole?: CommandSender['chatRole'];
163
+ readonly chatRoleLevels?: CommandSender['chatRoleLevels'];
164
+ readonly platform?: CommandSender['platform'];
151
165
  };
152
166
  readonly id?: string;
153
167
  readonly metadata?: Readonly<Record<string, unknown>>;
@@ -240,6 +254,11 @@ export interface CommandContext<TConfig = unknown, TInput extends CommandMessage
240
254
  */
241
255
  /** Canonical user-facing input/confirmation/selection module. */
242
256
  readonly interaction?: UserInteraction;
257
+ /**
258
+ * 由 `match` 正则触发时的匹配结果(捕获组、具名组 `match.groups`)。
259
+ * 走指令名路由且正文同样符合 `match` 时也会填入;其余情况为 `undefined`。
260
+ */
261
+ readonly match?: Readonly<RegExpExecArray>;
243
262
  }
244
263
  export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput extends CommandMessage = CommandMessage, TAdapter extends string | undefined = string | undefined> {
245
264
  /** @internal Runtime feature brand. */
@@ -265,7 +284,7 @@ export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput
265
284
  readonly preferredAliasI18n?: Readonly<Partial<Record<Locale, string>>>;
266
285
  /**
267
286
  * 内置 permit DSL(AND)。单项内逗号为 OR。
268
- * 例:`adapter(icqq)`、`role(master)`、`group(123,456)`。
287
+ * 例:`adapter(icqq)`、`owner`、`trusted`、`platform:admin`、`group(123,456)`。
269
288
  */
270
289
  readonly permit?: readonly string[];
271
290
  /** Optional full-operation cooldown, concurrency and one-shot progress feedback. */
@@ -275,6 +294,17 @@ export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput
275
294
  * 与普通路由一样是全局用户输入,冲突会在 generation 构建时拒绝。
276
295
  */
277
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;
278
308
  execute(context: CommandContext<TConfig, TInput, TAdapter>): TResult | Promise<TResult>;
279
309
  }
280
310
  declare module '@zhin.js/plugin-runtime' {
@@ -286,18 +316,23 @@ declare module '@zhin.js/plugin-runtime' {
286
316
  * 定义一个命令模块(`commands/` 约定目录下默认导出)。
287
317
  * @public 用户侧创作面,承诺 semver(见 docs/contributing/public-api-surface.md)。
288
318
  */
289
- 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'> & {
290
320
  readonly params?: P;
291
321
  readonly options?: O;
292
- execute(context: TypedCommandContext<TConfig, TInput, undefined, P, O>): TResult | Promise<TResult>;
293
- }) | {
294
- [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'> & {
295
324
  readonly adapter: TAdapter;
296
325
  readonly params?: P;
297
326
  readonly options?: O;
298
- execute(context: TypedCommandContext<TConfig, TInput, TAdapter, P, O>): TResult | Promise<TResult>;
299
- };
327
+ } & CommandBody<TConfig, TResult, TInput, TAdapter, P, O>;
300
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
+ };
301
336
  type TypedCommandContext<TConfig, TInput extends CommandMessage, TAdapter extends string | undefined, P extends CommandParams, O extends CommandOptions> = Omit<CommandContext<TConfig, TInput, TAdapter>, 'params' | 'options'> & {
302
337
  readonly params: keyof P extends never ? Readonly<Record<string, CommandParameterValue>> : InferCommandParams<P>;
303
338
  readonly options: InferCommandOptions<O>;
@@ -325,6 +360,8 @@ export declare namespace defineCommand {
325
360
  }
326
361
  /** @internal Validate display-only translations without changing their fallback text. */
327
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;
328
365
  /** @internal Convention-loader assembly helper. */
329
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>>;
330
367
  /** @internal Runtime validation for convention-discovered modules. */
@@ -341,6 +378,7 @@ export interface CommandInvocationArguments {
341
378
  readonly params?: Readonly<Record<string, CommandParameterValue>>;
342
379
  readonly options?: Readonly<Record<string, CommandParameterValue>>;
343
380
  readonly segments?: readonly Readonly<CommandSegment>[];
381
+ readonly match?: RegExpExecArray;
344
382
  }
345
383
  /** Author and lifetime authority captured by the dispatcher for this invocation. */
346
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,
@@ -213,14 +239,12 @@ export function resolveCommandSession(input) {
213
239
  if (operation) {
214
240
  const endpoint = input && typeof input === 'object' ? input.endpointId : undefined;
215
241
  const sender = operation.sender;
216
- const roles = [...new Set([...sender?.authorizationRoles ?? [], ...sender?.platformRoles ?? []])];
217
242
  return Object.freeze({
218
243
  adapter: operation.scene.endpoint.adapter,
219
244
  ...(typeof endpoint === 'string' && endpoint ? { endpoint } : {}),
220
245
  scene: Object.freeze({ id: operation.scene.id, type: operation.scene.kind,
221
246
  ...(operation.scene.name !== undefined ? { name: operation.scene.name } : {}) }),
222
- ...(sender ? { sender: Object.freeze({ id: sender.id, ...(sender.name !== undefined ? { name: sender.name } : {}),
223
- role: Object.freeze(roles.length ? roles : ['user']) }) } : {}),
247
+ ...(sender ? { sender: freezeSender(sender) } : {}),
224
248
  });
225
249
  }
226
250
  if (!isCommandMessageLike(input))
@@ -281,38 +305,8 @@ export function resolveCommandSender(input, metadata) {
281
305
  if (!id)
282
306
  return undefined;
283
307
  const name = input.sender?.name || firstString(metadata?.nickname, metadata?.senderName, metadata?.name);
284
- const role = resolveRoles(input, metadata);
285
- return Object.freeze({
286
- id,
287
- ...(name !== undefined ? { name } : {}),
288
- role,
289
- });
290
- }
291
- function resolveRoles(input, metadata) {
292
- if (input.sender?.roles?.length) {
293
- return Object.freeze([...input.sender.roles]);
294
- }
295
- const roles = [];
296
- const push = (value) => {
297
- if (typeof value !== 'string')
298
- return;
299
- const trimmed = value.trim();
300
- if (trimmed && !roles.includes(trimmed))
301
- roles.push(trimmed);
302
- };
303
- if (Array.isArray(metadata?.roles)) {
304
- for (const item of metadata.roles)
305
- push(item);
306
- }
307
- push(metadata?.senderRole);
308
- push(metadata?.role);
309
- if (metadata?.isMaster === true)
310
- push('master');
311
- if (metadata?.isTrusted === true)
312
- push('trusted');
313
- if (roles.length === 0)
314
- roles.push('user');
315
- return Object.freeze(roles);
308
+ // Roles come only from the Runtime-set sender fields, never from metadata (ADR 0056).
309
+ return freezeSender({ ...(input.sender ?? {}), id, ...(name !== undefined ? { name } : {}) });
316
310
  }
317
311
  function isCommandScene(value) {
318
312
  if (!value || typeof value !== 'object')
@@ -329,14 +323,31 @@ function isCommandSender(value) {
329
323
  const sender = value;
330
324
  return typeof sender.id === 'string'
331
325
  && sender.id.length > 0
332
- && Array.isArray(sender.role)
333
- && sender.role.every((item) => typeof item === 'string');
326
+ && (sender.chatRole === undefined || isChatRoleName(sender.chatRole));
327
+ }
328
+ function isChatRoleName(value) {
329
+ return value === 'owner' || value === 'admin' || value === 'trusted';
330
+ }
331
+ const STANDINGS = new Set(['owner', 'admin', 'member', 'guest']);
332
+ const LEVELS = new Set(['global', 'adapter', 'endpoint', 'conversation']);
333
+ function chatRoleLevelsOf(value) {
334
+ if (!value || typeof value !== 'object' || Array.isArray(value))
335
+ return undefined;
336
+ const levels = Object.fromEntries(['owner', 'admin', 'trusted']
337
+ .filter(role => LEVELS.has(String(value[role])))
338
+ .map(role => [role, value[role]]));
339
+ return Object.keys(levels).length ? Object.freeze(levels) : undefined;
334
340
  }
335
341
  function freezeSender(sender) {
342
+ const platform = sender.platform && typeof sender.platform === 'object'
343
+ && STANDINGS.has(String(sender.platform.standing))
344
+ ? sender.platform : undefined;
336
345
  return Object.freeze({
337
346
  id: sender.id,
338
347
  ...(sender.name !== undefined ? { name: sender.name } : {}),
339
- role: Object.freeze([...sender.role]),
348
+ ...(isChatRoleName(sender.chatRole) ? { chatRole: sender.chatRole } : {}),
349
+ ...(isChatRoleName(sender.chatRole) && chatRoleLevelsOf(sender.chatRoleLevels) ? { chatRoleLevels: chatRoleLevelsOf(sender.chatRoleLevels) } : {}),
350
+ ...(platform ? { platform } : {}),
340
351
  });
341
352
  }
342
353
  function firstString(...values) {
@@ -1,5 +1,5 @@
1
1
  /** Internal composition contract. Not part of the command authoring facade. */
2
- import { type PluginId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
2
+ import { type Locale, type PluginId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
3
3
  import type { CommandExecutionLimit, CommandExecutionPolicy } from './execution-policy.js';
4
4
  interface ConversationIdentity {
5
5
  readonly endpoint: {
@@ -55,6 +55,8 @@ export declare class CommandExecutionBudget {
55
55
  readonly conversation: ConversationIdentity;
56
56
  readonly senderId?: string;
57
57
  readonly retirementSignal: AbortSignal;
58
+ /** The operation's language for progress feedback; defaults to the catalog default. */
59
+ readonly locale?: Locale;
58
60
  readonly reply: (owner: PluginId, text: string) => Promise<unknown>;
59
61
  }): CommandOperationController;
60
62
  }
@@ -1,5 +1,6 @@
1
1
  /** Internal composition contract. Not part of the command authoring facade. */
2
2
  import { createToken } from '@zhin.js/plugin-runtime';
3
+ import { commandMessages } from './messages.js';
3
4
  /** @internal Admission-only Root resource; no bind or finish authority is published. */
4
5
  export const commandExecutionAdmissionToken = createToken('zhin.command.execution-admission');
5
6
  /** @internal Root-instance-owned state shared by projections and draining retired generations. */
@@ -26,7 +27,7 @@ export class CommandExecutionBudget {
26
27
  const operation = {
27
28
  snapshot: input.snapshot, key: valid ? Object.freeze(values) : undefined,
28
29
  active: true, progressStopped: false, admitted: false, signal: input.retirementSignal,
29
- reply: input.reply, releases: [], timers: new Set(), feedback: [], accepted: new Set(), sends: new Set(),
30
+ locale: input.locale, reply: input.reply, releases: [], timers: new Set(), feedback: [], accepted: new Set(), sends: new Set(),
30
31
  };
31
32
  const stop = () => {
32
33
  operation.progressStopped = true;
@@ -136,7 +137,7 @@ export class CommandExecutionBudget {
136
137
  // Observe failure once. Never retry uncertain delivery and never expose the exception.
137
138
  let send;
138
139
  try {
139
- send = Promise.resolve(operation.reply(owner, '正在处理中,请稍候。')).then(() => { }, () => { });
140
+ send = Promise.resolve(operation.reply(owner, commandMessages(operation.locale, 'progress'))).then(() => { }, () => { });
140
141
  }
141
142
  catch {
142
143
  send = Promise.resolve();
package/lib/index.d.ts CHANGED
@@ -7,7 +7,7 @@ export * from './command-index.js';
7
7
  /** @public Command authoring contract and `defineCommand`. */
8
8
  export * from './definition.js';
9
9
  export * from './permission-explanation.js';
10
- export { CONVERSATION_MANAGER_PERMIT, canManageConversation, assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, isPlatformPermit, parsePermitName, type ParsedPermit, type PermitKind, } from './permit.js';
10
+ export { CONVERSATION_MANAGER_PERMIT, canManageConversation, assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, removedPermitReplacement, parsePermitName, type ParsedPermit, type PermitKind, } from './permit.js';
11
11
  export { bindCommandSource, CommandPathSyntaxError, commandFeatureId, type BoundCommandSource, default as commandFeature, } from './provider.js';
12
12
  export { default } from './provider.js';
13
13
  export type { CommandValidationIssue, CommandValidationIssueCode } from './arguments.js';
package/lib/index.js CHANGED
@@ -7,6 +7,6 @@ export * from './command-index.js';
7
7
  /** @public Command authoring contract and `defineCommand`. */
8
8
  export * from './definition.js';
9
9
  export * from './permission-explanation.js';
10
- export { CONVERSATION_MANAGER_PERMIT, canManageConversation, assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, isPlatformPermit, parsePermitName, } from './permit.js';
10
+ export { CONVERSATION_MANAGER_PERMIT, canManageConversation, assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, removedPermitReplacement, parsePermitName, } from './permit.js';
11
11
  export { bindCommandSource, CommandPathSyntaxError, commandFeatureId, default as commandFeature, } from './provider.js';
12
12
  export { default } from './provider.js';
package/lib/messages.d.ts CHANGED
@@ -5,4 +5,4 @@ export declare function commandDescription(value: Readonly<{
5
5
  descriptionI18n?: Readonly<Partial<Record<Locale, string>>>;
6
6
  }>, locale: Locale): string | undefined;
7
7
  /** Static dictionaries; the caller supplies the operation's language. */
8
- export declare const commandMessages: (locale: Locale | undefined, key: "choices" | "defaultValue" | "menuKeyword" | "invalidPage" | "missingPlugin" | "pageOutOfRange" | "menuTitle" | "menuSummary" | "childPlugins" | "emptyMenu" | "pluginHint" | "previousPage" | "nextPage" | "guideTitle" | "guideSubtitle" | "commandCount" | "pluginCount" | "commandIndex" | "pluginIndex" | "guideEmpty" | "helpHint" | "genericHelpHint" | "expectedType" | "expectedArray" | "minimum" | "maximum" | "invalidFormat" | "invalidValue" | "unknownOption" | "duplicateOption" | "defaultUndefined" | "parameterUndefined" | "missingOption" | "missingRequiredParam" | "unexpectedArguments" | "invalidParameter" | "dynamicValue" | "noAdapter" | "missingEndpoint" | "adapterComparison" | "conditionMismatch" | "missingPermissionHost" | "missingEvidence" | "noPermit" | "uncheckedConditions" | "conditionsMatch" | "unconstrained" | "simulatedIdentity" | "unevaluatedCode" | "defaultedRole", values?: import("@zhin.js/plugin-runtime").MessageValues) => string;
8
+ export declare const commandMessages: (locale: Locale | undefined, key: "choices" | "defaultValue" | "menuKeyword" | "progress" | "invalidPage" | "missingPlugin" | "pageOutOfRange" | "menuTitle" | "menuSummary" | "childPlugins" | "emptyMenu" | "pluginHint" | "previousPage" | "nextPage" | "guideTitle" | "guideSubtitle" | "commandCount" | "pluginCount" | "commandIndex" | "pluginIndex" | "guideEmpty" | "helpHint" | "genericHelpHint" | "expectedType" | "expectedArray" | "minimum" | "maximum" | "invalidFormat" | "invalidValue" | "unknownOption" | "duplicateOption" | "defaultUndefined" | "parameterUndefined" | "missingOption" | "missingRequiredParam" | "unexpectedArguments" | "invalidParameter" | "dynamicValue" | "noAdapter" | "missingEndpoint" | "adapterComparison" | "conditionMismatch" | "missingPermissionHost" | "missingEvidence" | "noPermit" | "uncheckedConditions" | "conditionsMatch" | "unconstrained" | "simulatedIdentity" | "unevaluatedCode" | "defaultedRole", values?: import("@zhin.js/plugin-runtime").MessageValues) => string;