@zhin.js/command 1.0.6 → 1.0.9

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,28 +3,36 @@
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
  ```
17
24
 
18
- 最后一个文件名可以声明参数:
25
+ 最后一个文件名可以用 Next.js 风格方括号声明参数形态,类型与默认值在 `defineCommand({ params })` 中声明(`type` 必填,`default` 可选且有默认值时文件名必须用双方括号):
19
26
 
20
27
  ```text
21
- commands/gh/pr/[title:string=defaultTitle].ts -> gh pr [title]
22
- commands/upload/[asset:image].ts -> upload <asset>
28
+ commands/gh/pr/[[title]].ts -> gh pr [title] (params: { title: { type: 'string', default: 'defaultTitle' } })
29
+ commands/upload/[asset].ts -> upload <asset> (params: { asset: { type: 'image' } })
30
+ commands/search/[...kw].ts -> search <...kw> (params: { kw: { type: 'text' } },运行时 params.kw 为数组;元素粒度随类型:text 逐消息段,word/string 逐词,number/boolean 逐词转换)
23
31
  ```
24
32
 
25
33
  文本类型包括 `string`、`word`、`text`、`number`、`integer`、`float`、`boolean`;结构化
26
34
  类型包括 `mention`、`image`、`face`、`reply`、`forward`、`dice`、`rps`。结构化类型
27
- 直接从对应 segment 取值,不能声明文件名默认值。
35
+ 直接从对应 segment 取值,不能声明默认值。
28
36
 
29
37
  `execute()` 收到冻结的 `CommandContext`:
30
38
 
@@ -37,6 +45,12 @@ commands/upload/[asset:image].ts -> upload <asset>
37
45
  - `scene`:`{ id, type, name? }` 场景对象。
38
46
  - `sender`:`{ id, name?, role: string[] }` 发送者对象。
39
47
 
48
+ 可选声明字段:
49
+
50
+ - `alias`:替换全部本地静态段并重挂 owner 前缀(不打破子插件命名空间)。
51
+ - `permit`:内置 DSL(`adapter|group|private|channel|user|role`);失败为静默未命中。
52
+ - `shortcut`:全局整句精确匹配 → 预填 `params`(可打破命名空间)。
53
+
40
54
  单文件插件可在 `setup({ addCommand })` 中调用
41
55
  `addCommand('hello', defineCommand(...))`。它与目录发现共用 CommandIndex;拆成文件后
42
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, } 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,44 +17,83 @@ 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,
31
50
  source: slot.source,
32
51
  parameters: Object.freeze(parameter ? [{
33
52
  ...parameter,
34
- required: parameter.defaultValue === undefined,
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,12 +114,29 @@ export class CommandIndex {
73
114
  this.#diagnoseParameter(name);
74
115
  throw new Error(`Unknown Command: ${name}`);
75
116
  }
117
+ // Host / 无 session:跳过 permit。
76
118
  return match.command.slot.definition.execute(createCommandContext(this.snapshot, match.command.slot.owner, args, match.params));
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([]), shortcut.params, 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
141
  const value = await match.command.slot.definition.execute(createCommandContext(this.snapshot, match.command.slot.owner, args, match.params, source, match.remaining));
84
142
  return Object.freeze({
@@ -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,30 @@ 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;
193
+ const parameter = route.record.parameter;
194
+ const params = { ...result.params };
195
+ if (parameter?.rest) {
196
+ const raw = result.params[parameter.name];
197
+ const coerced = coerceRestValues(parameter, Array.isArray(raw) ? raw : []);
198
+ if (!coerced || (isRequiredParameter(parameter) && coerced.length === 0))
199
+ continue;
200
+ params[parameter.name] = coerced;
201
+ }
103
202
  const remaining = normalizeSegments(result.remaining);
104
203
  if (exact && remaining.length > 0)
105
204
  continue;
205
+ if (parameter && !parameter.rest && parameter.optional === true
206
+ && parameter.defaultValue === undefined
207
+ && (params[parameter.name] === '' || params[parameter.name] === null)) {
208
+ delete params[parameter.name];
209
+ }
106
210
  return {
107
- command,
108
- params: Object.freeze({ ...result.params }),
211
+ command: route.record,
212
+ params: Object.freeze(params),
109
213
  remaining,
110
214
  };
111
215
  }
@@ -113,14 +217,14 @@ export class CommandIndex {
113
217
  }
114
218
  #diagnoseParameter(name) {
115
219
  const words = splitCommand(name);
116
- for (const command of this.#commands) {
117
- const parameter = command.parameter;
118
- if (!parameter)
220
+ for (const route of this.#routes) {
221
+ const parameter = route.record.parameter;
222
+ if (!parameter || parameter.rest)
119
223
  continue;
120
- const parameterIndex = command.segments.findIndex((segment) => segment.startsWith('$'));
121
- if (words.length !== command.segments.length)
224
+ const parameterIndex = route.segments.findIndex((segment) => segment.startsWith('$'));
225
+ if (words.length !== route.segments.length)
122
226
  continue;
123
- if (!command.segments.every((segment, index) => index === parameterIndex || segment === words[index]))
227
+ if (!route.segments.every((segment, index) => index === parameterIndex || segment === words[index]))
124
228
  continue;
125
229
  const value = words[parameterIndex];
126
230
  if (value === undefined || matchesParameter(parameter.type, value))
@@ -146,6 +250,82 @@ function runtimeSegments(owner, localName) {
146
250
  const prefix = owner.slice('root/'.length).split('/').join('.');
147
251
  return [`${prefix}.${localSegments[0]}`, ...localSegments.slice(1)];
148
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
+ }
149
329
  function assertParameterSegment(segments, parameter, source) {
150
330
  const dynamicSegments = segments.filter((segment) => segment.startsWith('$'));
151
331
  if (!parameter && dynamicSegments.length === 0)
@@ -154,15 +334,26 @@ function assertParameterSegment(segments, parameter, source) {
154
334
  && dynamicSegments[0] === `$${parameter.name}`
155
335
  && segments.at(-1) === dynamicSegments[0])
156
336
  return;
157
- throw new Error(`Broken dynamic Command identity for ${source}`);
337
+ const dynamic = dynamicSegments[0] ?? (parameter ? `$${parameter.name}` : '$?');
338
+ throw new Error(`Invalid Command path for ${source}: the dynamic segment "${dynamic}" must be the only dynamic `
339
+ + `segment and come after a static segment (child plugin commands are prefixed by the plugin `
340
+ + `path, so a dynamic first segment is never reachable). `
341
+ + (parameter
342
+ ? `Hint: move the file under a static directory, e.g. "commands/add/[${parameter.name}:${parameter.type}].ts".`
343
+ : 'Hint: put the file under a static directory, e.g. "commands/add/<file>.ts".'));
344
+ }
345
+ function isRequiredParameter(parameter) {
346
+ return parameter.optional === true ? false : parameter.defaultValue === undefined;
158
347
  }
159
348
  function displayName(segments, parameter) {
160
349
  return segments.map((segment) => {
161
350
  if (!segment.startsWith('$'))
162
351
  return segment;
163
- return parameter?.defaultValue === undefined
164
- ? `<${segment.slice(1)}>`
165
- : `[${segment.slice(1)}]`;
352
+ const label = segment.slice(1);
353
+ const required = !parameter || isRequiredParameter(parameter);
354
+ if (parameter?.rest)
355
+ return required ? `<...${label}>` : `[...${label}]`;
356
+ return required ? `<${label}>` : `[${label}]`;
166
357
  }).join(' ');
167
358
  }
168
359
  function matcherPattern(segments, parameter) {
@@ -172,25 +363,72 @@ function matcherPattern(segments, parameter) {
172
363
  if (!parameter)
173
364
  throw new Error(`Missing Command parameter metadata: ${segment}`);
174
365
  const type = matcherType(parameter.type);
366
+ if (parameter.rest) {
367
+ return `[...${parameter.name}:${isStructuredRestType(parameter.type) ? type : 'text'}]`;
368
+ }
369
+ if (isRequiredParameter(parameter))
370
+ return `<${parameter.name}:${type}>`;
175
371
  return parameter.defaultValue === undefined
176
- ? `<${parameter.name}:${type}>`
372
+ ? `[${parameter.name}:${type}]`
177
373
  : `[${parameter.name}:${type}=${String(parameter.defaultValue)}]`;
178
374
  }).join(' ');
179
375
  }
180
376
  function matcherType(type) {
181
377
  return type === 'string' ? 'word' : type;
182
378
  }
379
+ function isStructuredRestType(type) {
380
+ return type === 'mention'
381
+ || type === 'image'
382
+ || type === 'face'
383
+ || type === 'reply'
384
+ || type === 'forward'
385
+ || type === 'dice'
386
+ || type === 'rps';
387
+ }
388
+ function coerceRestValues(parameter, values) {
389
+ const type = parameter.type;
390
+ if (type === 'text' || isStructuredRestType(type)) {
391
+ return values;
392
+ }
393
+ const words = values.flatMap((value) => typeof value === 'string' ? value.split(/\s+/u).filter(Boolean) : []);
394
+ if (type === 'string' || type === 'word')
395
+ return words;
396
+ if (type === 'number' || type === 'integer' || type === 'float') {
397
+ const numbers = [];
398
+ for (const [index, word] of words.entries()) {
399
+ const number = Number(word);
400
+ if (!Number.isFinite(number)
401
+ || (type === 'integer' && !Number.isInteger(number))
402
+ || (type === 'float' && !word.includes('.')))
403
+ return undefined;
404
+ numbers[index] = number;
405
+ }
406
+ return numbers;
407
+ }
408
+ if (type === 'boolean') {
409
+ if (!words.every((word) => word === 'true' || word === 'false'))
410
+ return undefined;
411
+ return words.map((word) => word === 'true');
412
+ }
413
+ return undefined;
414
+ }
183
415
  function routeShape(segments) {
184
416
  return segments.map((segment) => segment.startsWith('$') ? '$' : segment).join(' ');
185
417
  }
186
- function compareCommands(left, right) {
187
- const leftDynamic = left.parameter ? 1 : 0;
188
- const rightDynamic = right.parameter ? 1 : 0;
189
- return leftDynamic - rightDynamic
418
+ function compareRecords(left, right) {
419
+ return left.name.localeCompare(right.name);
420
+ }
421
+ function compareRoutes(left, right) {
422
+ return dynamicWeight(left) - dynamicWeight(right)
190
423
  || staticSegmentCount(right.segments) - staticSegmentCount(left.segments)
191
424
  || right.segments.length - left.segments.length
192
- || right.name.length - left.name.length
193
- || left.name.localeCompare(right.name);
425
+ || right.record.name.length - left.record.name.length
426
+ || left.record.name.localeCompare(right.record.name);
427
+ }
428
+ function dynamicWeight(route) {
429
+ if (!route.record.parameter)
430
+ return 0;
431
+ return route.record.parameter.rest ? 2 : 1;
194
432
  }
195
433
  function staticSegmentCount(segments) {
196
434
  return segments.filter((segment) => !segment.startsWith('$')).length;
@@ -251,12 +489,9 @@ function textArgs(segments) {
251
489
  return splitCommand(segment.data.text);
252
490
  }));
253
491
  }
254
- function toDescriptor({ slot: _slot, segments: _segments, parameter: _parameter, matcher: _matcher, ...descriptor }) {
492
+ function toDescriptor({ slot: _slot, segments: _segments, parameter: _parameter, ...descriptor }) {
255
493
  return descriptor;
256
494
  }
257
- function duplicateCommand(name) {
258
- return new Error(`Duplicate runtime Command: ${name}`);
259
- }
260
495
  export class CommandParameterValueError extends TypeError {
261
496
  constructor(name, type, value) {
262
497
  super(`Invalid value for Command parameter ${name}:${type}: ${value}`);
@@ -2,7 +2,18 @@ import type { PluginId, RuntimeSnapshot } from '@zhin.js/plugin-runtime';
2
2
  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
- export type CommandParameterValue = string | number | boolean | Readonly<Record<string, unknown>> | null;
5
+ export type CommandParameterValue = string | number | boolean | ReadonlyArray<string | number | boolean> | Readonly<Record<string, unknown>> | null;
6
+ export declare const commandParameterTypes: ReadonlySet<CommandParameterType>;
7
+ /**
8
+ * Next.js 风格参数声明(`defineCommand({ params: ... })`)。
9
+ * 文件名只声明参数形态(`[name]` / `[[name]]` / `[...name]` / `[[...name]]`),
10
+ * 类型与默认值统一在这里声明。
11
+ */
12
+ export interface CommandParamSchema {
13
+ readonly type: CommandParameterType;
14
+ readonly default?: CommandParameterValue;
15
+ readonly description?: string;
16
+ }
6
17
  /** Minimal structural contract shared with canonical IM segments. */
7
18
  export interface CommandSegment {
8
19
  readonly type: string | {
@@ -14,6 +25,11 @@ export interface CommandParameterDefinition {
14
25
  readonly name: string;
15
26
  readonly type: CommandParameterType;
16
27
  readonly defaultValue?: CommandParameterValue;
28
+ /** `[[name]]` / `[[...name]]` 可选段;缺省按 `defaultValue === undefined` 推断。 */
29
+ readonly optional?: boolean;
30
+ /** `[...name]` / `[[...name]]` 捕获所有段,运行时值为 `string[]`。 */
31
+ readonly rest?: boolean;
32
+ readonly description?: string;
17
33
  }
18
34
  /** 场景:群 / 私聊 / 频道等。 */
19
35
  export interface CommandScene {
@@ -30,6 +46,24 @@ export interface CommandSender {
30
46
  readonly name?: string;
31
47
  readonly role: readonly string[];
32
48
  }
49
+ /**
50
+ * 命令侧入站会话契约(结构对齐 `@zhin.js/im-contract` 的 ConversationRef;
51
+ * command 为 Feature 层,不能 import core / IM 契约包,故独立声明)。
52
+ */
53
+ export interface CommandConversation {
54
+ readonly endpoint: Readonly<{
55
+ readonly id: string;
56
+ /** 适配器插件 owner(PluginId),与 `snapshot.config.get(adapter)` 对齐。 */
57
+ readonly adapter: string;
58
+ }>;
59
+ readonly kind: 'private' | 'group' | 'channel';
60
+ readonly id: string;
61
+ readonly parent?: Readonly<{
62
+ readonly kind: 'private' | 'group' | 'channel';
63
+ readonly id: string;
64
+ }>;
65
+ readonly threadId?: string;
66
+ }
33
67
  /**
34
68
  * 命令侧入站消息契约。
35
69
  *
@@ -37,17 +71,39 @@ export interface CommandSender {
37
71
  * 因架构分层(command 为 Feature 层,不能 import core),此处独立声明。
38
72
  */
39
73
  export interface CommandMessage {
40
- readonly adapter: string;
41
- readonly target: string;
74
+ readonly conversation: CommandConversation;
42
75
  readonly content: string;
43
- /** 发送者 id(扁平字段;结构化视图见 CommandContext.sender)。 */
44
- readonly sender?: string;
76
+ /** 发送者(结构化视图见 CommandContext.sender)。 */
77
+ readonly sender?: {
78
+ readonly id: string;
79
+ readonly name?: string;
80
+ readonly roles?: readonly string[];
81
+ };
45
82
  readonly id?: string;
46
83
  readonly metadata?: Readonly<Record<string, unknown>>;
47
84
  /** 若上游已结构化,优先采用。 */
48
85
  readonly scene?: CommandScene;
49
- readonly $reply?: (content: unknown) => Promise<unknown>;
50
- readonly $replyFrom?: (requester: string, content: unknown) => Promise<unknown>;
86
+ $reply?(content: unknown): Promise<unknown>;
87
+ $replyFrom?(requester: string, content: unknown): Promise<unknown>;
88
+ /** 向同 Endpoint 的另一个通道发送消息(结构兼容 `Message.$sendTo`)。 */
89
+ $sendTo?(conversation: {
90
+ readonly kind: 'private' | 'group' | 'channel';
91
+ readonly id: string;
92
+ readonly parent?: Readonly<{
93
+ readonly kind: 'private' | 'group' | 'channel';
94
+ readonly id: string;
95
+ }>;
96
+ readonly threadId?: string;
97
+ }, content: unknown): Promise<unknown>;
98
+ /** 私信当前消息的发送者(结构兼容 `Message.$replyToPrivate`)。 */
99
+ $replyToPrivate?(content: unknown, from?: boolean | {
100
+ readonly kind: 'group' | 'channel';
101
+ readonly id: string;
102
+ }): Promise<unknown>;
103
+ /** 向指定群发送消息(结构兼容 `Message.$replyToGroup`)。 */
104
+ $replyToGroup?(groupId: string, content: unknown): Promise<unknown>;
105
+ /** 向指定频道发送消息(结构兼容 `Message.$replyToChannel`)。 */
106
+ $replyToChannel?(channelId: string, guildId: string, content: unknown, threadId?: string): Promise<unknown>;
51
107
  }
52
108
  /**
53
109
  * IM 入站快捷字段。
@@ -82,6 +138,26 @@ export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput
82
138
  readonly $feature: typeof commandBrand;
83
139
  readonly $parameter?: CommandParameterDefinition;
84
140
  readonly description?: string;
141
+ /**
142
+ * Next.js 风格参数声明:动态段文件名(`[name]` 等)的形态配合这里的
143
+ * 类型 / 默认值 / 描述使用。静态命令可忽略本字段。
144
+ */
145
+ readonly params?: Readonly<Record<string, CommandParamSchema>>;
146
+ /**
147
+ * 本地静态段别名(可多词,如 `'gh issue'`)。替换全部本地静态段后仍挂
148
+ * owner 前缀;不打破子插件命名空间。
149
+ */
150
+ readonly alias?: readonly string[];
151
+ /**
152
+ * 内置 permit DSL(AND)。单项内逗号为 OR。
153
+ * 例:`adapter(icqq)`、`role(master)`、`group(123,456)`。
154
+ */
155
+ readonly permit?: readonly string[];
156
+ /**
157
+ * 全局整句快捷方式:触发串(trim 后全文相等)→ 预填 params。
158
+ * 可打破 owner 命名空间。
159
+ */
160
+ readonly shortcut?: Readonly<Record<string, Readonly<Record<string, CommandParameterValue>>>>;
85
161
  execute(context: CommandContext<TConfig, TInput>): TResult | Promise<TResult>;
86
162
  }
87
163
  declare module '@zhin.js/plugin-runtime' {