@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/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
 
@@ -104,7 +105,7 @@ export interface CommandConversation {
104
105
  readonly kind: 'private' | 'group' | 'channel';
105
106
  readonly id: string;
106
107
  readonly parent?: Readonly<{
107
- readonly kind: 'group' | 'channel';
108
+ readonly kind: 'private' | 'group' | 'channel';
108
109
  readonly id: string;
109
110
  }>;
110
111
  readonly threadId?: string;
@@ -119,14 +120,35 @@ export interface CommandConversation {
119
120
  export interface CommandMessage {
120
121
  readonly conversation: CommandConversation;
121
122
  readonly content: string;
122
- /** 发送者 id(扁平字段;结构化视图见 CommandContext.sender)。 */
123
- readonly sender?: string;
123
+ /** 发送者(结构化视图见 CommandContext.sender)。 */
124
+ readonly sender?: { readonly id: string; readonly name?: string; readonly roles?: readonly string[] };
124
125
  readonly id?: string;
125
126
  readonly metadata?: Readonly<Record<string, unknown>>;
126
127
  /** 若上游已结构化,优先采用。 */
127
128
  readonly scene?: CommandScene;
128
- readonly $reply?: (content: unknown) => Promise<unknown>;
129
- readonly $replyFrom?: (requester: string, content: unknown) => Promise<unknown>;
129
+ // 方法式声明(而非属性式函数类型):方法参数双变,runtime `Message`
130
+ // `$reply(content: SendContent)` 等才能鸭式兼容本契约(属性式是抗变,会报错)。
131
+ $reply?(content: unknown): Promise<unknown>;
132
+ $replyFrom?(requester: string, content: unknown): Promise<unknown>;
133
+ /** 向同 Endpoint 的另一个通道发送消息(结构兼容 `Message.$sendTo`)。 */
134
+ $sendTo?(
135
+ conversation: {
136
+ readonly kind: 'private' | 'group' | 'channel';
137
+ readonly id: string;
138
+ readonly parent?: Readonly<{ readonly kind: 'private' | 'group' | 'channel'; readonly id: string }>;
139
+ readonly threadId?: string;
140
+ },
141
+ content: unknown,
142
+ ): Promise<unknown>;
143
+ /** 私信当前消息的发送者(结构兼容 `Message.$replyToPrivate`)。 */
144
+ $replyToPrivate?(
145
+ content: unknown,
146
+ from?: boolean | { readonly kind: 'group' | 'channel'; readonly id: string },
147
+ ): Promise<unknown>;
148
+ /** 向指定群发送消息(结构兼容 `Message.$replyToGroup`)。 */
149
+ $replyToGroup?(groupId: string, content: unknown): Promise<unknown>;
150
+ /** 向指定频道发送消息(结构兼容 `Message.$replyToChannel`)。 */
151
+ $replyToChannel?(channelId: string, guildId: string, content: unknown, threadId?: string): Promise<unknown>;
130
152
  }
131
153
 
132
154
  /**
@@ -176,6 +198,21 @@ export interface CommandDefinition<
176
198
  * 类型 / 默认值 / 描述使用。静态命令可忽略本字段。
177
199
  */
178
200
  readonly params?: Readonly<Record<string, CommandParamSchema>>;
201
+ /**
202
+ * 本地静态段别名(可多词,如 `'gh issue'`)。替换全部本地静态段后仍挂
203
+ * owner 前缀;不打破子插件命名空间。
204
+ */
205
+ readonly alias?: readonly string[];
206
+ /**
207
+ * 内置 permit DSL(AND)。单项内逗号为 OR。
208
+ * 例:`adapter(icqq)`、`role(master)`、`group(123,456)`。
209
+ */
210
+ readonly permit?: readonly string[];
211
+ /**
212
+ * 全局整句快捷方式:触发串(trim 后全文相等)→ 预填 params。
213
+ * 可打破 owner 命名空间。
214
+ */
215
+ readonly shortcut?: Readonly<Record<string, Readonly<Record<string, CommandParameterValue>>>>;
179
216
  execute(context: CommandContext<TConfig, TInput>): TResult | Promise<TResult>;
180
217
  }
181
218
 
@@ -213,9 +250,58 @@ export function defineCommand<
213
250
  }
214
251
  }
215
252
  }
253
+ validateCommandAlias(definition.alias);
254
+ validateCommandPermit(definition.permit);
255
+ validateCommandShortcutShape(definition.shortcut);
216
256
  return Object.freeze({ $feature: commandBrand, ...definition });
217
257
  }
218
258
 
