@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.
package/README.md CHANGED
@@ -3,14 +3,21 @@
3
3
  Zhin Plugin Runtime 的约定式 Command Feature。它发现 `commands/**/*.ts(x)`,将插件树
4
4
  路径与文件路径投影为命令,并用 `segment-matcher` 同时匹配纯文本和 canonical IM segments。
5
5
 
6
+ 静态命令文件名可为 ASCII kebab(`hello.ts`)或 Unicode 名(`赞我.ts`);动态参数文件
7
+ (`[name].ts` 等)仍限 ASCII。详见 [命令创作指南](../../../docs/authoring/commands.md)。
8
+
6
9
  ## Authoring
7
10
 
8
11
  ```ts
9
12
  // commands/gh/issue/list.ts -> gh issue list
13
+ // commands/赞我.ts -> 赞我
10
14
  import { defineCommand } from '@zhin.js/command';
11
15
 
12
16
  export default defineCommand({
13
17
  description: 'List GitHub issues',
18
+ alias: ['issues'], // 可多词;子插件仍保留 owner 前缀
19
+ permit: ['adapter(icqq)', 'role(master)'], // 数组 AND;未过则静默未命中
20
+ // shortcut: { '列 issue': {} }, // 全局整句,可打破命名空间
14
21
  execute: ({ args }) => `issues:${args.join(',')}`,
15
22
  });
16
23
  ```
