@zhin.js/command 1.0.5 → 1.0.7
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 +5 -4
- package/lib/command-index.js +93 -11
- package/lib/definition.d.ts +41 -3
- package/lib/definition.js +33 -24
- package/lib/provider.d.ts +2 -1
- package/lib/provider.js +38 -61
- package/package.json +3 -3
- package/src/command-index.ts +96 -13
- package/src/definition.ts +76 -24
- package/src/provider.ts +58 -79
package/README.md
CHANGED
|
@@ -15,16 +15,17 @@ export default defineCommand({
|
|
|
15
15
|
});
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
最后一个文件名可以用 Next.js 风格方括号声明参数形态,类型与默认值在 `defineCommand({ params })` 中声明(`type` 必填,`default` 可选且有默认值时文件名必须用双方括号):
|
|
19
19
|
|
|
20
20
|
```text
|
|
21
|
-
commands/gh/pr/[title
|
|
22
|
-
commands/upload/[asset
|
|
21
|
+
commands/gh/pr/[[title]].ts -> gh pr [title] (params: { title: { type: 'string', default: 'defaultTitle' } })
|
|
22
|
+
commands/upload/[asset].ts -> upload <asset> (params: { asset: { type: 'image' } })
|
|
23
|
+
commands/search/[...kw].ts -> search <...kw> (params: { kw: { type: 'text' } },运行时 params.kw 为数组;元素粒度随类型:text 逐消息段,word/string 逐词,number/boolean 逐词转换)
|
|
23
24
|
```
|
|
24
25
|
|
|
25
26
|
文本类型包括 `string`、`word`、`text`、`number`、`integer`、`float`、`boolean`;结构化
|
|
26
27
|
类型包括 `mention`、`image`、`face`、`reply`、`forward`、`dice`、`rps`。结构化类型
|
|
27
|
-
直接从对应 segment
|
|
28
|
+
直接从对应 segment 取值,不能声明默认值。
|
|
28
29
|
|
|
29
30
|
`execute()` 收到冻结的 `CommandContext`:
|
|
30
31
|
|
package/lib/command-index.js
CHANGED
|
@@ -31,7 +31,7 @@ export class CommandIndex {
|
|
|
31
31
|
source: slot.source,
|
|
32
32
|
parameters: Object.freeze(parameter ? [{
|
|
33
33
|
...parameter,
|
|
34
|
-
required: parameter
|
|
34
|
+
required: isRequiredParameter(parameter),
|
|
35
35
|
}] : []),
|
|
36
36
|
slot,
|
|
37
37
|
segments: Object.freeze(segments),
|
|
@@ -100,12 +100,29 @@ export class CommandIndex {
|
|
|
100
100
|
const result = command.matcher.match(asMatcherSegments(segments));
|
|
101
101
|
if (!result || !hasCommandBoundary(result.remaining))
|
|
102
102
|
continue;
|
|
103
|
+
const parameter = command.parameter;
|
|
104
|
+
const params = { ...result.params };
|
|
105
|
+
if (parameter?.rest) {
|
|
106
|
+
const raw = result.params[parameter.name];
|
|
107
|
+
const coerced = coerceRestValues(parameter, Array.isArray(raw) ? raw : []);
|
|
108
|
+
// 必需 `[...name]` 捕获所有:零元素视为不匹配;标量逐词转换失败同样不匹配。
|
|
109
|
+
if (!coerced || (isRequiredParameter(parameter) && coerced.length === 0))
|
|
110
|
+
continue;
|
|
111
|
+
params[parameter.name] = coerced;
|
|
112
|
+
}
|
|
103
113
|
const remaining = normalizeSegments(result.remaining);
|
|
104
114
|
if (exact && remaining.length > 0)
|
|
105
115
|
continue;
|
|
116
|
+
// `[[name]]` 无 default 且未命中时,matcher 对 text 回退 ''、其他类型回退 null;
|
|
117
|
+
// 按契约(省略 default 时未匹配为 undefined)删除该键。
|
|
118
|
+
if (parameter && !parameter.rest && parameter.optional === true
|
|
119
|
+
&& parameter.defaultValue === undefined
|
|
120
|
+
&& (params[parameter.name] === '' || params[parameter.name] === null)) {
|
|
121
|
+
delete params[parameter.name];
|
|
122
|
+
}
|
|
106
123
|
return {
|
|
107
124
|
command,
|
|
108
|
-
params: Object.freeze(
|
|
125
|
+
params: Object.freeze(params),
|
|
109
126
|
remaining,
|
|
110
127
|
};
|
|
111
128
|
}
|
|
@@ -115,7 +132,7 @@ export class CommandIndex {
|
|
|
115
132
|
const words = splitCommand(name);
|
|
116
133
|
for (const command of this.#commands) {
|
|
117
134
|
const parameter = command.parameter;
|
|
118
|
-
if (!parameter)
|
|
135
|
+
if (!parameter || parameter.rest)
|
|
119
136
|
continue;
|
|
120
137
|
const parameterIndex = command.segments.findIndex((segment) => segment.startsWith('$'));
|
|
121
138
|
if (words.length !== command.segments.length)
|
|
@@ -154,15 +171,26 @@ function assertParameterSegment(segments, parameter, source) {
|
|
|
154
171
|
&& dynamicSegments[0] === `$${parameter.name}`
|
|
155
172
|
&& segments.at(-1) === dynamicSegments[0])
|
|
156
173
|
return;
|
|
157
|
-
|
|
174
|
+
const dynamic = dynamicSegments[0] ?? (parameter ? `$${parameter.name}` : '$?');
|
|
175
|
+
throw new Error(`Invalid Command path for ${source}: the dynamic segment "${dynamic}" must be the only dynamic `
|
|
176
|
+
+ `segment and come after a static segment (child plugin commands are prefixed by the plugin `
|
|
177
|
+
+ `path, so a dynamic first segment is never reachable). `
|
|
178
|
+
+ (parameter
|
|
179
|
+
? `Hint: move the file under a static directory, e.g. "commands/add/[${parameter.name}:${parameter.type}].ts".`
|
|
180
|
+
: 'Hint: put the file under a static directory, e.g. "commands/add/<file>.ts".'));
|
|
181
|
+
}
|
|
182
|
+
function isRequiredParameter(parameter) {
|
|
183
|
+
return parameter.optional === true ? false : parameter.defaultValue === undefined;
|
|
158
184
|
}
|
|
159
185
|
function displayName(segments, parameter) {
|
|
160
186
|
return segments.map((segment) => {
|
|
161
187
|
if (!segment.startsWith('$'))
|
|
162
188
|
return segment;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
189
|
+
const label = segment.slice(1);
|
|
190
|
+
const required = !parameter || isRequiredParameter(parameter);
|
|
191
|
+
if (parameter?.rest)
|
|
192
|
+
return required ? `<...${label}>` : `[...${label}]`;
|
|
193
|
+
return required ? `<${label}>` : `[${label}]`;
|
|
166
194
|
}).join(' ');
|
|
167
195
|
}
|
|
168
196
|
function matcherPattern(segments, parameter) {
|
|
@@ -172,26 +200,80 @@ function matcherPattern(segments, parameter) {
|
|
|
172
200
|
if (!parameter)
|
|
173
201
|
throw new Error(`Missing Command parameter metadata: ${segment}`);
|
|
174
202
|
const type = matcherType(parameter.type);
|
|
203
|
+
// rest:结构化类型按消息段收集;标量类型先按 text 段收集,再在 #match 里逐词切分转换。
|
|
204
|
+
if (parameter.rest) {
|
|
205
|
+
return `[...${parameter.name}:${isStructuredRestType(parameter.type) ? type : 'text'}]`;
|
|
206
|
+
}
|
|
207
|
+
if (isRequiredParameter(parameter))
|
|
208
|
+
return `<${parameter.name}:${type}>`;
|
|
175
209
|
return parameter.defaultValue === undefined
|
|
176
|
-
?
|
|
210
|
+
? `[${parameter.name}:${type}]`
|
|
177
211
|
: `[${parameter.name}:${type}=${String(parameter.defaultValue)}]`;
|
|
178
212
|
}).join(' ');
|
|
179
213
|
}
|
|
180
214
|
function matcherType(type) {
|
|
181
215
|
return type === 'string' ? 'word' : type;
|
|
182
216
|
}
|
|
217
|
+
/** rest 参数中按消息段收集(matcher 原生行为)的结构化类型。 */
|
|
218
|
+
function isStructuredRestType(type) {
|
|
219
|
+
return type === 'mention'
|
|
220
|
+
|| type === 'image'
|
|
221
|
+
|| type === 'face'
|
|
222
|
+
|| type === 'reply'
|
|
223
|
+
|| type === 'forward'
|
|
224
|
+
|| type === 'dice'
|
|
225
|
+
|| type === 'rps';
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* 捕获所有参数的取值粒度由类型决定:
|
|
229
|
+
* - `text` / 结构化类型:逐消息段(matcher 原生结果);
|
|
230
|
+
* - `word` / `string`:逐词(空白切分);
|
|
231
|
+
* - `number` / `integer` / `float` / `boolean`:逐词切分后逐个转换,任一失败返回 undefined(不匹配)。
|
|
232
|
+
*/
|
|
233
|
+
function coerceRestValues(parameter, values) {
|
|
234
|
+
const type = parameter.type;
|
|
235
|
+
if (type === 'text' || isStructuredRestType(type)) {
|
|
236
|
+
// 逐消息段,保持 matcher 原生提取值(text 为 string,结构化类型可能是 number 等)。
|
|
237
|
+
return values;
|
|
238
|
+
}
|
|
239
|
+
const words = values.flatMap((value) => typeof value === 'string' ? value.split(/\s+/u).filter(Boolean) : []);
|
|
240
|
+
if (type === 'string' || type === 'word')
|
|
241
|
+
return words;
|
|
242
|
+
if (type === 'number' || type === 'integer' || type === 'float') {
|
|
243
|
+
const numbers = [];
|
|
244
|
+
for (const [index, word] of words.entries()) {
|
|
245
|
+
const number = Number(word);
|
|
246
|
+
if (!Number.isFinite(number)
|
|
247
|
+
|| (type === 'integer' && !Number.isInteger(number))
|
|
248
|
+
|| (type === 'float' && !word.includes('.')))
|
|
249
|
+
return undefined;
|
|
250
|
+
numbers[index] = number;
|
|
251
|
+
}
|
|
252
|
+
return numbers;
|
|
253
|
+
}
|
|
254
|
+
if (type === 'boolean') {
|
|
255
|
+
if (!words.every((word) => word === 'true' || word === 'false'))
|
|
256
|
+
return undefined;
|
|
257
|
+
return words.map((word) => word === 'true');
|
|
258
|
+
}
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
183
261
|
function routeShape(segments) {
|
|
184
262
|
return segments.map((segment) => segment.startsWith('$') ? '$' : segment).join(' ');
|
|
185
263
|
}
|
|
186
264
|
function compareCommands(left, right) {
|
|
187
|
-
|
|
188
|
-
const rightDynamic = right.parameter ? 1 : 0;
|
|
189
|
-
return leftDynamic - rightDynamic
|
|
265
|
+
return dynamicWeight(left) - dynamicWeight(right)
|
|
190
266
|
|| staticSegmentCount(right.segments) - staticSegmentCount(left.segments)
|
|
191
267
|
|| right.segments.length - left.segments.length
|
|
192
268
|
|| right.name.length - left.name.length
|
|
193
269
|
|| left.name.localeCompare(right.name);
|
|
194
270
|
}
|
|
271
|
+
/** 静态 < 单参数 < 捕获所有:更具体的形状优先匹配。 */
|
|
272
|
+
function dynamicWeight(command) {
|
|
273
|
+
if (!command.parameter)
|
|
274
|
+
return 0;
|
|
275
|
+
return command.parameter.rest ? 2 : 1;
|
|
276
|
+
}
|
|
195
277
|
function staticSegmentCount(segments) {
|
|
196
278
|
return segments.filter((segment) => !segment.startsWith('$')).length;
|
|
197
279
|
}
|
package/lib/definition.d.ts
CHANGED
|
@@ -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: 'group' | 'channel';
|
|
63
|
+
readonly id: string;
|
|
64
|
+
}>;
|
|
65
|
+
readonly threadId?: string;
|
|
66
|
+
}
|
|
33
67
|
/**
|
|
34
68
|
* 命令侧入站消息契约。
|
|
35
69
|
*
|
|
@@ -37,8 +71,7 @@ export interface CommandSender {
|
|
|
37
71
|
* 因架构分层(command 为 Feature 层,不能 import core),此处独立声明。
|
|
38
72
|
*/
|
|
39
73
|
export interface CommandMessage {
|
|
40
|
-
readonly
|
|
41
|
-
readonly target: string;
|
|
74
|
+
readonly conversation: CommandConversation;
|
|
42
75
|
readonly content: string;
|
|
43
76
|
/** 发送者 id(扁平字段;结构化视图见 CommandContext.sender)。 */
|
|
44
77
|
readonly sender?: string;
|
|
@@ -82,6 +115,11 @@ export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput
|
|
|
82
115
|
readonly $feature: typeof commandBrand;
|
|
83
116
|
readonly $parameter?: CommandParameterDefinition;
|
|
84
117
|
readonly description?: string;
|
|
118
|
+
/**
|
|
119
|
+
* Next.js 风格参数声明:动态段文件名(`[name]` 等)的形态配合这里的
|
|
120
|
+
* 类型 / 默认值 / 描述使用。静态命令可忽略本字段。
|
|
121
|
+
*/
|
|
122
|
+
readonly params?: Readonly<Record<string, CommandParamSchema>>;
|
|
85
123
|
execute(context: CommandContext<TConfig, TInput>): TResult | Promise<TResult>;
|
|
86
124
|
}
|
|
87
125
|
declare module '@zhin.js/plugin-runtime' {
|
package/lib/definition.js
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import { createCapabilityContext, } from '@zhin.js/feature-kit';
|
|
2
2
|
const commandBrand = 'zhin.command/1';
|
|
3
|
+
export const commandParameterTypes = new Set([
|
|
4
|
+
'string',
|
|
5
|
+
'number',
|
|
6
|
+
'integer',
|
|
7
|
+
'float',
|
|
8
|
+
'boolean',
|
|
9
|
+
'word',
|
|
10
|
+
'text',
|
|
11
|
+
'mention',
|
|
12
|
+
'image',
|
|
13
|
+
'face',
|
|
14
|
+
'reply',
|
|
15
|
+
'forward',
|
|
16
|
+
'dice',
|
|
17
|
+
'rps',
|
|
18
|
+
]);
|
|
3
19
|
/**
|
|
4
20
|
* 定义一个命令模块(`commands/` 约定目录下默认导出)。
|
|
5
21
|
* @public 用户侧创作面,承诺 semver(见 docs/contributing/public-api-surface.md)。
|
|
@@ -8,6 +24,17 @@ export function defineCommand(definition) {
|
|
|
8
24
|
if (typeof definition.execute !== 'function') {
|
|
9
25
|
throw new TypeError('Command execute must be a function');
|
|
10
26
|
}
|
|
27
|
+
if (definition.params !== undefined) {
|
|
28
|
+
if (!definition.params || typeof definition.params !== 'object') {
|
|
29
|
+
throw new TypeError('Command params must be a Record<string, CommandParamSchema>');
|
|
30
|
+
}
|
|
31
|
+
for (const [name, schema] of Object.entries(definition.params)) {
|
|
32
|
+
if (!schema || typeof schema !== 'object'
|
|
33
|
+
|| !commandParameterTypes.has(schema.type)) {
|
|
34
|
+
throw new TypeError(`Command params.${name} requires a valid type`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
11
38
|
return Object.freeze({ $feature: commandBrand, ...definition });
|
|
12
39
|
}
|
|
13
40
|
export function bindCommandParameter(definition, parameter) {
|
|
@@ -47,7 +74,7 @@ export function resolveCommandSession(input) {
|
|
|
47
74
|
const metadata = input.metadata && typeof input.metadata === 'object'
|
|
48
75
|
? input.metadata
|
|
49
76
|
: undefined;
|
|
50
|
-
const adapter = input.adapter
|
|
77
|
+
const adapter = input.conversation.endpoint.adapter || undefined;
|
|
51
78
|
const endpoint = typeof metadata?.endpoint === 'string' && metadata.endpoint
|
|
52
79
|
? metadata.endpoint
|
|
53
80
|
: undefined;
|
|
@@ -64,8 +91,8 @@ function isCommandMessageLike(input) {
|
|
|
64
91
|
if (!input || typeof input !== 'object')
|
|
65
92
|
return false;
|
|
66
93
|
const value = input;
|
|
67
|
-
return
|
|
68
|
-
&& typeof value.
|
|
94
|
+
return !!value.conversation
|
|
95
|
+
&& typeof value.conversation === 'object'
|
|
69
96
|
&& typeof value.content === 'string';
|
|
70
97
|
}
|
|
71
98
|
function resolveScene(input, metadata) {
|
|
@@ -76,12 +103,12 @@ function resolveScene(input, metadata) {
|
|
|
76
103
|
...(input.scene.name !== undefined ? { name: input.scene.name } : {}),
|
|
77
104
|
});
|
|
78
105
|
}
|
|
79
|
-
const
|
|
106
|
+
const conversation = input.conversation;
|
|
80
107
|
const type = (typeof metadata?.channelType === 'string' && metadata.channelType)
|
|
81
108
|
|| (typeof metadata?.type === 'string' && metadata.type)
|
|
82
|
-
||
|
|
109
|
+
|| conversation.kind;
|
|
83
110
|
const id = (typeof metadata?.channelId === 'string' && metadata.channelId)
|
|
84
|
-
||
|
|
111
|
+
|| conversation.id;
|
|
85
112
|
if (!type || !id)
|
|
86
113
|
return undefined;
|
|
87
114
|
const name = firstString(metadata?.channelName, metadata?.group_name, metadata?.groupName, metadata?.sceneName);
|
|
@@ -136,24 +163,6 @@ function resolveRoles(metadata) {
|
|
|
136
163
|
roles.push('user');
|
|
137
164
|
return Object.freeze(roles);
|
|
138
165
|
}
|
|
139
|
-
function parseTarget(target) {
|
|
140
|
-
const parts = target.split(':').filter(Boolean);
|
|
141
|
-
if (parts.length < 2)
|
|
142
|
-
return undefined;
|
|
143
|
-
const [kind, ...rest] = parts;
|
|
144
|
-
if (!kind)
|
|
145
|
-
return undefined;
|
|
146
|
-
const lastPart = parts.at(-1);
|
|
147
|
-
if (!lastPart)
|
|
148
|
-
return undefined;
|
|
149
|
-
if (kind === 'channel' && parts.length >= 3) {
|
|
150
|
-
return { type: 'channel', id: lastPart };
|
|
151
|
-
}
|
|
152
|
-
if (kind === 'temp' && parts.length >= 3) {
|
|
153
|
-
return { type: 'private', id: lastPart };
|
|
154
|
-
}
|
|
155
|
-
return { type: kind, id: rest.join(':') };
|
|
156
|
-
}
|
|
157
166
|
function isCommandScene(value) {
|
|
158
167
|
if (!value || typeof value !== 'object')
|
|
159
168
|
return false;
|
package/lib/provider.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { CommandIndex } from './command-index.js';
|
|
2
|
+
import { type CommandDefinition } from './definition.js';
|
|
2
3
|
export declare const commandFeatureId: import("@zhin.js/plugin-runtime").FeatureId;
|
|
3
4
|
export declare class CommandPathSyntaxError extends TypeError {
|
|
4
5
|
constructor(file: string, detail?: string);
|
|
5
6
|
}
|
|
6
|
-
declare const commandFeature: Readonly<import("@zhin.js/feature-kit").FeatureProvider<
|
|
7
|
+
declare const commandFeature: Readonly<import("@zhin.js/feature-kit").FeatureProvider<CommandDefinition<unknown, unknown, import("./definition.js").CommandMessage>, CommandIndex>>;
|
|
7
8
|
export default commandFeature;
|
package/lib/provider.js
CHANGED
|
@@ -14,7 +14,7 @@ const commandFiles = {
|
|
|
14
14
|
const module = await context.host.loadModule(source.source);
|
|
15
15
|
const definition = parseCommandDefinition(module.default);
|
|
16
16
|
const file = parseCommandFile(basename(source.source));
|
|
17
|
-
return bindCommandParameter(definition, file
|
|
17
|
+
return bindCommandParameter(definition, resolveParameter(definition, file, source.source));
|
|
18
18
|
},
|
|
19
19
|
};
|
|
20
20
|
async function* discoverCommandDirectory(context, directory, ancestors) {
|
|
@@ -59,45 +59,53 @@ async function* discoverCommandDirectory(context, directory, ancestors) {
|
|
|
59
59
|
function isCommandSegment(value) {
|
|
60
60
|
return /^[a-z0-9][a-z0-9-]*$/.test(value);
|
|
61
61
|
}
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
'boolean',
|
|
69
|
-
'word',
|
|
70
|
-
'text',
|
|
71
|
-
'mention',
|
|
72
|
-
'image',
|
|
73
|
-
'face',
|
|
74
|
-
'reply',
|
|
75
|
-
'forward',
|
|
76
|
-
'dice',
|
|
77
|
-
'rps',
|
|
78
|
-
]);
|
|
62
|
+
const dynamicCommandFilePatterns = [
|
|
63
|
+
{ pattern: /^\[\[\.\.\.([a-zA-Z][a-zA-Z0-9]*)\]\]\.(?:tsx?|[cm]?js)$/, optional: true, rest: true },
|
|
64
|
+
{ pattern: /^\[\.\.\.([a-zA-Z][a-zA-Z0-9]*)\]\.(?:tsx?|[cm]?js)$/, optional: false, rest: true },
|
|
65
|
+
{ pattern: /^\[\[([a-zA-Z][a-zA-Z0-9]*)\]\]\.(?:tsx?|[cm]?js)$/, optional: true, rest: false },
|
|
66
|
+
{ pattern: /^\[([a-zA-Z][a-zA-Z0-9]*)\]\.(?:tsx?|[cm]?js)$/, optional: false, rest: false },
|
|
67
|
+
];
|
|
79
68
|
function parseCommandFile(value) {
|
|
80
69
|
if (/^[a-z0-9][a-z0-9-]*\.(?:tsx?|[cm]?js)$/.test(value)) {
|
|
81
70
|
return { localSegment: parse(value).name };
|
|
82
71
|
}
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
const type = rawType;
|
|
72
|
+
for (const { pattern, optional, rest } of dynamicCommandFilePatterns) {
|
|
73
|
+
const match = pattern.exec(value);
|
|
74
|
+
if (!match || !match[1])
|
|
75
|
+
continue;
|
|
76
|
+
const name = match[1];
|
|
90
77
|
// Metadata can change during HMR while $name keeps the Capability identity stable.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
: { name,
|
|
94
|
-
|
|
78
|
+
return {
|
|
79
|
+
localSegment: `$${name}`,
|
|
80
|
+
parameter: { name, optional, rest },
|
|
81
|
+
};
|
|
95
82
|
}
|
|
96
83
|
if (value.startsWith('[') || value.includes(']')) {
|
|
97
84
|
throw new CommandPathSyntaxError(value);
|
|
98
85
|
}
|
|
99
86
|
return undefined;
|
|
100
87
|
}
|
|
88
|
+
/** 把文件名形态与 `definition.params` 合并成完整参数定义。 */
|
|
89
|
+
function resolveParameter(definition, file, source) {
|
|
90
|
+
const hint = file?.parameter;
|
|
91
|
+
if (!hint)
|
|
92
|
+
return undefined;
|
|
93
|
+
const schema = definition.params?.[hint.name];
|
|
94
|
+
if (!schema) {
|
|
95
|
+
throw new CommandPathSyntaxError(source, `missing params.${hint.name} declaration in defineCommand({ params })`);
|
|
96
|
+
}
|
|
97
|
+
if (!hint.optional && schema.default !== undefined) {
|
|
98
|
+
throw new CommandPathSyntaxError(source, `params.${hint.name} has a default but the file is required: rename to [[${hint.name}]]`);
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
name: hint.name,
|
|
102
|
+
type: schema.type,
|
|
103
|
+
...(schema.default !== undefined ? { defaultValue: schema.default } : {}),
|
|
104
|
+
optional: hint.optional,
|
|
105
|
+
rest: hint.rest,
|
|
106
|
+
...(schema.description !== undefined ? { description: schema.description } : {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
101
109
|
function commandFilePriority(value, preferJavaScript) {
|
|
102
110
|
const extension = value.slice(value.lastIndexOf('.') + 1);
|
|
103
111
|
const order = preferJavaScript
|
|
@@ -106,39 +114,8 @@ function commandFilePriority(value, preferJavaScript) {
|
|
|
106
114
|
const priority = order.indexOf(extension);
|
|
107
115
|
return priority < 0 ? Number.MAX_SAFE_INTEGER : priority;
|
|
108
116
|
}
|
|
109
|
-
function parseParameterValue(name, type, value, source) {
|
|
110
|
-
if (type === 'string' || type === 'word' || type === 'text')
|
|
111
|
-
return value;
|
|
112
|
-
if (type === 'number' || type === 'integer' || type === 'float') {
|
|
113
|
-
const number = Number(value);
|
|
114
|
-
if (value.trim().length > 0
|
|
115
|
-
&& Number.isFinite(number)
|
|
116
|
-
&& (type !== 'integer' || Number.isInteger(number))
|
|
117
|
-
&& (type !== 'float' || value.includes('.')))
|
|
118
|
-
return number;
|
|
119
|
-
throw new CommandPathSyntaxError(source, `default for ${name}:${type} is invalid`);
|
|
120
|
-
}
|
|
121
|
-
if (type === 'boolean') {
|
|
122
|
-
if (value === 'true' || value === 'false')
|
|
123
|
-
return value === 'true';
|
|
124
|
-
throw new CommandPathSyntaxError(source, `default for ${name}:${type} is invalid`);
|
|
125
|
-
}
|
|
126
|
-
if (isStructuredParameter(type)) {
|
|
127
|
-
throw new CommandPathSyntaxError(source, `default for structured parameter ${name}:${type} is not supported`);
|
|
128
|
-
}
|
|
129
|
-
throw new CommandPathSyntaxError(source, `default for ${name}:${type} is invalid`);
|
|
130
|
-
}
|
|
131
|
-
function isStructuredParameter(type) {
|
|
132
|
-
return type === 'mention'
|
|
133
|
-
|| type === 'image'
|
|
134
|
-
|| type === 'face'
|
|
135
|
-
|| type === 'reply'
|
|
136
|
-
|| type === 'forward'
|
|
137
|
-
|| type === 'dice'
|
|
138
|
-
|| type === 'rps';
|
|
139
|
-
}
|
|
140
117
|
export class CommandPathSyntaxError extends TypeError {
|
|
141
|
-
constructor(file, detail = 'expected [name
|
|
118
|
+
constructor(file, detail = 'expected [name].ts(x), [[name]].ts(x), [...name].ts(x) or [[...name]].ts(x)') {
|
|
142
119
|
super(`Invalid Command path ${file}: ${detail}`);
|
|
143
120
|
this.name = 'CommandPathSyntaxError';
|
|
144
121
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/command",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Convention-based Command Feature for Zhin Plugin Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"segment-matcher": "^1.0.5",
|
|
21
|
-
"@zhin.js/feature-kit": "1.0.
|
|
22
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
21
|
+
"@zhin.js/feature-kit": "1.0.6",
|
|
22
|
+
"@zhin.js/plugin-runtime": "1.1.3"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@types/node": "^26.1.2",
|
package/src/command-index.ts
CHANGED
|
@@ -85,7 +85,7 @@ export class CommandIndex {
|
|
|
85
85
|
source: slot.source,
|
|
86
86
|
parameters: Object.freeze(parameter ? [{
|
|
87
87
|
...parameter,
|
|
88
|
-
required: parameter
|
|
88
|
+
required: isRequiredParameter(parameter),
|
|
89
89
|
}] : []),
|
|
90
90
|
slot,
|
|
91
91
|
segments: Object.freeze(segments),
|
|
@@ -173,13 +173,27 @@ export class CommandIndex {
|
|
|
173
173
|
for (const command of this.#commands) {
|
|
174
174
|
const result = command.matcher.match(asMatcherSegments(segments));
|
|
175
175
|
if (!result || !hasCommandBoundary(result.remaining)) continue;
|
|
176
|
+
const parameter = command.parameter;
|
|
177
|
+
const params: Record<string, CommandParameterValue> = { ...result.params };
|
|
178
|
+
if (parameter?.rest) {
|
|
179
|
+
const raw = result.params[parameter.name];
|
|
180
|
+
const coerced = coerceRestValues(parameter, Array.isArray(raw) ? raw : []);
|
|
181
|
+
// 必需 `[...name]` 捕获所有:零元素视为不匹配;标量逐词转换失败同样不匹配。
|
|
182
|
+
if (!coerced || (isRequiredParameter(parameter) && coerced.length === 0)) continue;
|
|
183
|
+
params[parameter.name] = coerced;
|
|
184
|
+
}
|
|
176
185
|
const remaining = normalizeSegments(result.remaining);
|
|
177
186
|
if (exact && remaining.length > 0) continue;
|
|
187
|
+
// `[[name]]` 无 default 且未命中时,matcher 对 text 回退 ''、其他类型回退 null;
|
|
188
|
+
// 按契约(省略 default 时未匹配为 undefined)删除该键。
|
|
189
|
+
if (parameter && !parameter.rest && parameter.optional === true
|
|
190
|
+
&& parameter.defaultValue === undefined
|
|
191
|
+
&& (params[parameter.name] === '' || params[parameter.name] === null)) {
|
|
192
|
+
delete params[parameter.name];
|
|
193
|
+
}
|
|
178
194
|
return {
|
|
179
195
|
command,
|
|
180
|
-
params: Object.freeze(
|
|
181
|
-
Record<string, CommandParameterValue>
|
|
182
|
-
>,
|
|
196
|
+
params: Object.freeze(params),
|
|
183
197
|
remaining,
|
|
184
198
|
};
|
|
185
199
|
}
|
|
@@ -190,7 +204,7 @@ export class CommandIndex {
|
|
|
190
204
|
const words = splitCommand(name);
|
|
191
205
|
for (const command of this.#commands) {
|
|
192
206
|
const parameter = command.parameter;
|
|
193
|
-
if (!parameter) continue;
|
|
207
|
+
if (!parameter || parameter.rest) continue;
|
|
194
208
|
const parameterIndex = command.segments.findIndex((segment) => segment.startsWith('$'));
|
|
195
209
|
if (words.length !== command.segments.length) continue;
|
|
196
210
|
if (!command.segments.every((segment, index) =>
|
|
@@ -230,7 +244,19 @@ function assertParameterSegment(
|
|
|
230
244
|
if (parameter && dynamicSegments.length === 1
|
|
231
245
|
&& dynamicSegments[0] === `$${parameter.name}`
|
|
232
246
|
&& segments.at(-1) === dynamicSegments[0]) return;
|
|
233
|
-
|
|
247
|
+
const dynamic = dynamicSegments[0] ?? (parameter ? `$${parameter.name}` : '$?');
|
|
248
|
+
throw new Error(
|
|
249
|
+
`Invalid Command path for ${source}: the dynamic segment "${dynamic}" must be the only dynamic `
|
|
250
|
+
+ `segment and come after a static segment (child plugin commands are prefixed by the plugin `
|
|
251
|
+
+ `path, so a dynamic first segment is never reachable). `
|
|
252
|
+
+ (parameter
|
|
253
|
+
? `Hint: move the file under a static directory, e.g. "commands/add/[${parameter.name}:${parameter.type}].ts".`
|
|
254
|
+
: 'Hint: put the file under a static directory, e.g. "commands/add/<file>.ts".'),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function isRequiredParameter(parameter: CommandParameterDefinition): boolean {
|
|
259
|
+
return parameter.optional === true ? false : parameter.defaultValue === undefined;
|
|
234
260
|
}
|
|
235
261
|
|
|
236
262
|
function displayName(
|
|
@@ -239,9 +265,10 @@ function displayName(
|
|
|
239
265
|
): string {
|
|
240
266
|
return segments.map((segment) => {
|
|
241
267
|
if (!segment.startsWith('$')) return segment;
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
268
|
+
const label = segment.slice(1);
|
|
269
|
+
const required = !parameter || isRequiredParameter(parameter);
|
|
270
|
+
if (parameter?.rest) return required ? `<...${label}>` : `[...${label}]`;
|
|
271
|
+
return required ? `<${label}>` : `[${label}]`;
|
|
245
272
|
}).join(' ');
|
|
246
273
|
}
|
|
247
274
|
|
|
@@ -253,8 +280,13 @@ function matcherPattern(
|
|
|
253
280
|
if (!segment.startsWith('$')) return segment;
|
|
254
281
|
if (!parameter) throw new Error(`Missing Command parameter metadata: ${segment}`);
|
|
255
282
|
const type = matcherType(parameter.type);
|
|
283
|
+
// rest:结构化类型按消息段收集;标量类型先按 text 段收集,再在 #match 里逐词切分转换。
|
|
284
|
+
if (parameter.rest) {
|
|
285
|
+
return `[...${parameter.name}:${isStructuredRestType(parameter.type) ? type : 'text'}]`;
|
|
286
|
+
}
|
|
287
|
+
if (isRequiredParameter(parameter)) return `<${parameter.name}:${type}>`;
|
|
256
288
|
return parameter.defaultValue === undefined
|
|
257
|
-
?
|
|
289
|
+
? `[${parameter.name}:${type}]`
|
|
258
290
|
: `[${parameter.name}:${type}=${String(parameter.defaultValue)}]`;
|
|
259
291
|
}).join(' ');
|
|
260
292
|
}
|
|
@@ -263,20 +295,71 @@ function matcherType(type: CommandParameterType): string {
|
|
|
263
295
|
return type === 'string' ? 'word' : type;
|
|
264
296
|
}
|
|
265
297
|
|
|
298
|
+
/** rest 参数中按消息段收集(matcher 原生行为)的结构化类型。 */
|
|
299
|
+
function isStructuredRestType(type: CommandParameterType): boolean {
|
|
300
|
+
return type === 'mention'
|
|
301
|
+
|| type === 'image'
|
|
302
|
+
|| type === 'face'
|
|
303
|
+
|| type === 'reply'
|
|
304
|
+
|| type === 'forward'
|
|
305
|
+
|| type === 'dice'
|
|
306
|
+
|| type === 'rps';
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* 捕获所有参数的取值粒度由类型决定:
|
|
311
|
+
* - `text` / 结构化类型:逐消息段(matcher 原生结果);
|
|
312
|
+
* - `word` / `string`:逐词(空白切分);
|
|
313
|
+
* - `number` / `integer` / `float` / `boolean`:逐词切分后逐个转换,任一失败返回 undefined(不匹配)。
|
|
314
|
+
*/
|
|
315
|
+
function coerceRestValues(
|
|
316
|
+
parameter: CommandParameterDefinition,
|
|
317
|
+
values: readonly unknown[],
|
|
318
|
+
): readonly (string | number | boolean)[] | undefined {
|
|
319
|
+
const type = parameter.type;
|
|
320
|
+
if (type === 'text' || isStructuredRestType(type)) {
|
|
321
|
+
// 逐消息段,保持 matcher 原生提取值(text 为 string,结构化类型可能是 number 等)。
|
|
322
|
+
return values as readonly (string | number | boolean)[];
|
|
323
|
+
}
|
|
324
|
+
const words = values.flatMap((value) =>
|
|
325
|
+
typeof value === 'string' ? value.split(/\s+/u).filter(Boolean) : []);
|
|
326
|
+
if (type === 'string' || type === 'word') return words;
|
|
327
|
+
if (type === 'number' || type === 'integer' || type === 'float') {
|
|
328
|
+
const numbers: number[] = [];
|
|
329
|
+
for (const [index, word] of words.entries()) {
|
|
330
|
+
const number = Number(word);
|
|
331
|
+
if (!Number.isFinite(number)
|
|
332
|
+
|| (type === 'integer' && !Number.isInteger(number))
|
|
333
|
+
|| (type === 'float' && !word.includes('.'))) return undefined;
|
|
334
|
+
numbers[index] = number;
|
|
335
|
+
}
|
|
336
|
+
return numbers;
|
|
337
|
+
}
|
|
338
|
+
if (type === 'boolean') {
|
|
339
|
+
if (!words.every((word) => word === 'true' || word === 'false')) return undefined;
|
|
340
|
+
return words.map((word) => word === 'true');
|
|
341
|
+
}
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
|
|
266
345
|
function routeShape(segments: readonly string[]): string {
|
|
267
346
|
return segments.map((segment) => segment.startsWith('$') ? '$' : segment).join(' ');
|
|
268
347
|
}
|
|
269
348
|
|
|
270
349
|
function compareCommands(left: CommandRecord, right: CommandRecord): number {
|
|
271
|
-
|
|
272
|
-
const rightDynamic = right.parameter ? 1 : 0;
|
|
273
|
-
return leftDynamic - rightDynamic
|
|
350
|
+
return dynamicWeight(left) - dynamicWeight(right)
|
|
274
351
|
|| staticSegmentCount(right.segments) - staticSegmentCount(left.segments)
|
|
275
352
|
|| right.segments.length - left.segments.length
|
|
276
353
|
|| right.name.length - left.name.length
|
|
277
354
|
|| left.name.localeCompare(right.name);
|
|
278
355
|
}
|
|
279
356
|
|
|
357
|
+
/** 静态 < 单参数 < 捕获所有:更具体的形状优先匹配。 */
|
|
358
|
+
function dynamicWeight(command: CommandRecord): number {
|
|
359
|
+
if (!command.parameter) return 0;
|
|
360
|
+
return command.parameter.rest ? 2 : 1;
|
|
361
|
+
}
|
|
362
|
+
|
|
280
363
|
function staticSegmentCount(segments: readonly string[]): number {
|
|
281
364
|
return segments.filter((segment) => !segment.startsWith('$')).length;
|
|
282
365
|
}
|
package/src/definition.ts
CHANGED
|
@@ -25,9 +25,38 @@ export type CommandParameterValue =
|
|
|
25
25
|
| string
|
|
26
26
|
| number
|
|
27
27
|
| boolean
|
|
28
|
+
| ReadonlyArray<string | number | boolean>
|
|
28
29
|
| Readonly<Record<string, unknown>>
|
|
29
30
|
| null;
|
|
30
31
|
|
|
32
|
+
export const commandParameterTypes: ReadonlySet<CommandParameterType> = new Set([
|
|
33
|
+
'string',
|
|
34
|
+
'number',
|
|
35
|
+
'integer',
|
|
36
|
+
'float',
|
|
37
|
+
'boolean',
|
|
38
|
+
'word',
|
|
39
|
+
'text',
|
|
40
|
+
'mention',
|
|
41
|
+
'image',
|
|
42
|
+
'face',
|
|
43
|
+
'reply',
|
|
44
|
+
'forward',
|
|
45
|
+
'dice',
|
|
46
|
+
'rps',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Next.js 风格参数声明(`defineCommand({ params: ... })`)。
|
|
51
|
+
* 文件名只声明参数形态(`[name]` / `[[name]]` / `[...name]` / `[[...name]]`),
|
|
52
|
+
* 类型与默认值统一在这里声明。
|
|
53
|
+
*/
|
|
54
|
+
export interface CommandParamSchema {
|
|
55
|
+
readonly type: CommandParameterType;
|
|
56
|
+
readonly default?: CommandParameterValue;
|
|
57
|
+
readonly description?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
31
60
|
/** Minimal structural contract shared with canonical IM segments. */
|
|
32
61
|
export interface CommandSegment {
|
|
33
62
|
readonly type: string | { readonly name: string };
|
|
@@ -38,6 +67,11 @@ export interface CommandParameterDefinition {
|
|
|
38
67
|
readonly name: string;
|
|
39
68
|
readonly type: CommandParameterType;
|
|
40
69
|
readonly defaultValue?: CommandParameterValue;
|
|
70
|
+
/** `[[name]]` / `[[...name]]` 可选段;缺省按 `defaultValue === undefined` 推断。 */
|
|
71
|
+
readonly optional?: boolean;
|
|
72
|
+
/** `[...name]` / `[[...name]]` 捕获所有段,运行时值为 `string[]`。 */
|
|
73
|
+
readonly rest?: boolean;
|
|
74
|
+
readonly description?: string;
|
|
41
75
|
}
|
|
42
76
|
|
|
43
77
|
/** 场景:群 / 私聊 / 频道等。 */
|
|
@@ -57,6 +91,25 @@ export interface CommandSender {
|
|
|
57
91
|
readonly role: readonly string[];
|
|
58
92
|
}
|
|
59
93
|
|
|
94
|
+
/**
|
|
95
|
+
* 命令侧入站会话契约(结构对齐 `@zhin.js/im-contract` 的 ConversationRef;
|
|
96
|
+
* command 为 Feature 层,不能 import core / IM 契约包,故独立声明)。
|
|
97
|
+
*/
|
|
98
|
+
export interface CommandConversation {
|
|
99
|
+
readonly endpoint: Readonly<{
|
|
100
|
+
readonly id: string;
|
|
101
|
+
/** 适配器插件 owner(PluginId),与 `snapshot.config.get(adapter)` 对齐。 */
|
|
102
|
+
readonly adapter: string;
|
|
103
|
+
}>;
|
|
104
|
+
readonly kind: 'private' | 'group' | 'channel';
|
|
105
|
+
readonly id: string;
|
|
106
|
+
readonly parent?: Readonly<{
|
|
107
|
+
readonly kind: 'group' | 'channel';
|
|
108
|
+
readonly id: string;
|
|
109
|
+
}>;
|
|
110
|
+
readonly threadId?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
60
113
|
/**
|
|
61
114
|
* 命令侧入站消息契约。
|
|
62
115
|
*
|
|
@@ -64,8 +117,7 @@ export interface CommandSender {
|
|
|
64
117
|
* 因架构分层(command 为 Feature 层,不能 import core),此处独立声明。
|
|
65
118
|
*/
|
|
66
119
|
export interface CommandMessage {
|
|
67
|
-
readonly
|
|
68
|
-
readonly target: string;
|
|
120
|
+
readonly conversation: CommandConversation;
|
|
69
121
|
readonly content: string;
|
|
70
122
|
/** 发送者 id(扁平字段;结构化视图见 CommandContext.sender)。 */
|
|
71
123
|
readonly sender?: string;
|
|
@@ -119,6 +171,11 @@ export interface CommandDefinition<
|
|
|
119
171
|
readonly $feature: typeof commandBrand;
|
|
120
172
|
readonly $parameter?: CommandParameterDefinition;
|
|
121
173
|
readonly description?: string;
|
|
174
|
+
/**
|
|
175
|
+
* Next.js 风格参数声明:动态段文件名(`[name]` 等)的形态配合这里的
|
|
176
|
+
* 类型 / 默认值 / 描述使用。静态命令可忽略本字段。
|
|
177
|
+
*/
|
|
178
|
+
readonly params?: Readonly<Record<string, CommandParamSchema>>;
|
|
122
179
|
execute(context: CommandContext<TConfig, TInput>): TResult | Promise<TResult>;
|
|
123
180
|
}
|
|
124
181
|
|
|
@@ -145,6 +202,17 @@ export function defineCommand<
|
|
|
145
202
|
if (typeof definition.execute !== 'function') {
|
|
146
203
|
throw new TypeError('Command execute must be a function');
|
|
147
204
|
}
|
|
205
|
+
if (definition.params !== undefined) {
|
|
206
|
+
if (!definition.params || typeof definition.params !== 'object') {
|
|
207
|
+
throw new TypeError('Command params must be a Record<string, CommandParamSchema>');
|
|
208
|
+
}
|
|
209
|
+
for (const [name, schema] of Object.entries(definition.params)) {
|
|
210
|
+
if (!schema || typeof schema !== 'object'
|
|
211
|
+
|| !commandParameterTypes.has((schema as CommandParamSchema).type)) {
|
|
212
|
+
throw new TypeError(`Command params.${name} requires a valid type`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
148
216
|
return Object.freeze({ $feature: commandBrand, ...definition });
|
|
149
217
|
}
|
|
150
218
|
|
|
@@ -202,7 +270,7 @@ export function resolveCommandSession(input: unknown): CommandSession {
|
|
|
202
270
|
? input.metadata as Readonly<Record<string, unknown>>
|
|
203
271
|
: undefined;
|
|
204
272
|
|
|
205
|
-
const adapter = input.adapter
|
|
273
|
+
const adapter = input.conversation.endpoint.adapter || undefined;
|
|
206
274
|
const endpoint = typeof metadata?.endpoint === 'string' && metadata.endpoint
|
|
207
275
|
? metadata.endpoint
|
|
208
276
|
: undefined;
|
|
@@ -221,8 +289,8 @@ export function resolveCommandSession(input: unknown): CommandSession {
|
|
|
221
289
|
function isCommandMessageLike(input: unknown): input is CommandMessage {
|
|
222
290
|
if (!input || typeof input !== 'object') return false;
|
|
223
291
|
const value = input as Partial<CommandMessage>;
|
|
224
|
-
return
|
|
225
|
-
&& typeof value.
|
|
292
|
+
return !!value.conversation
|
|
293
|
+
&& typeof value.conversation === 'object'
|
|
226
294
|
&& typeof value.content === 'string';
|
|
227
295
|
}
|
|
228
296
|
|
|
@@ -238,12 +306,12 @@ function resolveScene(
|
|
|
238
306
|
});
|
|
239
307
|
}
|
|
240
308
|
|
|
241
|
-
const
|
|
309
|
+
const conversation = input.conversation;
|
|
242
310
|
const type = (typeof metadata?.channelType === 'string' && metadata.channelType)
|
|
243
311
|
|| (typeof metadata?.type === 'string' && metadata.type)
|
|
244
|
-
||
|
|
312
|
+
|| conversation.kind;
|
|
245
313
|
const id = (typeof metadata?.channelId === 'string' && metadata.channelId)
|
|
246
|
-
||
|
|
314
|
+
|| conversation.id;
|
|
247
315
|
if (!type || !id) return undefined;
|
|
248
316
|
|
|
249
317
|
const name = firstString(
|
|
@@ -309,22 +377,6 @@ function resolveRoles(
|
|
|
309
377
|
return Object.freeze(roles);
|
|
310
378
|
}
|
|
311
379
|
|
|
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
380
|
function isCommandScene(value: unknown): value is CommandScene {
|
|
329
381
|
if (!value || typeof value !== 'object') return false;
|
|
330
382
|
const scene = value as Partial<CommandScene>;
|
package/src/provider.ts
CHANGED
|
@@ -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
|
|
|
@@ -81,45 +80,40 @@ function isCommandSegment(value: string): boolean {
|
|
|
81
80
|
|
|
82
81
|
interface ParsedCommandFile {
|
|
83
82
|
readonly localSegment: string;
|
|
84
|
-
readonly parameter?:
|
|
83
|
+
readonly parameter?: CommandParameterHint;
|
|
85
84
|
}
|
|
86
85
|
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
/** 文件名声明的参数形态;类型与默认值来自 `defineCommand({ params })`。 */
|
|
87
|
+
interface CommandParameterHint {
|
|
88
|
+
readonly name: string;
|
|
89
|
+
readonly optional: boolean;
|
|
90
|
+
readonly rest: boolean;
|
|
91
|
+
}
|
|
89
92
|
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
'face',
|
|
101
|
-
'reply',
|
|
102
|
-
'forward',
|
|
103
|
-
'dice',
|
|
104
|
-
'rps',
|
|
105
|
-
]);
|
|
93
|
+
const dynamicCommandFilePatterns: ReadonlyArray<{
|
|
94
|
+
readonly pattern: RegExp;
|
|
95
|
+
readonly optional: boolean;
|
|
96
|
+
readonly rest: boolean;
|
|
97
|
+
}> = [
|
|
98
|
+
{ pattern: /^\[\[\.\.\.([a-zA-Z][a-zA-Z0-9]*)\]\]\.(?:tsx?|[cm]?js)$/, optional: true, rest: true },
|
|
99
|
+
{ pattern: /^\[\.\.\.([a-zA-Z][a-zA-Z0-9]*)\]\.(?:tsx?|[cm]?js)$/, optional: false, rest: true },
|
|
100
|
+
{ pattern: /^\[\[([a-zA-Z][a-zA-Z0-9]*)\]\]\.(?:tsx?|[cm]?js)$/, optional: true, rest: false },
|
|
101
|
+
{ pattern: /^\[([a-zA-Z][a-zA-Z0-9]*)\]\.(?:tsx?|[cm]?js)$/, optional: false, rest: false },
|
|
102
|
+
];
|
|
106
103
|
|
|
107
104
|
function parseCommandFile(value: string): ParsedCommandFile | undefined {
|
|
108
105
|
if (/^[a-z0-9][a-z0-9-]*\.(?:tsx?|[cm]?js)$/.test(value)) {
|
|
109
106
|
return { localSegment: parse(value).name };
|
|
110
107
|
}
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
throw new CommandPathSyntaxError(value, `unsupported parameter type: ${rawType ?? ''}`);
|
|
116
|
-
}
|
|
117
|
-
const type = rawType as CommandParameterType;
|
|
108
|
+
for (const { pattern, optional, rest } of dynamicCommandFilePatterns) {
|
|
109
|
+
const match = pattern.exec(value);
|
|
110
|
+
if (!match || !match[1]) continue;
|
|
111
|
+
const name = match[1];
|
|
118
112
|
// Metadata can change during HMR while $name keeps the Capability identity stable.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
: { name,
|
|
122
|
-
|
|
113
|
+
return {
|
|
114
|
+
localSegment: `$${name}`,
|
|
115
|
+
parameter: { name, optional, rest },
|
|
116
|
+
};
|
|
123
117
|
}
|
|
124
118
|
if (value.startsWith('[') || value.includes(']')) {
|
|
125
119
|
throw new CommandPathSyntaxError(value);
|
|
@@ -127,66 +121,51 @@ function parseCommandFile(value: string): ParsedCommandFile | undefined {
|
|
|
127
121
|
return undefined;
|
|
128
122
|
}
|
|
129
123
|
|
|
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,
|
|
124
|
+
/** 把文件名形态与 `definition.params` 合并成完整参数定义。 */
|
|
125
|
+
function resolveParameter(
|
|
126
|
+
definition: CommandDefinition,
|
|
127
|
+
file: ParsedCommandFile | undefined,
|
|
143
128
|
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;
|
|
129
|
+
): CommandParameterDefinition | undefined {
|
|
130
|
+
const hint = file?.parameter;
|
|
131
|
+
if (!hint) return undefined;
|
|
132
|
+
const schema = definition.params?.[hint.name];
|
|
133
|
+
if (!schema) {
|
|
154
134
|
throw new CommandPathSyntaxError(
|
|
155
135
|
source,
|
|
156
|
-
`
|
|
136
|
+
`missing params.${hint.name} declaration in defineCommand({ params })`,
|
|
157
137
|
);
|
|
158
138
|
}
|
|
159
|
-
if (
|
|
160
|
-
if (value === 'true' || value === 'false') return value === 'true';
|
|
139
|
+
if (!hint.optional && schema.default !== undefined) {
|
|
161
140
|
throw new CommandPathSyntaxError(
|
|
162
141
|
source,
|
|
163
|
-
`default
|
|
142
|
+
`params.${hint.name} has a default but the file is required: rename to [[${hint.name}]]`,
|
|
164
143
|
);
|
|
165
144
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
`default for ${name}:${type} is invalid`,
|
|
175
|
-
);
|
|
145
|
+
return {
|
|
146
|
+
name: hint.name,
|
|
147
|
+
type: schema.type,
|
|
148
|
+
...(schema.default !== undefined ? { defaultValue: schema.default } : {}),
|
|
149
|
+
optional: hint.optional,
|
|
150
|
+
rest: hint.rest,
|
|
151
|
+
...(schema.description !== undefined ? { description: schema.description } : {}),
|
|
152
|
+
};
|
|
176
153
|
}
|
|
177
154
|
|
|
178
|
-
function
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|| type === 'rps';
|
|
155
|
+
function commandFilePriority(value: string, preferJavaScript: boolean): number {
|
|
156
|
+
const extension = value.slice(value.lastIndexOf('.') + 1);
|
|
157
|
+
const order = preferJavaScript
|
|
158
|
+
? ['js', 'mjs', 'cjs', 'ts', 'tsx']
|
|
159
|
+
: ['ts', 'tsx', 'js', 'mjs', 'cjs'];
|
|
160
|
+
const priority = order.indexOf(extension);
|
|
161
|
+
return priority < 0 ? Number.MAX_SAFE_INTEGER : priority;
|
|
186
162
|
}
|
|
187
163
|
|
|
188
164
|
export class CommandPathSyntaxError extends TypeError {
|
|
189
|
-
constructor(
|
|
165
|
+
constructor(
|
|
166
|
+
file: string,
|
|
167
|
+
detail = 'expected [name].ts(x), [[name]].ts(x), [...name].ts(x) or [[...name]].ts(x)',
|
|
168
|
+
) {
|
|
190
169
|
super(`Invalid Command path ${file}: ${detail}`);
|
|
191
170
|
this.name = 'CommandPathSyntaxError';
|
|
192
171
|
}
|