@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/src/definition.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  createCapabilityContext,
4
4
  type CapabilityContext,
5
5
  } from '@zhin.js/feature-kit';
6
+ import { assertPermitSyntax } from '@zhin.js/permission';
6
7
 
7
8
  const commandBrand = 'zhin.command/1' as const;
8
9
 
@@ -29,6 +30,29 @@ export type CommandParameterValue =
29
30
  | Readonly<Record<string, unknown>>
30
31
  | null;
31
32
 
33
+ /**
34
+ * 可从运行时会话上下文动态解析的参数值。
35
+ *
36
+ * 静态值直接使用;函数值在命令派发时接收 {@link CommandSession},
37
+ * 返回最终的 {@link CommandParameterValue}。适用于 shortcut 预填
38
+ * 和 params.default。
39
+ *
40
+ * ```ts
41
+ * defineCommand({
42
+ * params: {
43
+ * user_id: { type: 'string', default: (s) => s.sender?.id ?? '' },
44
+ * },
45
+ * shortcut: {
46
+ * '查看我的信息': { user_id: (s) => s.sender?.id ?? '' },
47
+ * },
48
+ * execute: ({ params }) => `profile:${params.user_id}`,
49
+ * })
50
+ * ```
51
+ */
52
+ export type CommandDynamicValue =
53
+ | CommandParameterValue
54
+ | ((session: CommandSession) => CommandParameterValue);
55
+
32
56
  export const commandParameterTypes: ReadonlySet<CommandParameterType> = new Set([
33
57
  'string',
34
58
  'number',
@@ -53,7 +77,7 @@ export const commandParameterTypes: ReadonlySet<CommandParameterType> = new Set(
53
77
  */
54
78
  export interface CommandParamSchema {
55
79
  readonly type: CommandParameterType;
56
- readonly default?: CommandParameterValue;
80
+ readonly default?: CommandDynamicValue;
57
81
  readonly description?: string;
58
82
  }
59
83
 
@@ -66,7 +90,7 @@ export interface CommandSegment {
66
90
  export interface CommandParameterDefinition {
67
91
  readonly name: string;
68
92
  readonly type: CommandParameterType;
69
- readonly defaultValue?: CommandParameterValue;
93
+ readonly defaultValue?: CommandDynamicValue;
70
94
  /** `[[name]]` / `[[...name]]` 可选段;缺省按 `defaultValue === undefined` 推断。 */
71
95
  readonly optional?: boolean;
72
96
  /** `[...name]` / `[[...name]]` 捕获所有段,运行时值为 `string[]`。 */
@@ -104,7 +128,7 @@ export interface CommandConversation {
104
128
  readonly kind: 'private' | 'group' | 'channel';
105
129
  readonly id: string;
106
130
  readonly parent?: Readonly<{
107
- readonly kind: 'group' | 'channel';
131
+ readonly kind: 'private' | 'group' | 'channel';
108
132
  readonly id: string;
109
133
  }>;
110
134
  readonly threadId?: string;
@@ -119,14 +143,35 @@ export interface CommandConversation {
119
143
  export interface CommandMessage {
120
144
  readonly conversation: CommandConversation;
121
145
  readonly content: string;
122
- /** 发送者 id(扁平字段;结构化视图见 CommandContext.sender)。 */
123
- readonly sender?: string;
146
+ /** 发送者(结构化视图见 CommandContext.sender)。 */
147
+ readonly sender?: { readonly id: string; readonly name?: string; readonly roles?: readonly string[] };
124
148
  readonly id?: string;
125
149
  readonly metadata?: Readonly<Record<string, unknown>>;
126
150
  /** 若上游已结构化,优先采用。 */
127
151
  readonly scene?: CommandScene;
128
- readonly $reply?: (content: unknown) => Promise<unknown>;
129
- readonly $replyFrom?: (requester: string, content: unknown) => Promise<unknown>;
152
+ // 方法式声明(而非属性式函数类型):方法参数双变,runtime `Message`
153
+ // `$reply(content: SendContent)` 等才能鸭式兼容本契约(属性式是抗变,会报错)。
154
+ $reply?(content: unknown): Promise<unknown>;
155
+ $replyFrom?(requester: string, content: unknown): Promise<unknown>;
156
+ /** 向同 Endpoint 的另一个通道发送消息(结构兼容 `Message.$sendTo`)。 */
157
+ $sendTo?(
158
+ conversation: {
159
+ readonly kind: 'private' | 'group' | 'channel';
160
+ readonly id: string;
161
+ readonly parent?: Readonly<{ readonly kind: 'private' | 'group' | 'channel'; readonly id: string }>;
162
+ readonly threadId?: string;
163
+ },
164
+ content: unknown,
165
+ ): Promise<unknown>;
166
+ /** 私信当前消息的发送者(结构兼容 `Message.$replyToPrivate`)。 */
167
+ $replyToPrivate?(
168
+ content: unknown,
169
+ from?: boolean | { readonly kind: 'group' | 'channel'; readonly id: string },
170
+ ): Promise<unknown>;
171
+ /** 向指定群发送消息(结构兼容 `Message.$replyToGroup`)。 */
172
+ $replyToGroup?(groupId: string, content: unknown): Promise<unknown>;
173
+ /** 向指定频道发送消息(结构兼容 `Message.$replyToChannel`)。 */
174
+ $replyToChannel?(channelId: string, guildId: string, content: unknown, threadId?: string): Promise<unknown>;
130
175
  }
131
176
 
132
177
  /**
@@ -176,6 +221,21 @@ export interface CommandDefinition<
176
221
  * 类型 / 默认值 / 描述使用。静态命令可忽略本字段。
177
222
  */
178
223
  readonly params?: Readonly<Record<string, CommandParamSchema>>;
224
+ /**
225
+ * 本地静态段别名(可多词,如 `'gh issue'`)。替换全部本地静态段后仍挂
226
+ * owner 前缀;不打破子插件命名空间。
227
+ */
228
+ readonly alias?: readonly string[];
229
+ /**
230
+ * 内置 permit DSL(AND)。单项内逗号为 OR。
231
+ * 例:`adapter(icqq)`、`role(master)`、`group(123,456)`。
232
+ */
233
+ readonly permit?: readonly string[];
234
+ /**
235
+ * 全局整句快捷方式:触发串(trim 后全文相等)→ 预填 params。
236
+ * 可打破 owner 命名空间。
237
+ */
238
+ readonly shortcut?: Readonly<Record<string, Readonly<Record<string, CommandDynamicValue>>>>;
179
239
  execute(context: CommandContext<TConfig, TInput>): TResult | Promise<TResult>;
180
240
  }
181
241
 
@@ -213,9 +273,58 @@ export function defineCommand<
213
273
  }
214
274
  }
215
275
  }
276
+ validateCommandAlias(definition.alias);
277
+ validateCommandPermit(definition.permit);
278
+ validateCommandShortcutShape(definition.shortcut);
216
279
  return Object.freeze({ $feature: commandBrand, ...definition });
217
280
  }
218
281
 
282
+ function validateCommandAlias(alias: readonly string[] | undefined): void {
283
+ if (alias === undefined) return;
284
+ if (!Array.isArray(alias)) {
285
+ throw new TypeError('Command alias must be a readonly string[]');
286
+ }
287
+ for (const [index, entry] of alias.entries()) {
288
+ if (typeof entry !== 'string') {
289
+ throw new TypeError(`Command alias[${index}] must be a string`);
290
+ }
291
+ const tokens = entry.trim().split(/\s+/u).filter(Boolean);
292
+ if (tokens.length === 0) {
293
+ throw new TypeError(`Command alias[${index}] must contain at least one token`);
294
+ }
295
+ }
296
+ }
297
+
298
+ function validateCommandPermit(permit: readonly string[] | undefined): void {
299
+ if (permit === undefined) return;
300
+ if (!Array.isArray(permit)) {
301
+ throw new TypeError('Command permit must be a readonly string[]');
302
+ }
303
+ for (const [index, entry] of permit.entries()) {
304
+ if (typeof entry !== 'string') {
305
+ throw new TypeError(`Command permit[${index}] must be a string`);
306
+ }
307
+ }
308
+ assertPermitSyntax(permit);
309
+ }
310
+
311
+ function validateCommandShortcutShape(
312
+ shortcut: Readonly<Record<string, Readonly<Record<string, CommandDynamicValue>>>> | undefined,
313
+ ): void {
314
+ if (shortcut === undefined) return;
315
+ if (!shortcut || typeof shortcut !== 'object' || Array.isArray(shortcut)) {
316
+ throw new TypeError('Command shortcut must be a Record<string, Record<string, value>>');
317
+ }
318
+ for (const [trigger, params] of Object.entries(shortcut)) {
319
+ if (!trigger.trim()) {
320
+ throw new TypeError('Command shortcut keys must be non-empty after trim');
321
+ }
322
+ if (!params || typeof params !== 'object' || Array.isArray(params)) {
323
+ throw new TypeError(`Command shortcut[${JSON.stringify(trigger)}] must be a params Record`);
324
+ }
325
+ }
326
+ }
327
+
219
328
  export function bindCommandParameter<
220
329
  TConfig,
221
330
  TResult,
@@ -239,6 +348,22 @@ export function parseCommandDefinition(value: unknown): CommandDefinition {
239
348
  return definition as CommandDefinition;
240
349
  }
241
350
 
351
+ /**
352
+ * 将动态参数值(可能包含函数)批量解析为静态值。
353
+ * 函数值接收从 `source`(通常是 IM Runtime `Message`)解析出的 {@link CommandSession}。
354
+ */
355
+ export function resolveDynamicParams(
356
+ params: Readonly<Record<string, CommandDynamicValue>>,
357
+ source: unknown,
358
+ ): Readonly<Record<string, CommandParameterValue>> {
359
+ const session = resolveCommandSession(source);
360
+ const resolved: Record<string, CommandParameterValue> = {};
361
+ for (const [key, value] of Object.entries(params)) {
362
+ resolved[key] = typeof value === 'function' ? value(session) : value;
363
+ }
364
+ return Object.freeze(resolved);
365
+ }
366
+
242
367
  export function createCommandContext(
243
368
  snapshot: RuntimeSnapshot,
244
369
  ownerId: PluginId,
@@ -271,9 +396,8 @@ export function resolveCommandSession(input: unknown): CommandSession {
271
396
  : undefined;
272
397
 
273
398
  const adapter = input.conversation.endpoint.adapter || undefined;
274
- const endpoint = typeof metadata?.endpoint === 'string' && metadata.endpoint
275
- ? metadata.endpoint
276
- : undefined;
399
+ const endpoint = (input as { endpointId?: string }).endpointId
400
+ || (typeof metadata?.endpoint === 'string' && metadata.endpoint ? metadata.endpoint : undefined);
277
401
 
278
402
  const scene = resolveScene(input, metadata);
279
403
  const sender = resolveSender(input, metadata);
@@ -307,11 +431,8 @@ function resolveScene(
307
431
  }
308
432
 
309
433
  const conversation = input.conversation;
310
- const type = (typeof metadata?.channelType === 'string' && metadata.channelType)
311
- || (typeof metadata?.type === 'string' && metadata.type)
312
- || conversation.kind;
313
- const id = (typeof metadata?.channelId === 'string' && metadata.channelId)
314
- || conversation.id;
434
+ const type = conversation.kind;
435
+ const id = conversation.id;
315
436
  if (!type || !id) return undefined;
316
437
 
317
438
  const name = firstString(
@@ -336,18 +457,12 @@ function resolveSender(
336
457
  if (isCommandSender(structured)) {
337
458
  return freezeSender(structured);
338
459
  }
339
- // 允许上游把 sender 直接做成对象(未来 Runtime Message 演进)
340
- if (isCommandSender(input.sender)) {
341
- return freezeSender(input.sender);
342
- }
343
460
 
344
- const id = typeof input.sender === 'string' && input.sender
345
- ? input.sender
346
- : firstString(metadata?.user_id, metadata?.userId);
461
+ const id = input.sender?.id || firstString(metadata?.user_id, metadata?.userId);
347
462
  if (!id) return undefined;
348
463
 
349
- const name = firstString(metadata?.nickname, metadata?.senderName, metadata?.name);
350
- const role = resolveRoles(metadata);
464
+ const name = input.sender?.name || firstString(metadata?.nickname, metadata?.senderName, metadata?.name);
465
+ const role = resolveRoles(input, metadata);
351
466
 
352
467
  return Object.freeze({
353
468
  id,
@@ -357,8 +472,12 @@ function resolveSender(
357
472
  }
358
473
 
359
474
  function resolveRoles(
475
+ input: CommandMessage,
360
476
  metadata: Readonly<Record<string, unknown>> | undefined,
361
477
  ): readonly string[] {
478
+ if (input.sender?.roles?.length) {
479
+ return Object.freeze([...input.sender.roles]);
480
+ }
362
481
  const roles: string[] = [];
363
482
  const push = (value: unknown) => {
364
483
  if (typeof value !== 'string') return;
package/src/index.ts CHANGED
@@ -1,5 +1,15 @@
1
1
  export * from './command-index.js';
2
2
  export * from './definition.js';
3
+ export {
4
+ assertBuiltinPermits,
5
+ checkBuiltinPermit,
6
+ checkBuiltinPermitList,
7
+ isBuiltinPermit,
8
+ isPlatformPermit,
9
+ parsePermitName,
10
+ type ParsedPermit,
11
+ type PermitKind,
12
+ } from './permit.js';
3
13
  export {
4
14
  CommandPathSyntaxError,
5
15
  commandFeatureId,
package/src/permit.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Re-export permit 解析/校验 from @zhin.js/permission (SSOT)。
3
+ * 保留 CommandSession 兼容签名供 command-index 过渡使用。
4
+ */
5
+ export {
6
+ type PermitKind,
7
+ type ParsedPermit,
8
+ parsePermitName,
9
+ isBuiltinPermit,
10
+ isPlatformPermit,
11
+ assertPermitSyntax as assertBuiltinPermits,
12
+ checkBuiltinPermit,
13
+ checkBuiltinPermitList,
14
+ } from '@zhin.js/permission';
package/src/provider.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { basename, join, parse, sep } from 'node:path';
2
- import { featureId } from '@zhin.js/plugin-runtime';
2
+ import { featureId, isCapabilityLocalSegment } from '@zhin.js/plugin-runtime';
3
3
  import {
4
4
  defineFeatureProvider,
5
5
  type DiscoveryContext,
@@ -54,7 +54,7 @@ async function* discoverCommandDirectory(
54
54
  }
55
55
  }
56
56
  for (const entry of entries) {
57
- if (entry.kind === 'directory' && isCommandSegment(entry.name)) {
57
+ if (entry.kind === 'directory' && isCapabilityLocalSegment(entry.name)) {
58
58
  yield* discoverCommandDirectory(
59
59
  context,
60
60
  join(directory, entry.name),
@@ -74,10 +74,6 @@ async function* discoverCommandDirectory(
74
74
  }
75
75
  }
76
76
 
77
- function isCommandSegment(value: string): boolean {
78
- return /^[a-z0-9][a-z0-9-]*$/.test(value);
79
- }
80
-
81
77
  interface ParsedCommandFile {
82
78
  readonly localSegment: string;
83
79
  readonly parameter?: CommandParameterHint;
@@ -101,9 +97,15 @@ const dynamicCommandFilePatterns: ReadonlyArray<{
101
97
  { pattern: /^\[([a-zA-Z][a-zA-Z0-9]*)\]\.(?:tsx?|[cm]?js)$/, optional: false, rest: false },
102
98
  ];
103
99
 
100
+ const commandModuleExtension = /\.(?:tsx?|[cm]?js)$/u;
101
+
104
102
  function parseCommandFile(value: string): ParsedCommandFile | undefined {
105
- if (/^[a-z0-9][a-z0-9-]*\.(?:tsx?|[cm]?js)$/.test(value)) {
106
- return { localSegment: parse(value).name };
103
+ // 静态段:ASCII kebab(hello.ts)或 Unicode 名(赞我.ts);与 isCapabilityLocalSegment 对齐。
104
+ if (commandModuleExtension.test(value)) {
105
+ const localSegment = parse(value).name;
106
+ if (isCapabilityLocalSegment(localSegment)) {
107
+ return { localSegment };
108
+ }
107
109
  }
108
110
  for (const { pattern, optional, rest } of dynamicCommandFilePatterns) {
109
111
  const match = pattern.exec(value);