@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 +18 -4
- package/lib/command-index.d.ts +4 -0
- package/lib/command-index.js +280 -45
- package/lib/definition.d.ts +83 -7
- package/lib/definition.js +90 -40
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/permit.d.ts +5 -0
- package/lib/permit.js +5 -0
- package/lib/provider.d.ts +2 -1
- package/lib/provider.js +47 -68
- package/package.json +4 -3
- package/src/command-index.ts +339 -45
- package/src/definition.ts +175 -43
- package/src/index.ts +10 -0
- package/src/permit.ts +14 -0
- package/src/provider.ts +68 -87
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
|
|
|
@@ -25,9 +26,38 @@ export type CommandParameterValue =
|
|
|
25
26
|
| string
|
|
26
27
|
| number
|
|
27
28
|
| boolean
|
|
29
|
+
| ReadonlyArray<string | number | boolean>
|
|
28
30
|
| Readonly<Record<string, unknown>>
|
|
29
31
|
| null;
|
|
30
32
|
|
|
33
|
+
export const commandParameterTypes: ReadonlySet<CommandParameterType> = new Set([
|
|
34
|
+
'string',
|
|
35
|
+
'number',
|
|
36
|
+
'integer',
|
|
37
|
+
'float',
|
|
38
|
+
'boolean',
|
|
39
|
+
'word',
|
|
40
|
+
'text',
|
|
41
|
+
'mention',
|
|
42
|
+
'image',
|
|
43
|
+
'face',
|
|
44
|
+
'reply',
|
|
45
|
+
'forward',
|
|
46
|
+
'dice',
|
|
47
|
+
'rps',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Next.js 风格参数声明(`defineCommand({ params: ... })`)。
|
|
52
|
+
* 文件名只声明参数形态(`[name]` / `[[name]]` / `[...name]` / `[[...name]]`),
|
|
53
|
+
* 类型与默认值统一在这里声明。
|
|
54
|
+
*/
|
|
55
|
+
export interface CommandParamSchema {
|
|
56
|
+
readonly type: CommandParameterType;
|
|
57
|
+
readonly default?: CommandParameterValue;
|
|
58
|
+
readonly description?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
31
61
|
/** Minimal structural contract shared with canonical IM segments. */
|
|
32
62
|
export interface CommandSegment {
|
|
33
63
|
readonly type: string | { readonly name: string };
|
|
@@ -38,6 +68,11 @@ export interface CommandParameterDefinition {
|
|
|
38
68
|
readonly name: string;
|
|
39
69
|
readonly type: CommandParameterType;
|
|
40
70
|
readonly defaultValue?: CommandParameterValue;
|
|
71
|
+
/** `[[name]]` / `[[...name]]` 可选段;缺省按 `defaultValue === undefined` 推断。 */
|
|
72
|
+
readonly optional?: boolean;
|
|
73
|
+
/** `[...name]` / `[[...name]]` 捕获所有段,运行时值为 `string[]`。 */
|
|
74
|
+
readonly rest?: boolean;
|
|
75
|
+
readonly description?: string;
|
|
41
76
|
}
|
|
42
77
|
|
|
43
78
|
/** 场景:群 / 私聊 / 频道等。 */
|
|
@@ -57,6 +92,25 @@ export interface CommandSender {
|
|
|
57
92
|
readonly role: readonly string[];
|
|
58
93
|
}
|
|
59
94
|
|
|
95
|
+
/**
|
|
96
|
+
* 命令侧入站会话契约(结构对齐 `@zhin.js/im-contract` 的 ConversationRef;
|
|
97
|
+
* command 为 Feature 层,不能 import core / IM 契约包,故独立声明)。
|
|
98
|
+
*/
|
|
99
|
+
export interface CommandConversation {
|
|
100
|
+
readonly endpoint: Readonly<{
|
|
101
|
+
readonly id: string;
|
|
102
|
+
/** 适配器插件 owner(PluginId),与 `snapshot.config.get(adapter)` 对齐。 */
|
|
103
|
+
readonly adapter: string;
|
|
104
|
+
}>;
|
|
105
|
+
readonly kind: 'private' | 'group' | 'channel';
|
|
106
|
+
readonly id: string;
|
|
107
|
+
readonly parent?: Readonly<{
|
|
108
|
+
readonly kind: 'private' | 'group' | 'channel';
|
|
109
|
+
readonly id: string;
|
|
110
|
+
}>;
|
|
111
|
+
readonly threadId?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
60
114
|
/**
|
|
61
115
|
* 命令侧入站消息契约。
|
|
62
116
|
*
|
|
@@ -64,17 +118,37 @@ export interface CommandSender {
|
|
|
64
118
|
* 因架构分层(command 为 Feature 层,不能 import core),此处独立声明。
|
|
65
119
|
*/
|
|
66
120
|
export interface CommandMessage {
|
|
67
|
-
readonly
|
|
68
|
-
readonly target: string;
|
|
121
|
+
readonly conversation: CommandConversation;
|
|
69
122
|
readonly content: string;
|
|
70
|
-
/**
|
|
71
|
-
readonly sender?: string;
|
|
123
|
+
/** 发送者(结构化视图见 CommandContext.sender)。 */
|
|
124
|
+
readonly sender?: { readonly id: string; readonly name?: string; readonly roles?: readonly string[] };
|
|
72
125
|
readonly id?: string;
|
|
73
126
|
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
74
127
|
/** 若上游已结构化,优先采用。 */
|
|
75
128
|
readonly scene?: CommandScene;
|
|
76
|
-
|
|
77
|
-
|
|
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>;
|
|
78
152
|
}
|
|
79
153
|
|
|
80
154
|
/**
|
|
@@ -119,6 +193,26 @@ export interface CommandDefinition<
|
|
|
119
193
|
readonly $feature: typeof commandBrand;
|
|
120
194
|
readonly $parameter?: CommandParameterDefinition;
|
|
121
195
|
readonly description?: string;
|
|
196
|
+
/**
|
|
197
|
+
* Next.js 风格参数声明:动态段文件名(`[name]` 等)的形态配合这里的
|
|
198
|
+
* 类型 / 默认值 / 描述使用。静态命令可忽略本字段。
|
|
199
|
+
*/
|
|
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>>>>;
|
|
122
216
|
execute(context: CommandContext<TConfig, TInput>): TResult | Promise<TResult>;
|
|
123
217
|
}
|
|
124
218
|
|
|
@@ -145,9 +239,69 @@ export function defineCommand<
|
|
|
145
239
|
if (typeof definition.execute !== 'function') {
|
|
146
240
|
throw new TypeError('Command execute must be a function');
|
|
147
241
|
}
|
|
242
|
+
if (definition.params !== undefined) {
|
|
243
|
+
if (!definition.params || typeof definition.params !== 'object') {
|
|
244
|
+
throw new TypeError('Command params must be a Record<string, CommandParamSchema>');
|
|
245
|
+
}
|
|
246
|
+
for (const [name, schema] of Object.entries(definition.params)) {
|
|
247
|
+
if (!schema || typeof schema !== 'object'
|
|
248
|
+
|| !commandParameterTypes.has((schema as CommandParamSchema).type)) {
|
|
249
|
+
throw new TypeError(`Command params.${name} requires a valid type`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
validateCommandAlias(definition.alias);
|
|
254
|
+
validateCommandPermit(definition.permit);
|
|
255
|
+
validateCommandShortcutShape(definition.shortcut);
|
|
148
256
|
return Object.freeze({ $feature: commandBrand, ...definition });
|
|
149
257
|
}
|
|
150
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
|
+
|
|
151
305
|
export function bindCommandParameter<
|
|
152
306
|
TConfig,
|
|
153
307
|
TResult,
|
|
@@ -202,10 +356,9 @@ export function resolveCommandSession(input: unknown): CommandSession {
|
|
|
202
356
|
? input.metadata as Readonly<Record<string, unknown>>
|
|
203
357
|
: undefined;
|
|
204
358
|
|
|
205
|
-
const adapter = input.adapter
|
|
206
|
-
const endpoint =
|
|
207
|
-
? metadata.endpoint
|
|
208
|
-
: undefined;
|
|
359
|
+
const adapter = input.conversation.endpoint.adapter || undefined;
|
|
360
|
+
const endpoint = (input as { endpointId?: string }).endpointId
|
|
361
|
+
|| (typeof metadata?.endpoint === 'string' && metadata.endpoint ? metadata.endpoint : undefined);
|
|
209
362
|
|
|
210
363
|
const scene = resolveScene(input, metadata);
|
|
211
364
|
const sender = resolveSender(input, metadata);
|
|
@@ -221,8 +374,8 @@ export function resolveCommandSession(input: unknown): CommandSession {
|
|
|
221
374
|
function isCommandMessageLike(input: unknown): input is CommandMessage {
|
|
222
375
|
if (!input || typeof input !== 'object') return false;
|
|
223
376
|
const value = input as Partial<CommandMessage>;
|
|
224
|
-
return
|
|
225
|
-
&& typeof value.
|
|
377
|
+
return !!value.conversation
|
|
378
|
+
&& typeof value.conversation === 'object'
|
|
226
379
|
&& typeof value.content === 'string';
|
|
227
380
|
}
|
|
228
381
|
|
|
@@ -238,12 +391,9 @@ function resolveScene(
|
|
|
238
391
|
});
|
|
239
392
|
}
|
|
240
393
|
|
|
241
|
-
const
|
|
242
|
-
const type =
|
|
243
|
-
|
|
244
|
-
|| parsed?.type;
|
|
245
|
-
const id = (typeof metadata?.channelId === 'string' && metadata.channelId)
|
|
246
|
-
|| parsed?.id;
|
|
394
|
+
const conversation = input.conversation;
|
|
395
|
+
const type = conversation.kind;
|
|
396
|
+
const id = conversation.id;
|
|
247
397
|
if (!type || !id) return undefined;
|
|
248
398
|
|
|
249
399
|
const name = firstString(
|
|
@@ -268,18 +418,12 @@ function resolveSender(
|
|
|
268
418
|
if (isCommandSender(structured)) {
|
|
269
419
|
return freezeSender(structured);
|
|
270
420
|
}
|
|
271
|
-
// 允许上游把 sender 直接做成对象(未来 Runtime Message 演进)
|
|
272
|
-
if (isCommandSender(input.sender)) {
|
|
273
|
-
return freezeSender(input.sender);
|
|
274
|
-
}
|
|
275
421
|
|
|
276
|
-
const id =
|
|
277
|
-
? input.sender
|
|
278
|
-
: firstString(metadata?.user_id, metadata?.userId);
|
|
422
|
+
const id = input.sender?.id || firstString(metadata?.user_id, metadata?.userId);
|
|
279
423
|
if (!id) return undefined;
|
|
280
424
|
|
|
281
|
-
const name = firstString(metadata?.nickname, metadata?.senderName, metadata?.name);
|
|
282
|
-
const role = resolveRoles(metadata);
|
|
425
|
+
const name = input.sender?.name || firstString(metadata?.nickname, metadata?.senderName, metadata?.name);
|
|
426
|
+
const role = resolveRoles(input, metadata);
|
|
283
427
|
|
|
284
428
|
return Object.freeze({
|
|
285
429
|
id,
|
|
@@ -289,8 +433,12 @@ function resolveSender(
|
|
|
289
433
|
}
|
|
290
434
|
|
|
291
435
|
function resolveRoles(
|
|
436
|
+
input: CommandMessage,
|
|
292
437
|
metadata: Readonly<Record<string, unknown>> | undefined,
|
|
293
438
|
): readonly string[] {
|
|
439
|
+
if (input.sender?.roles?.length) {
|
|
440
|
+
return Object.freeze([...input.sender.roles]);
|
|
441
|
+
}
|
|
294
442
|
const roles: string[] = [];
|
|
295
443
|
const push = (value: unknown) => {
|
|
296
444
|
if (typeof value !== 'string') return;
|
|
@@ -309,22 +457,6 @@ function resolveRoles(
|
|
|
309
457
|
return Object.freeze(roles);
|
|
310
458
|
}
|
|
311
459
|
|
|
312
|
-
function parseTarget(target: string): { readonly type: string; readonly id: string } | undefined {
|
|
313
|
-
const parts = target.split(':').filter(Boolean);
|
|
314
|
-
if (parts.length < 2) return undefined;
|
|
315
|
-
const [kind, ...rest] = parts;
|
|
316
|
-
if (!kind) return undefined;
|
|
317
|
-
const lastPart = parts.at(-1);
|
|
318
|
-
if (!lastPart) return undefined;
|
|
319
|
-
if (kind === 'channel' && parts.length >= 3) {
|
|
320
|
-
return { type: 'channel', id: lastPart };
|
|
321
|
-
}
|
|
322
|
-
if (kind === 'temp' && parts.length >= 3) {
|
|
323
|
-
return { type: 'private', id: lastPart };
|
|
324
|
-
}
|
|
325
|
-
return { type: kind, id: rest.join(':') };
|
|
326
|
-
}
|
|
327
|
-
|
|
328
460
|
function isCommandScene(value: unknown): value is CommandScene {
|
|
329
461
|
if (!value || typeof value !== 'object') return false;
|
|
330
462
|
const scene = value as Partial<CommandScene>;
|
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,
|
|
@@ -10,9 +10,8 @@ import { CommandIndex } from './command-index.js';
|
|
|
10
10
|
import {
|
|
11
11
|
bindCommandParameter,
|
|
12
12
|
parseCommandDefinition,
|
|
13
|
+
type CommandDefinition,
|
|
13
14
|
type CommandParameterDefinition,
|
|
14
|
-
type CommandParameterType,
|
|
15
|
-
type CommandParameterValue,
|
|
16
15
|
} from './definition.js';
|
|
17
16
|
|
|
18
17
|
export const commandFeatureId = featureId('zhin.command');
|
|
@@ -27,7 +26,7 @@ const commandFiles: SourceConvention = {
|
|
|
27
26
|
const module = await context.host.loadModule<{ default?: unknown }>(source.source);
|
|
28
27
|
const definition = parseCommandDefinition(module.default);
|
|
29
28
|
const file = parseCommandFile(basename(source.source));
|
|
30
|
-
return bindCommandParameter(definition, file
|
|
29
|
+
return bindCommandParameter(definition, resolveParameter(definition, file, source.source));
|
|
31
30
|
},
|
|
32
31
|
};
|
|
33
32
|
|
|
@@ -55,7 +54,7 @@ async function* discoverCommandDirectory(
|
|
|
55
54
|
}
|
|
56
55
|
}
|
|
57
56
|
for (const entry of entries) {
|
|
58
|
-
if (entry.kind === 'directory' &&
|
|
57
|
+
if (entry.kind === 'directory' && isCapabilityLocalSegment(entry.name)) {
|
|
59
58
|
yield* discoverCommandDirectory(
|
|
60
59
|
context,
|
|
61
60
|
join(directory, entry.name),
|
|
@@ -75,51 +74,48 @@ async function* discoverCommandDirectory(
|
|
|
75
74
|
}
|
|
76
75
|
}
|
|
77
76
|
|
|
78
|
-
function isCommandSegment(value: string): boolean {
|
|
79
|
-
return /^[a-z0-9][a-z0-9-]*$/.test(value);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
77
|
interface ParsedCommandFile {
|
|
83
78
|
readonly localSegment: string;
|
|
84
|
-
readonly parameter?:
|
|
79
|
+
readonly parameter?: CommandParameterHint;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 文件名声明的参数形态;类型与默认值来自 `defineCommand({ params })`。 */
|
|
83
|
+
interface CommandParameterHint {
|
|
84
|
+
readonly name: string;
|
|
85
|
+
readonly optional: boolean;
|
|
86
|
+
readonly rest: boolean;
|
|
85
87
|
}
|
|
86
88
|
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
+
const dynamicCommandFilePatterns: ReadonlyArray<{
|
|
90
|
+
readonly pattern: RegExp;
|
|
91
|
+
readonly optional: boolean;
|
|
92
|
+
readonly rest: boolean;
|
|
93
|
+
}> = [
|
|
94
|
+
{ pattern: /^\[\[\.\.\.([a-zA-Z][a-zA-Z0-9]*)\]\]\.(?:tsx?|[cm]?js)$/, optional: true, rest: true },
|
|
95
|
+
{ pattern: /^\[\.\.\.([a-zA-Z][a-zA-Z0-9]*)\]\.(?:tsx?|[cm]?js)$/, optional: false, rest: true },
|
|
96
|
+
{ pattern: /^\[\[([a-zA-Z][a-zA-Z0-9]*)\]\]\.(?:tsx?|[cm]?js)$/, optional: true, rest: false },
|
|
97
|
+
{ pattern: /^\[([a-zA-Z][a-zA-Z0-9]*)\]\.(?:tsx?|[cm]?js)$/, optional: false, rest: false },
|
|
98
|
+
];
|
|
89
99
|
|
|
90
|
-
const
|
|
91
|
-
'string',
|
|
92
|
-
'number',
|
|
93
|
-
'integer',
|
|
94
|
-
'float',
|
|
95
|
-
'boolean',
|
|
96
|
-
'word',
|
|
97
|
-
'text',
|
|
98
|
-
'mention',
|
|
99
|
-
'image',
|
|
100
|
-
'face',
|
|
101
|
-
'reply',
|
|
102
|
-
'forward',
|
|
103
|
-
'dice',
|
|
104
|
-
'rps',
|
|
105
|
-
]);
|
|
100
|
+
const commandModuleExtension = /\.(?:tsx?|[cm]?js)$/u;
|
|
106
101
|
|
|
107
102
|
function parseCommandFile(value: string): ParsedCommandFile | undefined {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const [, name, rawType, rawDefault] = match;
|
|
114
|
-
if (!name || !rawType || !commandParameterTypes.has(rawType as CommandParameterType)) {
|
|
115
|
-
throw new CommandPathSyntaxError(value, `unsupported parameter type: ${rawType ?? ''}`);
|
|
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 };
|
|
116
108
|
}
|
|
117
|
-
|
|
109
|
+
}
|
|
110
|
+
for (const { pattern, optional, rest } of dynamicCommandFilePatterns) {
|
|
111
|
+
const match = pattern.exec(value);
|
|
112
|
+
if (!match || !match[1]) continue;
|
|
113
|
+
const name = match[1];
|
|
118
114
|
// Metadata can change during HMR while $name keeps the Capability identity stable.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
: { name,
|
|
122
|
-
|
|
115
|
+
return {
|
|
116
|
+
localSegment: `$${name}`,
|
|
117
|
+
parameter: { name, optional, rest },
|
|
118
|
+
};
|
|
123
119
|
}
|
|
124
120
|
if (value.startsWith('[') || value.includes(']')) {
|
|
125
121
|
throw new CommandPathSyntaxError(value);
|
|
@@ -127,66 +123,51 @@ function parseCommandFile(value: string): ParsedCommandFile | undefined {
|
|
|
127
123
|
return undefined;
|
|
128
124
|
}
|
|
129
125
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
: ['ts', 'tsx', 'js', 'mjs', 'cjs'];
|
|
135
|
-
const priority = order.indexOf(extension);
|
|
136
|
-
return priority < 0 ? Number.MAX_SAFE_INTEGER : priority;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function parseParameterValue(
|
|
140
|
-
name: string,
|
|
141
|
-
type: CommandParameterType,
|
|
142
|
-
value: string,
|
|
126
|
+
/** 把文件名形态与 `definition.params` 合并成完整参数定义。 */
|
|
127
|
+
function resolveParameter(
|
|
128
|
+
definition: CommandDefinition,
|
|
129
|
+
file: ParsedCommandFile | undefined,
|
|
143
130
|
source: string,
|
|
144
|
-
):
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
value.trim().length > 0
|
|
150
|
-
&& Number.isFinite(number)
|
|
151
|
-
&& (type !== 'integer' || Number.isInteger(number))
|
|
152
|
-
&& (type !== 'float' || value.includes('.'))
|
|
153
|
-
) return number;
|
|
131
|
+
): CommandParameterDefinition | undefined {
|
|
132
|
+
const hint = file?.parameter;
|
|
133
|
+
if (!hint) return undefined;
|
|
134
|
+
const schema = definition.params?.[hint.name];
|
|
135
|
+
if (!schema) {
|
|
154
136
|
throw new CommandPathSyntaxError(
|
|
155
137
|
source,
|
|
156
|
-
`
|
|
138
|
+
`missing params.${hint.name} declaration in defineCommand({ params })`,
|
|
157
139
|
);
|
|
158
140
|
}
|
|
159
|
-
if (
|
|
160
|
-
if (value === 'true' || value === 'false') return value === 'true';
|
|
141
|
+
if (!hint.optional && schema.default !== undefined) {
|
|
161
142
|
throw new CommandPathSyntaxError(
|
|
162
143
|
source,
|
|
163
|
-
`default
|
|
144
|
+
`params.${hint.name} has a default but the file is required: rename to [[${hint.name}]]`,
|
|
164
145
|
);
|
|
165
146
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
`default for ${name}:${type} is invalid`,
|
|
175
|
-
);
|
|
147
|
+
return {
|
|
148
|
+
name: hint.name,
|
|
149
|
+
type: schema.type,
|
|
150
|
+
...(schema.default !== undefined ? { defaultValue: schema.default } : {}),
|
|
151
|
+
optional: hint.optional,
|
|
152
|
+
rest: hint.rest,
|
|
153
|
+
...(schema.description !== undefined ? { description: schema.description } : {}),
|
|
154
|
+
};
|
|
176
155
|
}
|
|
177
156
|
|
|
178
|
-
function
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|| type === 'rps';
|
|
157
|
+
function commandFilePriority(value: string, preferJavaScript: boolean): number {
|
|
158
|
+
const extension = value.slice(value.lastIndexOf('.') + 1);
|
|
159
|
+
const order = preferJavaScript
|
|
160
|
+
? ['js', 'mjs', 'cjs', 'ts', 'tsx']
|
|
161
|
+
: ['ts', 'tsx', 'js', 'mjs', 'cjs'];
|
|
162
|
+
const priority = order.indexOf(extension);
|
|
163
|
+
return priority < 0 ? Number.MAX_SAFE_INTEGER : priority;
|
|
186
164
|
}
|
|
187
165
|
|
|
188
166
|
export class CommandPathSyntaxError extends TypeError {
|
|
189
|
-
constructor(
|
|
167
|
+
constructor(
|
|
168
|
+
file: string,
|
|
169
|
+
detail = 'expected [name].ts(x), [[name]].ts(x), [...name].ts(x) or [[...name]].ts(x)',
|
|
170
|
+
) {
|
|
190
171
|
super(`Invalid Command path ${file}: ${detail}`);
|
|
191
172
|
this.name = 'CommandPathSyntaxError';
|
|
192
173
|
}
|