259
+ function validateCommandAlias(alias: readonly string[] | undefined): void {
260
+ if (alias === undefined) return;
261
+ if (!Array.isArray(alias)) {
262
+ throw new TypeError('Command alias must be a readonly string[]');
263
+ }
264
+ for (const [index, entry] of alias.entries()) {
265
+ if (typeof entry !== 'string') {
266
+ throw new TypeError(`Command alias[${index}] must be a string`);
267
+ }
268
+ const tokens = entry.trim().split(/\s+/u).filter(Boolean);
269
+ if (tokens.length === 0) {
270
+ throw new TypeError(`Command alias[${index}] must contain at least one token`);
271
+ }
272
+ }
273
+ }
274
+
275
+ function validateCommandPermit(permit: readonly string[] | undefined): void {
276
+ if (permit === undefined) return;
277
+ if (!Array.isArray(permit)) {
278
+ throw new TypeError('Command permit must be a readonly string[]');
279
+ }
280
+ for (const [index, entry] of permit.entries()) {
281
+ if (typeof entry !== 'string') {
282
+ throw new TypeError(`Command permit[${index}] must be a string`);
283
+ }
284
+ }
285
+ assertPermitSyntax(permit);
286
+ }
287
+
288
+ function validateCommandShortcutShape(
289
+ shortcut: Readonly<Record<string, Readonly<Record<string, CommandParameterValue>>>> | undefined,
290
+ ): void {
291
+ if (shortcut === undefined) return;
292
+ if (!shortcut || typeof shortcut !== 'object' || Array.isArray(shortcut)) {
293
+ throw new TypeError('Command shortcut must be a Record<string, Record<string, value>>');
294
+ }
295
+ for (const [trigger, params] of Object.entries(shortcut)) {
296
+ if (!trigger.trim()) {
297
+ throw new TypeError('Command shortcut keys must be non-empty after trim');
298
+ }
299
+ if (!params || typeof params !== 'object' || Array.isArray(params)) {
300
+ throw new TypeError(`Command shortcut[${JSON.stringify(trigger)}] must be a params Record`);
301
+ }
302
+ }
303
+ }
304
+
219
305
  export function bindCommandParameter<
220
306
  TConfig,
221
307
  TResult,
@@ -271,9 +357,8 @@ export function resolveCommandSession(input: unknown): CommandSession {
271
357
  : undefined;
272
358
 
273
359
  const adapter = input.conversation.endpoint.adapter || undefined;
274
- const endpoint = typeof metadata?.endpoint === 'string' && metadata.endpoint
275
- ? metadata.endpoint
276
- : undefined;
360
+ const endpoint = (input as { endpointId?: string }).endpointId
361
+ || (typeof metadata?.endpoint === 'string' && metadata.endpoint ? metadata.endpoint : undefined);
277
362
 
278
363
  const scene = resolveScene(input, metadata);
279
364
  const sender = resolveSender(input, metadata);
@@ -307,11 +392,8 @@ function resolveScene(
307
392
  }
308
393
 
309
394
  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;
395
+ const type = conversation.kind;
396
+ const id = conversation.id;
315
397
  if (!type || !id) return undefined;
316
398
 
317
399
  const name = firstString(
@@ -336,18 +418,12 @@ function resolveSender(
336
418
  if (isCommandSender(structured)) {
337
419
  return freezeSender(structured);
338
420
  }
339
- // 允许上游把 sender 直接做成对象(未来 Runtime Message 演进)
340
- if (isCommandSender(input.sender)) {
341
- return freezeSender(input.sender);
342
- }
343
421
 
344
- const id = typeof input.sender === 'string' && input.sender
345
- ? input.sender
346
- : firstString(metadata?.user_id, metadata?.userId);
422
+ const id = input.sender?.id || firstString(metadata?.user_id, metadata?.userId);
347
423
  if (!id) return undefined;
348
424
 
349
- const name = firstString(metadata?.nickname, metadata?.senderName, metadata?.name);
350
- const role = resolveRoles(metadata);
425
+ const name = input.sender?.name || firstString(metadata?.nickname, metadata?.senderName, metadata?.name);
426
+ const role = resolveRoles(input, metadata);
351
427
 
352
428
  return Object.freeze({
353
429
  id,
@@ -357,8 +433,12 @@ function resolveSender(
357
433
  }
358
434
 
359
435
  function resolveRoles(
436
+ input: CommandMessage,
360
437
  metadata: Readonly<Record<string, unknown>> | undefined,
361
438
  ): readonly string[] {
439
+ if (input.sender?.roles?.length) {
440
+ return Object.freeze([...input.sender.roles]);
441
+ }
362
442
  const roles: string[] = [];
363
443
  const push = (value: unknown) => {
364
444
  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);