@zhin.js/command 1.0.7 → 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,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, } 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,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,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
  }
@@ -214,7 +376,6 @@ function matcherPattern(segments, parameter) {
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}`);
@@ -59,7 +59,7 @@ export interface CommandConversation {
59
59
  readonly kind: 'private' | 'group' | 'channel';
60
60
  readonly id: string;
61
61
  readonly parent?: Readonly<{
62
- readonly kind: 'group' | 'channel';
62
+ readonly kind: 'private' | 'group' | 'channel';
63
63
  readonly id: string;
64
64
  }>;
65
65
  readonly threadId?: string;
@@ -73,14 +73,37 @@ export interface CommandConversation {
73
73
  export interface CommandMessage {
74
74
  readonly conversation: CommandConversation;
75
75
  readonly content: string;
76
- /** 发送者 id(扁平字段;结构化视图见 CommandContext.sender)。 */
77
- readonly sender?: string;
76
+ /** 发送者(结构化视图见 CommandContext.sender)。 */
77
+ readonly sender?: {
78
+ readonly id: string;
79
+ readonly name?: string;
80
+ readonly roles?: readonly string[];
81
+ };
78
82
  readonly id?: string;
79
83
  readonly metadata?: Readonly<Record<string, unknown>>;
80
84
  /** 若上游已结构化,优先采用。 */
81
85
  readonly scene?: CommandScene;
82
- readonly $reply?: (content: unknown) => Promise<unknown>;
83
- 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>;
84
107
  }
85
108
  /**
86
109
  * IM 入站快捷字段。
@@ -120,6 +143,21 @@ export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput
120
143
  * 类型 / 默认值 / 描述使用。静态命令可忽略本字段。
121
144
  */
122
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>>>>;
123
161
  execute(context: CommandContext<TConfig, TInput>): TResult | Promise<TResult>;
124
162
  }
125
163
  declare module '@zhin.js/plugin-runtime' {
package/lib/definition.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createCapabilityContext, } from '@zhin.js/feature-kit';
2
+ import { assertPermitSyntax } from '@zhin.js/permission';
2
3
  const commandBrand = 'zhin.command/1';
3
4
  export const commandParameterTypes = new Set([
4
5
  'string',
@@ -35,8 +36,55 @@ export function defineCommand(definition) {
35
36
  }
36
37
  }
37
38
  }
39
+ validateCommandAlias(definition.alias);
40
+ validateCommandPermit(definition.permit);
41
+ validateCommandShortcutShape(definition.shortcut);
38
42
  return Object.freeze({ $feature: commandBrand, ...definition });
39
43
  }
44
+ function validateCommandAlias(alias) {
45
+ if (alias === undefined)
46
+ return;
47
+ if (!Array.isArray(alias)) {
48
+ throw new TypeError('Command alias must be a readonly string[]');
49
+ }
50
+ for (const [index, entry] of alias.entries()) {
51
+ if (typeof entry !== 'string') {
52
+ throw new TypeError(`Command alias[${index}] must be a string`);
53
+ }
54
+ const tokens = entry.trim().split(/\s+/u).filter(Boolean);
55
+ if (tokens.length === 0) {
56
+ throw new TypeError(`Command alias[${index}] must contain at least one token`);
57
+ }
58
+ }
59
+ }
60
+ function validateCommandPermit(permit) {
61
+ if (permit === undefined)
62
+ return;
63
+ if (!Array.isArray(permit)) {
64
+ throw new TypeError('Command permit must be a readonly string[]');
65
+ }
66
+ for (const [index, entry] of permit.entries()) {
67
+ if (typeof entry !== 'string') {
68
+ throw new TypeError(`Command permit[${index}] must be a string`);
69
+ }
70
+ }
71
+ assertPermitSyntax(permit);
72
+ }
73
+ function validateCommandShortcutShape(shortcut) {
74
+ if (shortcut === undefined)
75
+ return;
76
+ if (!shortcut || typeof shortcut !== 'object' || Array.isArray(shortcut)) {
77
+ throw new TypeError('Command shortcut must be a Record<string, Record<string, value>>');
78
+ }
79
+ for (const [trigger, params] of Object.entries(shortcut)) {
80
+ if (!trigger.trim()) {
81
+ throw new TypeError('Command shortcut keys must be non-empty after trim');
82
+ }
83
+ if (!params || typeof params !== 'object' || Array.isArray(params)) {
84
+ throw new TypeError(`Command shortcut[${JSON.stringify(trigger)}] must be a params Record`);
85
+ }
86
+ }
87
+ }
40
88
  export function bindCommandParameter(definition, parameter) {
41
89
  if (!parameter)
42
90
  return definition;
@@ -75,9 +123,8 @@ export function resolveCommandSession(input) {
75
123
  ? input.metadata
76
124
  : undefined;
77
125
  const adapter = input.conversation.endpoint.adapter || undefined;
78
- const endpoint = typeof metadata?.endpoint === 'string' && metadata.endpoint
79
- ? metadata.endpoint
80
- : undefined;
126
+ const endpoint = input.endpointId
127
+ || (typeof metadata?.endpoint === 'string' && metadata.endpoint ? metadata.endpoint : undefined);
81
128
  const scene = resolveScene(input, metadata);
82
129
  const sender = resolveSender(input, metadata);
83
130
  return Object.freeze({
@@ -104,11 +151,8 @@ function resolveScene(input, metadata) {
104
151
  });
105
152
  }
106
153
  const conversation = input.conversation;
107
- const type = (typeof metadata?.channelType === 'string' && metadata.channelType)
108
- || (typeof metadata?.type === 'string' && metadata.type)
109
- || conversation.kind;
110
- const id = (typeof metadata?.channelId === 'string' && metadata.channelId)
111
- || conversation.id;
154
+ const type = conversation.kind;
155
+ const id = conversation.id;
112
156
  if (!type || !id)
113
157
  return undefined;
114
158
  const name = firstString(metadata?.channelName, metadata?.group_name, metadata?.groupName, metadata?.sceneName);
@@ -123,24 +167,21 @@ function resolveSender(input, metadata) {
123
167
  if (isCommandSender(structured)) {
124
168
  return freezeSender(structured);
125
169
  }
126
- // 允许上游把 sender 直接做成对象(未来 Runtime Message 演进)
127
- if (isCommandSender(input.sender)) {
128
- return freezeSender(input.sender);
129
- }
130
- const id = typeof input.sender === 'string' && input.sender
131
- ? input.sender
132
- : firstString(metadata?.user_id, metadata?.userId);
170
+ const id = input.sender?.id || firstString(metadata?.user_id, metadata?.userId);
133
171
  if (!id)
134
172
  return undefined;
135
- const name = firstString(metadata?.nickname, metadata?.senderName, metadata?.name);
136
- const role = resolveRoles(metadata);
173
+ const name = input.sender?.name || firstString(metadata?.nickname, metadata?.senderName, metadata?.name);
174
+ const role = resolveRoles(input, metadata);
137
175
  return Object.freeze({
138
176
  id,
139
177
  ...(name !== undefined ? { name } : {}),
140
178
  role,
141
179
  });
142
180
  }
143
- function resolveRoles(metadata) {
181
+ function resolveRoles(input, metadata) {
182
+ if (input.sender?.roles?.length) {
183
+ return Object.freeze([...input.sender.roles]);
184
+ }
144
185
  const roles = [];
145
186
  const push = (value) => {
146
187
  if (typeof value !== 'string')
package/lib/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './command-index.js';
2
2
  export * from './definition.js';
3
+ export { assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, isPlatformPermit, parsePermitName, type ParsedPermit, type PermitKind, } from './permit.js';
3
4
  export { CommandPathSyntaxError, commandFeatureId, default as commandFeature, } from './provider.js';
4
5
  export { default } from './provider.js';
package/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './command-index.js';
2
2
  export * from './definition.js';
3
+ export { assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, isPlatformPermit, parsePermitName, } from './permit.js';
3
4
  export { CommandPathSyntaxError, commandFeatureId, default as commandFeature, } from './provider.js';
4
5
  export { default } from './provider.js';
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Re-export permit 解析/校验 from @zhin.js/permission (SSOT)。
3
+ * 保留 CommandSession 兼容签名供 command-index 过渡使用。
4
+ */
5
+ export { type PermitKind, type ParsedPermit, parsePermitName, isBuiltinPermit, isPlatformPermit, assertPermitSyntax as assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, } from '@zhin.js/permission';
package/lib/permit.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Re-export permit 解析/校验 from @zhin.js/permission (SSOT)。
3
+ * 保留 CommandSession 兼容签名供 command-index 过渡使用。
4
+ */
5
+ export { parsePermitName, isBuiltinPermit, isPlatformPermit, assertPermitSyntax as assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, } from '@zhin.js/permission';