@@ -38,6 +45,12 @@ commands/search/[...kw].ts -> search <...kw> (params: { kw: { type: 'text
38
45
  - `scene`:`{ id, type, name? }` 场景对象。
39
46
  - `sender`:`{ id, name?, role: string[] }` 发送者对象。
40
47
 
48
+ 可选声明字段:
49
+
50
+ - `alias`:替换全部本地静态段并重挂 owner 前缀(不打破子插件命名空间)。
51
+ - `permit`:内置 DSL(`adapter|group|private|channel|user|role`);失败为静默未命中。
52
+ - `shortcut`:全局整句精确匹配 → 预填 `params`(可打破命名空间)。
53
+
41
54
  单文件插件可在 `setup({ addCommand })` 中调用
42
55
  `addCommand('hello', defineCommand(...))`。它与目录发现共用 CommandIndex;拆成文件后
43
56
  可获得单命令 HMR。
@@ -8,6 +8,10 @@ export interface CommandDescriptor {
8
8
  readonly description?: string;
9
9
  readonly source: string;
10
10
  readonly parameters: readonly CommandParameterDescriptor[];
11
+ readonly alias?: readonly string[];
12
+ readonly permit?: readonly string[];
13
+ /** shortcut 触发键列表(不含预填 params)。 */
14
+ readonly shortcut?: readonly string[];
11
15
  }
12
16
  export interface CommandDispatchResult {
13
17
  readonly matched: boolean;
@@ -1,5 +1,7 @@
1
1
  import { SegmentMatcher, TypeMatcherRegistry, } from 'segment-matcher';
2
- import { createCommandContext, } from './definition.js';
2
+ import { createCommandContext, resolveCommandSession, resolveDynamicParams, } from './definition.js';
3
+ import { permissionHostToken } from '@zhin.js/permission';
4
+ import { toPermissionSubject } from '@zhin.js/permission';
3
5
  const segmentFields = {
4
6
  text: 'text',
5
7
  mention: 'target',
@@ -15,16 +17,33 @@ export class CommandIndex {
15
17
  snapshot;
16
18
  $projection = 'zhin.command-index/1';
17
19
  #commands;
20
+ #routes;
21
+ #shortcuts;
18
22
  constructor(slots, snapshot) {
19
23
  this.snapshot = snapshot;
20
24
  const commands = [];
21
- const staticCommands = new Map();
22
- const dynamicCommands = new Map();
25
+ const routes = [];
26
+ const occupancy = new Map();
27
+ const shortcuts = new Map();
28
+ const claim = (key, source) => {
29
+ const existing = occupancy.get(key);
30
+ if (existing !== undefined) {
31
+ throw new Error(`Duplicate Command route "${key}" (${source} vs ${existing})`);
32
+ }
33
+ occupancy.set(key, source);
34
+ };
23
35
  for (const slot of slots) {
24
- const segments = runtimeSegments(slot.owner, slot.localName);
36
+ const primarySegments = runtimeSegments(slot.owner, slot.localName);
25
37
  const parameter = slot.definition.$parameter;
26
- assertParameterSegment(segments, parameter, slot.source);
27
- const name = displayName(segments, parameter);
38
+ assertParameterSegment(primarySegments, parameter, slot.source);
39
+ const name = displayName(primarySegments, parameter);
40
+ const alias = normalizeAliasList(slot.definition.alias);
41
+ const permit = slot.definition.permit
42
+ ? Object.freeze([...slot.definition.permit])
43
+ : undefined;
44
+ const shortcutKeys = slot.definition.shortcut
45
+ ? Object.freeze(Object.keys(slot.definition.shortcut).map((key) => key.trim()))
46
+ : undefined;
28
47
  const record = Object.freeze({
29
48
  name,
30
49
  description: slot.definition.description,
@@ -33,26 +52,48 @@ export class CommandIndex {
33
52
  ...parameter,
34
53
  required: isRequiredParameter(parameter),
35
54
  }] : []),
55
+ ...(alias ? { alias } : {}),
56
+ ...(permit ? { permit } : {}),
57
+ ...(shortcutKeys && shortcutKeys.length > 0 ? { shortcut: shortcutKeys } : {}),
36
58
  slot,
37
- segments: Object.freeze(segments),
59
+ segments: Object.freeze(primarySegments),
38
60
  parameter,
39
- matcher: new SegmentMatcher(matcherPattern(segments, parameter), segmentFields),
40
61
  });
41
- if (!parameter) {
42
- const key = segments.join(' ');
43
- if (staticCommands.has(key))
44
- throw duplicateCommand(key);
45
- staticCommands.set(key, record);
62
+ claim(occupancyKey(primarySegments, parameter), slot.source);
63
+ routes.push({
64
+ record,
65
+ segments: primarySegments,
66
+ matcher: new SegmentMatcher(matcherPattern(primarySegments, parameter), segmentFields),
67
+ kind: 'primary',
68
+ });
69
+ if (alias) {
70
+ for (const entry of alias) {
71
+ const aliasSegments = aliasRuntimeSegments(slot.owner, entry, primarySegments);
72
+ assertParameterSegment(aliasSegments, parameter, `${slot.source} alias ${JSON.stringify(entry)}`);
73
+ claim(occupancyKey(aliasSegments, parameter), `${slot.source} alias ${JSON.stringify(entry)}`);
74
+ routes.push({
75
+ record,
76
+ segments: Object.freeze(aliasSegments),
77
+ matcher: new SegmentMatcher(matcherPattern(aliasSegments, parameter), segmentFields),
78
+ kind: 'alias',
79
+ });
80
+ }
46
81
  }
47
- else {
48
- const shape = routeShape(segments);
49
- if (dynamicCommands.has(shape))
50
- throw duplicateCommand(name);
51
- dynamicCommands.set(shape, record);
82
+ if (slot.definition.shortcut) {
83
+ for (const [rawTrigger, prefill] of Object.entries(slot.definition.shortcut)) {
84
+ const trigger = rawTrigger.trim();
85
+ claim(trigger, `${slot.source} shortcut ${JSON.stringify(trigger)}`);
86
+ shortcuts.set(trigger, {
87
+ record,
88
+ params: Object.freeze(resolveShortcutParams(slot.definition, prefill, `${slot.source} shortcut ${JSON.stringify(trigger)}`)),
89
+ });
90
+ }
52
91
  }
53
92
  commands.push(record);
54
93
  }
55
- this.#commands = Object.freeze(commands.sort(compareCommands));
94
+ this.#commands = Object.freeze(commands.sort(compareRecords));
95
+ this.#routes = Object.freeze(routes.sort(compareRoutes));
96
+ this.#shortcuts = shortcuts;
56
97
  }
57
98
  list() {
58
99
  return this.#commands.map(toDescriptor);
@@ -73,14 +114,31 @@ export class CommandIndex {
73
114
  this.#diagnoseParameter(name);
74
115
  throw new Error(`Unknown Command: ${name}`);
75
116
  }
76
- return match.command.slot.definition.execute(createCommandContext(this.snapshot, match.command.slot.owner, args, match.params));
117
+ // Host / session:跳过 permit;无 source 时函数默认值得到空 session。
118
+ return match.command.slot.definition.execute(createCommandContext(this.snapshot, match.command.slot.owner, args, resolveDynamicParams(match.params, undefined)));
77
119
  }
78
120
  async dispatch(input, source = undefined) {
121
+ const shortcut = this.#matchShortcut(input);
122
+ if (shortcut) {
123
+ if (!(await this.#permitAllows(shortcut.record, source))) {
124
+ return Object.freeze({ matched: false });
125
+ }
126
+ const value = await shortcut.record.slot.definition.execute(createCommandContext(this.snapshot, shortcut.record.slot.owner, Object.freeze([]), resolveDynamicParams(shortcut.params, source), source, Object.freeze([])));
127
+ return Object.freeze({
128
+ matched: true,
129
+ command: shortcut.record.name,
130
+ owner: shortcut.record.slot.owner,
131
+ value,
132
+ });
133
+ }
79
134
  const match = this.#match(input, false);
80
135
  if (!match)
81
136
  return Object.freeze({ matched: false });
137
+ if (!(await this.#permitAllows(match.command, source))) {
138
+ return Object.freeze({ matched: false });
139
+ }
82
140
  const args = textArgs(match.remaining);
83
- const value = await match.command.slot.definition.execute(createCommandContext(this.snapshot, match.command.slot.owner, args, match.params, source, match.remaining));
141
+ const value = await match.command.slot.definition.execute(createCommandContext(this.snapshot, match.command.slot.owner, args, resolveDynamicParams(match.params, source), source, match.remaining));
84
142
  return Object.freeze({
85
143
  matched: true,
86
144
  command: match.command.name,
@@ -88,6 +146,38 @@ export class CommandIndex {
88
146
  value,
89
147
  });
90
148
  }
149
+ async #permitAllows(record, source) {
150
+ const permits = record.permit;
151
+ if (!permits || permits.length === 0)
152
+ return true;
153
+ if (!hasImSession(source))
154
+ return true;
155
+ const host = this.#resolveHost();
156
+ if (!host)
157
+ return false;
158
+ const subject = toPermissionSubject(resolveCommandSession(source));
159
+ return host.checkAll(permits, subject);
160
+ }
161
+ #resolveHost() {
162
+ try {
163
+ const resources = this.snapshot.resources.get(this.snapshot.root);
164
+ if (!resources)
165
+ return undefined;
166
+ const host = resources.get(permissionHostToken.id);
167
+ return host && typeof host.check === 'function'
168
+ ? host
169
+ : undefined;
170
+ }
171
+ catch {
172
+ return undefined;
173
+ }
174
+ }
175
+ #matchShortcut(input) {
176
+ const text = exactMessageText(input);
177
+ if (text === undefined)
178
+ return undefined;
179
+ return this.#shortcuts.get(text);
180
+ }
91
181
  #match(input, exact) {
92
182
  const segments = normalizeSegments(typeof input === 'string'
93
183
  ? input.trim()
@@ -96,16 +186,15 @@ export class CommandIndex {
96
186
  : input);
97
187
  if (segments.length === 0)
98
188
  return undefined;
99
- for (const command of this.#commands) {
100
- const result = command.matcher.match(asMatcherSegments(segments));
189
+ for (const route of this.#routes) {
190
+ const result = route.matcher.match(asMatcherSegments(segments));
101
191
  if (!result || !hasCommandBoundary(result.remaining))
102
192
  continue;
103
- const parameter = command.parameter;
193
+ const parameter = route.record.parameter;
104
194
  const params = { ...result.params };
105
195
  if (parameter?.rest) {
106
196
  const raw = result.params[parameter.name];
107
197
  const coerced = coerceRestValues(parameter, Array.isArray(raw) ? raw : []);
108
- // 必需 `[...name]` 捕获所有:零元素视为不匹配;标量逐词转换失败同样不匹配。
109
198
  if (!coerced || (isRequiredParameter(parameter) && coerced.length === 0))
110
199
  continue;
111
200
  params[parameter.name] = coerced;
@@ -113,15 +202,13 @@ export class CommandIndex {
113
202
  const remaining = normalizeSegments(result.remaining);
114
203
  if (exact && remaining.length > 0)
115
204
  continue;
116
- // `[[name]]` 无 default 且未命中时,matcher 对 text 回退 ''、其他类型回退 null;
117
- // 按契约(省略 default 时未匹配为 undefined)删除该键。
118
205
  if (parameter && !parameter.rest && parameter.optional === true
119
206
  && parameter.defaultValue === undefined
120
207
  && (params[parameter.name] === '' || params[parameter.name] === null)) {
121
208
  delete params[parameter.name];
122
209
  }
123
210
  return {
124
- command,
211
+ command: route.record,
125
212
  params: Object.freeze(params),
126
213
  remaining,
127
214
  };
@@ -130,14 +217,14 @@ export class CommandIndex {
130
217
  }
131
218
  #diagnoseParameter(name) {
132
219
  const words = splitCommand(name);
133
- for (const command of this.#commands) {
134
- const parameter = command.parameter;
220
+ for (const route of this.#routes) {
221
+ const parameter = route.record.parameter;
135
222
  if (!parameter || parameter.rest)
136
223
  continue;
137
- const parameterIndex = command.segments.findIndex((segment) => segment.startsWith('$'));
138
- if (words.length !== command.segments.length)
224
+ const parameterIndex = route.segments.findIndex((segment) => segment.startsWith('$'));
225
+ if (words.length !== route.segments.length)
139
226
  continue;
140
- if (!command.segments.every((segment, index) => index === parameterIndex || segment === words[index]))
227
+ if (!route.segments.every((segment, index) => index === parameterIndex || segment === words[index]))
141
228
  continue;
142
229
  const value = words[parameterIndex];
143
230
  if (value === undefined || matchesParameter(parameter.type, value))
@@ -163,6 +250,82 @@ function runtimeSegments(owner, localName) {
163
250
  const prefix = owner.slice('root/'.length).split('/').join('.');
164
251
  return [`${prefix}.${localSegments[0]}`, ...localSegments.slice(1)];
165
252
  }
253
+ /**
254
+ * 用 alias 词序列替换全部本地静态段,再按 owner 规则重挂前缀;动态段保留。
255
+ */
256
+ function aliasRuntimeSegments(owner, alias, primarySegments) {
257
+ const aliasTokens = alias.trim().split(/\s+/u).filter(Boolean);
258
+ const dynamicTail = primarySegments.filter((segment) => segment.startsWith('$'));
259
+ if (owner === 'root')
260
+ return [...aliasTokens, ...dynamicTail];
261
+ const prefix = owner.slice('root/'.length).split('/').join('.');
262
+ return [`${prefix}.${aliasTokens[0]}`, ...aliasTokens.slice(1), ...dynamicTail];
263
+ }
264
+ function occupancyKey(segments, parameter) {
265
+ return parameter ? routeShape(segments) : segments.join(' ');
266
+ }
267
+ function normalizeAliasList(alias) {
268
+ if (!alias || alias.length === 0)
269
+ return undefined;
270
+ return Object.freeze(alias.map((entry) => entry.trim().split(/\s+/u).filter(Boolean).join(' ')));
271
+ }
272
+ function resolveShortcutParams(definition, prefill, source) {
273
+ const allowed = new Set();
274
+ const parameter = definition.$parameter;
275
+ if (parameter)
276
+ allowed.add(parameter.name);
277
+ if (definition.params) {
278
+ for (const key of Object.keys(definition.params))
279
+ allowed.add(key);
280
+ }
281
+ for (const key of Object.keys(prefill)) {
282
+ if (!allowed.has(key)) {
283
+ throw new TypeError(`Invalid shortcut params for ${source}: unknown key ${JSON.stringify(key)}`);
284
+ }
285
+ }
286
+ const result = { ...prefill };
287
+ if (parameter) {
288
+ if (result[parameter.name] === undefined) {
289
+ if (parameter.defaultValue !== undefined) {
290
+ result[parameter.name] = parameter.defaultValue;
291
+ }
292
+ else if (isRequiredParameter(parameter)) {
293
+ throw new TypeError(`Invalid shortcut params for ${source}: missing required ${parameter.name}`);
294
+ }
295
+ }
296
+ }
297
+ else if (allowed.size === 0 && Object.keys(prefill).length > 0) {
298
+ throw new TypeError(`Invalid shortcut params for ${source}: command has no params declaration`);
299
+ }
300
+ if (definition.params) {
301
+ for (const [name, schema] of Object.entries(definition.params)) {
302
+ if (result[name] === undefined && schema.default !== undefined) {
303
+ result[name] = schema.default;
304
+ }
305
+ }
306
+ }
307
+ return result;
308
+ }
309
+ function hasImSession(source) {
310
+ if (!source || typeof source !== 'object')
311
+ return false;
312
+ const conversation = source.conversation;
313
+ return !!conversation && typeof conversation === 'object';
314
+ }
315
+ function exactMessageText(input) {
316
+ if (typeof input === 'string') {
317
+ const trimmed = input.trim();
318
+ return trimmed || undefined;
319
+ }
320
+ // 仅纯单 text 段可作整句 shortcut;含 mention/image 等则不走 shortcut。
321
+ if (input.length !== 1)
322
+ return undefined;
323
+ const only = input[0];
324
+ if (!only || only.type !== 'text' || typeof only.data.text !== 'string')
325
+ return undefined;
326
+ const trimmed = only.data.text.trim();
327
+ return trimmed || undefined;
328
+ }
166
329
  function assertParameterSegment(segments, parameter, source) {
167
330
  const dynamicSegments = segments.filter((segment) => segment.startsWith('$'));
168
331
  if (!parameter && dynamicSegments.length === 0)
@@ -200,7 +363,6 @@ function matcherPattern(segments, parameter) {
200
363
  if (!parameter)
201
364
  throw new Error(`Missing Command parameter metadata: ${segment}`);
202
365
  const type = matcherType(parameter.type);
203
- // rest:结构化类型按消息段收集;标量类型先按 text 段收集,再在 #match 里逐词切分转换。
204
366
  if (parameter.rest) {
205
367
  return `[...${parameter.name}:${isStructuredRestType(parameter.type) ? type : 'text'}]`;
206
368
  }
@@ -208,13 +370,12 @@ function matcherPattern(segments, parameter) {
208
370
  return `<${parameter.name}:${type}>`;
209
371
  return parameter.defaultValue === undefined
210
372
  ? `[${parameter.name}:${type}]`
211
- : `[${parameter.name}:${type}=${String(parameter.defaultValue)}]`;
373
+ : `[${parameter.name}:${type}=${typeof parameter.defaultValue === 'function' ? '<dynamic>' : String(parameter.defaultValue)}]`;
212
374
  }).join(' ');
213
375
  }
214
376
  function matcherType(type) {
215
377
  return type === 'string' ? 'word' : type;
216
378
  }
217
- /** rest 参数中按消息段收集(matcher 原生行为)的结构化类型。 */
218
379
  function isStructuredRestType(type) {
219
380
  return type === 'mention'
220
381
  || type === 'image'
@@ -224,16 +385,9 @@ function isStructuredRestType(type) {
224
385
  || type === 'dice'
225
386
  || type === 'rps';
226
387
  }
227
- /**
228
- * 捕获所有参数的取值粒度由类型决定:
229
- * - `text` / 结构化类型:逐消息段(matcher 原生结果);
230
- * - `word` / `string`:逐词(空白切分);
231
- * - `number` / `integer` / `float` / `boolean`:逐词切分后逐个转换,任一失败返回 undefined(不匹配)。
232
- */
233
388
  function coerceRestValues(parameter, values) {
234
389
  const type = parameter.type;
235
390
  if (type === 'text' || isStructuredRestType(type)) {
236
- // 逐消息段,保持 matcher 原生提取值(text 为 string,结构化类型可能是 number 等)。
237
391
  return values;
238
392
  }
239
393
  const words = values.flatMap((value) => typeof value === 'string' ? value.split(/\s+/u).filter(Boolean) : []);
@@ -261,18 +415,20 @@ function coerceRestValues(parameter, values) {
261
415
  function routeShape(segments) {
262
416
  return segments.map((segment) => segment.startsWith('$') ? '$' : segment).join(' ');
263
417
  }
264
- function compareCommands(left, right) {
418
+ function compareRecords(left, right) {
419
+ return left.name.localeCompare(right.name);
420
+ }
421
+ function compareRoutes(left, right) {
265
422
  return dynamicWeight(left) - dynamicWeight(right)
266
423
  || staticSegmentCount(right.segments) - staticSegmentCount(left.segments)
267
424
  || right.segments.length - left.segments.length
268
- || right.name.length - left.name.length
269
- || left.name.localeCompare(right.name);
425
+ || right.record.name.length - left.record.name.length
426
+ || left.record.name.localeCompare(right.record.name);
270
427
  }
271
- /** 静态 < 单参数 < 捕获所有:更具体的形状优先匹配。 */
272
- function dynamicWeight(command) {
273
- if (!command.parameter)
428
+ function dynamicWeight(route) {
429
+ if (!route.record.parameter)
274
430
  return 0;
275
- return command.parameter.rest ? 2 : 1;
431
+ return route.record.parameter.rest ? 2 : 1;
276
432
  }
277
433
  function staticSegmentCount(segments) {
278
434
  return segments.filter((segment) => !segment.startsWith('$')).length;
@@ -333,12 +489,9 @@ function textArgs(segments) {
333
489
  return splitCommand(segment.data.text);
334
490
  }));
335
491
  }
336
- function toDescriptor({ slot: _slot, segments: _segments, parameter: _parameter, matcher: _matcher, ...descriptor }) {
492
+ function toDescriptor({ slot: _slot, segments: _segments, parameter: _parameter, ...descriptor }) {
337
493
  return descriptor;
338
494
  }
339
- function duplicateCommand(name) {
340
- return new Error(`Duplicate runtime Command: ${name}`);
341
- }
342
495
  export class CommandParameterValueError extends TypeError {
343
496
  constructor(name, type, value) {
344
497
  super(`Invalid value for Command parameter ${name}:${type}: ${value}`);
@@ -3,6 +3,26 @@ import { type CapabilityContext } from '@zhin.js/feature-kit';
3
3
  declare const commandBrand: "zhin.command/1";
4
4
  export type CommandParameterType = 'string' | 'number' | 'integer' | 'float' | 'boolean' | 'word' | 'text' | 'mention' | 'image' | 'face' | 'reply' | 'forward' | 'dice' | 'rps';
5
5
  export type CommandParameterValue = string | number | boolean | ReadonlyArray<string | number | boolean> | Readonly<Record<string, unknown>> | null;
6
+ /**
7
+ * 可从运行时会话上下文动态解析的参数值。
8
+ *
9
+ * 静态值直接使用;函数值在命令派发时接收 {@link CommandSession},
10
+ * 返回最终的 {@link CommandParameterValue}。适用于 shortcut 预填
11
+ * 和 params.default。
12
+ *
13
+ * ```ts
14
+ * defineCommand({
15
+ * params: {
16
+ * user_id: { type: 'string', default: (s) => s.sender?.id ?? '' },
17
+ * },
18
+ * shortcut: {
19
+ * '查看我的信息': { user_id: (s) => s.sender?.id ?? '' },
20
+ * },
21
+ * execute: ({ params }) => `profile:${params.user_id}`,
22
+ * })
23
+ * ```
24
+ */
25
+ export type CommandDynamicValue = CommandParameterValue | ((session: CommandSession) => CommandParameterValue);
6
26
  export declare const commandParameterTypes: ReadonlySet<CommandParameterType>;
7
27
  /**
8
28
  * Next.js 风格参数声明(`defineCommand({ params: ... })`)。
@@ -11,7 +31,7 @@ export declare const commandParameterTypes: ReadonlySet<CommandParameterType>;
11
31
  */
12
32
  export interface CommandParamSchema {
13
33
  readonly type: CommandParameterType;
14
- readonly default?: CommandParameterValue;
34
+ readonly default?: CommandDynamicValue;
15
35
  readonly description?: string;
16
36
  }
17
37
  /** Minimal structural contract shared with canonical IM segments. */
@@ -24,7 +44,7 @@ export interface CommandSegment {
24
44
  export interface CommandParameterDefinition {
25
45
  readonly name: string;
26
46
  readonly type: CommandParameterType;
27
- readonly defaultValue?: CommandParameterValue;
47
+ readonly defaultValue?: CommandDynamicValue;
28
48
  /** `[[name]]` / `[[...name]]` 可选段;缺省按 `defaultValue === undefined` 推断。 */
29
49
  readonly optional?: boolean;
30
50
  /** `[...name]` / `[[...name]]` 捕获所有段,运行时值为 `string[]`。 */
@@ -59,7 +79,7 @@ export interface CommandConversation {
59
79
  readonly kind: 'private' | 'group' | 'channel';
60
80
  readonly id: string;
61
81
  readonly parent?: Readonly<{
62
- readonly kind: 'group' | 'channel';
82
+ readonly kind: 'private' | 'group' | 'channel';
63
83
  readonly id: string;
64
84
  }>;
65
85
  readonly threadId?: string;
@@ -73,14 +93,37 @@ export interface CommandConversation {
73
93
  export interface CommandMessage {
74
94
  readonly conversation: CommandConversation;
75
95
  readonly content: string;
76
- /** 发送者 id(扁平字段;结构化视图见 CommandContext.sender)。 */
77
- readonly sender?: string;
96
+ /** 发送者(结构化视图见 CommandContext.sender)。 */
97
+ readonly sender?: {
98
+ readonly id: string;
99
+ readonly name?: string;
100
+ readonly roles?: readonly string[];
101
+ };
78
102
  readonly id?: string;
79
103
  readonly metadata?: Readonly<Record<string, unknown>>;
80
104
  /** 若上游已结构化,优先采用。 */
81
105
  readonly scene?: CommandScene;
82
- readonly $reply?: (content: unknown) => Promise<unknown>;
83
- readonly $replyFrom?: (requester: string, content: unknown) => Promise<unknown>;
106
+ $reply?(content: unknown): Promise<unknown>;
107
+ $replyFrom?(requester: string, content: unknown): Promise<unknown>;
108
+ /** 向同 Endpoint 的另一个通道发送消息(结构兼容 `Message.$sendTo`)。 */
109
+ $sendTo?(conversation: {
110
+ readonly kind: 'private' | 'group' | 'channel';
111
+ readonly id: string;
112
+ readonly parent?: Readonly<{
113
+ readonly kind: 'private' | 'group' | 'channel';
114
+ readonly id: string;
115
+ }>;
116
+ readonly threadId?: string;
117
+ }, content: unknown): Promise<unknown>;
118
+ /** 私信当前消息的发送者(结构兼容 `Message.$replyToPrivate`)。 */
119
+ $replyToPrivate?(content: unknown, from?: boolean | {
120
+ readonly kind: 'group' | 'channel';
121
+ readonly id: string;
122
+ }): Promise<unknown>;
123
+ /** 向指定群发送消息(结构兼容 `Message.$replyToGroup`)。 */
124
+ $replyToGroup?(groupId: string, content: unknown): Promise<unknown>;
125
+ /** 向指定频道发送消息(结构兼容 `Message.$replyToChannel`)。 */
126
+ $replyToChannel?(channelId: string, guildId: string, content: unknown, threadId?: string): Promise<unknown>;
84
127
  }
85
128
  /**
86
129
  * IM 入站快捷字段。
@@ -120,6 +163,21 @@ export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput
120
163
  * 类型 / 默认值 / 描述使用。静态命令可忽略本字段。
121
164
  */
122
165
  readonly params?: Readonly<Record<string, CommandParamSchema>>;
166
+ /**
167
+ * 本地静态段别名(可多词,如 `'gh issue'`)。替换全部本地静态段后仍挂
168
+ * owner 前缀;不打破子插件命名空间。
169
+ */
170
+ readonly alias?: readonly string[];
171
+ /**
172
+ * 内置 permit DSL(AND)。单项内逗号为 OR。
173
+ * 例:`adapter(icqq)`、`role(master)`、`group(123,456)`。
174
+ */
175
+ readonly permit?: readonly string[];
176
+ /**
177
+ * 全局整句快捷方式:触发串(trim 后全文相等)→ 预填 params。
178
+ * 可打破 owner 命名空间。
179
+ */
180
+ readonly shortcut?: Readonly<Record<string, Readonly<Record<string, CommandDynamicValue>>>>;
123
181
  execute(context: CommandContext<TConfig, TInput>): TResult | Promise<TResult>;
124
182
  }
125
183
  declare module '@zhin.js/plugin-runtime' {
@@ -134,6 +192,11 @@ declare module '@zhin.js/plugin-runtime' {
134
192
  export declare function defineCommand<TConfig = unknown, TResult = unknown, TInput extends CommandMessage = CommandMessage>(definition: Omit<CommandDefinition<TConfig, TResult, TInput>, '$feature' | '$parameter'>): Readonly<CommandDefinition<TConfig, TResult, TInput>>;
135
193
  export declare function bindCommandParameter<TConfig, TResult, TInput extends CommandMessage>(definition: CommandDefinition<TConfig, TResult, TInput>, parameter: CommandParameterDefinition | undefined): Readonly<CommandDefinition<TConfig, TResult, TInput>>;
136
194
  export declare function parseCommandDefinition(value: unknown): CommandDefinition;
195
+ /**
196
+ * 将动态参数值(可能包含函数)批量解析为静态值。
197
+ * 函数值接收从 `source`(通常是 IM Runtime `Message`)解析出的 {@link CommandSession}。
198
+ */
199
+ export declare function resolveDynamicParams(params: Readonly<Record<string, CommandDynamicValue>>, source: unknown): Readonly<Record<string, CommandParameterValue>>;
137
200
  export declare function createCommandContext(snapshot: RuntimeSnapshot, ownerId: PluginId, args: readonly string[], params?: Readonly<Record<string, CommandParameterValue>>, input?: unknown, segments?: readonly Readonly<CommandSegment>[]): CommandContext;
138
201
  /**
139
202
  * 从派发来源(通常是 Runtime `Message`)解析入站快捷字段。