@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/README.md
CHANGED
|
@@ -3,28 +3,36 @@
|
|
|
3
3
|
Zhin Plugin Runtime 的约定式 Command Feature。它发现 `commands/**/*.ts(x)`,将插件树
|
|
4
4
|
路径与文件路径投影为命令,并用 `segment-matcher` 同时匹配纯文本和 canonical IM segments。
|
|
5
5
|
|
|
6
|
+
静态命令文件名可为 ASCII kebab(`hello.ts`)或 Unicode 名(`赞我.ts`);动态参数文件
|
|
7
|
+
(`[name].ts` 等)仍限 ASCII。详见 [命令创作指南](../../../docs/authoring/commands.md)。
|
|
8
|
+
|
|
6
9
|
## Authoring
|
|
7
10
|
|
|
8
11
|
```ts
|
|
9
12
|
// commands/gh/issue/list.ts -> gh issue list
|
|
13
|
+
// commands/赞我.ts -> 赞我
|
|
10
14
|
import { defineCommand } from '@zhin.js/command';
|
|
11
15
|
|
|
12
16
|
export default defineCommand({
|
|
13
17
|
description: 'List GitHub issues',
|
|
18
|
+
alias: ['issues'], // 可多词;子插件仍保留 owner 前缀
|
|
19
|
+
permit: ['adapter(icqq)', 'role(master)'], // 数组 AND;未过则静默未命中
|
|
20
|
+
// shortcut: { '列 issue': {} }, // 全局整句,可打破命名空间
|
|
14
21
|
execute: ({ args }) => `issues:${args.join(',')}`,
|
|
15
22
|
});
|
|
16
23
|
```
|
|
17
24
|
|
|
18
|
-
|
|
25
|
+
最后一个文件名可以用 Next.js 风格方括号声明参数形态,类型与默认值在 `defineCommand({ params })` 中声明(`type` 必填,`default` 可选且有默认值时文件名必须用双方括号):
|
|
19
26
|
|
|
20
27
|
```text
|
|
21
|
-
commands/gh/pr/[title
|
|
22
|
-
commands/upload/[asset
|
|
28
|
+
commands/gh/pr/[[title]].ts -> gh pr [title] (params: { title: { type: 'string', default: 'defaultTitle' } })
|
|
29
|
+
commands/upload/[asset].ts -> upload <asset> (params: { asset: { type: 'image' } })
|
|
30
|
+
commands/search/[...kw].ts -> search <...kw> (params: { kw: { type: 'text' } },运行时 params.kw 为数组;元素粒度随类型:text 逐消息段,word/string 逐词,number/boolean 逐词转换)
|
|
23
31
|
```
|
|
24
32
|
|
|
25
33
|
文本类型包括 `string`、`word`、`text`、`number`、`integer`、`float`、`boolean`;结构化
|
|
26
34
|
类型包括 `mention`、`image`、`face`、`reply`、`forward`、`dice`、`rps`。结构化类型
|
|
27
|
-
直接从对应 segment
|
|
35
|
+
直接从对应 segment 取值,不能声明默认值。
|
|
28
36
|
|
|
29
37
|
`execute()` 收到冻结的 `CommandContext`:
|
|
30
38
|
|
|
@@ -37,6 +45,12 @@ commands/upload/[asset:image].ts -> upload <asset>
|
|
|
37
45
|
- `scene`:`{ id, type, name? }` 场景对象。
|
|
38
46
|
- `sender`:`{ id, name?, role: string[] }` 发送者对象。
|
|
39
47
|
|
|
48
|
+
可选声明字段:
|
|
49
|
+
|
|
50
|
+
- `alias`:替换全部本地静态段并重挂 owner 前缀(不打破子插件命名空间)。
|
|
51
|
+
- `permit`:内置 DSL(`adapter|group|private|channel|user|role`);失败为静默未命中。
|
|
52
|
+
- `shortcut`:全局整句精确匹配 → 预填 `params`(可打破命名空间)。
|
|
53
|
+
|
|
40
54
|
单文件插件可在 `setup({ addCommand })` 中调用
|
|
41
55
|
`addCommand('hello', defineCommand(...))`。它与目录发现共用 CommandIndex;拆成文件后
|
|
42
56
|
可获得单命令 HMR。
|
package/lib/command-index.d.ts
CHANGED
|
@@ -8,6 +8,10 @@ export interface CommandDescriptor {
|
|
|
8
8
|
readonly description?: string;
|
|
9
9
|
readonly source: string;
|
|
10
10
|
readonly parameters: readonly CommandParameterDescriptor[];
|
|
11
|
+
readonly alias?: readonly string[];
|
|
12
|
+
readonly permit?: readonly string[];
|
|
13
|
+
/** shortcut 触发键列表(不含预填 params)。 */
|
|
14
|
+
readonly shortcut?: readonly string[];
|
|
11
15
|
}
|
|
12
16
|
export interface CommandDispatchResult {
|
|
13
17
|
readonly matched: boolean;
|
package/lib/command-index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { SegmentMatcher, TypeMatcherRegistry, } from 'segment-matcher';
|
|
2
|
-
import { createCommandContext, } from './definition.js';
|
|
2
|
+
import { createCommandContext, resolveCommandSession, } from './definition.js';
|
|
3
|
+
import { permissionHostToken } from '@zhin.js/permission';
|
|
4
|
+
import { toPermissionSubject } from '@zhin.js/permission';
|
|
3
5
|
const segmentFields = {
|
|
4
6
|
text: 'text',
|
|
5
7
|
mention: 'target',
|
|
@@ -15,44 +17,83 @@ export class CommandIndex {
|
|
|
15
17
|
snapshot;
|
|
16
18
|
$projection = 'zhin.command-index/1';
|
|
17
19
|
#commands;
|
|
20
|
+
#routes;
|
|
21
|
+
#shortcuts;
|
|
18
22
|
constructor(slots, snapshot) {
|
|
19
23
|
this.snapshot = snapshot;
|
|
20
24
|
const commands = [];
|
|
21
|
-
const
|
|
22
|
-
const
|
|
25
|
+
const routes = [];
|
|
26
|
+
const occupancy = new Map();
|
|
27
|
+
const shortcuts = new Map();
|
|
28
|
+
const claim = (key, source) => {
|
|
29
|
+
const existing = occupancy.get(key);
|
|
30
|
+
if (existing !== undefined) {
|
|
31
|
+
throw new Error(`Duplicate Command route "${key}" (${source} vs ${existing})`);
|
|
32
|
+
}
|
|
33
|
+
occupancy.set(key, source);
|
|
34
|
+
};
|
|
23
35
|
for (const slot of slots) {
|
|
24
|
-
const
|
|
36
|
+
const primarySegments = runtimeSegments(slot.owner, slot.localName);
|
|
25
37
|
const parameter = slot.definition.$parameter;
|
|
26
|
-
assertParameterSegment(
|
|
27
|
-
const name = displayName(
|
|
38
|
+
assertParameterSegment(primarySegments, parameter, slot.source);
|
|
39
|
+
const name = displayName(primarySegments, parameter);
|
|
40
|
+
const alias = normalizeAliasList(slot.definition.alias);
|
|
41
|
+
const permit = slot.definition.permit
|
|
42
|
+
? Object.freeze([...slot.definition.permit])
|
|
43
|
+
: undefined;
|
|
44
|
+
const shortcutKeys = slot.definition.shortcut
|
|
45
|
+
? Object.freeze(Object.keys(slot.definition.shortcut).map((key) => key.trim()))
|
|
46
|
+
: undefined;
|
|
28
47
|
const record = Object.freeze({
|
|
29
48
|
name,
|
|
30
49
|
description: slot.definition.description,
|
|
31
50
|
source: slot.source,
|
|
32
51
|
parameters: Object.freeze(parameter ? [{
|
|
33
52
|
...parameter,
|
|
34
|
-
required: parameter
|
|
53
|
+
required: isRequiredParameter(parameter),
|
|
35
54
|
}] : []),
|
|
55
|
+
...(alias ? { alias } : {}),
|
|
56
|
+
...(permit ? { permit } : {}),
|
|
57
|
+
...(shortcutKeys && shortcutKeys.length > 0 ? { shortcut: shortcutKeys } : {}),
|
|
36
58
|
slot,
|
|
37
|
-
segments: Object.freeze(
|
|
59
|
+
segments: Object.freeze(primarySegments),
|
|
38
60
|
parameter,
|
|
39
|
-
matcher: new SegmentMatcher(matcherPattern(segments, parameter), segmentFields),
|
|
40
61
|
});
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
62
|
+
claim(occupancyKey(primarySegments, parameter), slot.source);
|
|
63
|
+
routes.push({
|
|
64
|
+
record,
|
|
65
|
+
segments: primarySegments,
|
|
66
|
+
matcher: new SegmentMatcher(matcherPattern(primarySegments, parameter), segmentFields),
|
|
67
|
+
kind: 'primary',
|
|
68
|
+
});
|
|
69
|
+
if (alias) {
|
|
70
|
+
for (const entry of alias) {
|
|
71
|
+
const aliasSegments = aliasRuntimeSegments(slot.owner, entry, primarySegments);
|
|
72
|
+
assertParameterSegment(aliasSegments, parameter, `${slot.source} alias ${JSON.stringify(entry)}`);
|
|
73
|
+
claim(occupancyKey(aliasSegments, parameter), `${slot.source} alias ${JSON.stringify(entry)}`);
|
|
74
|
+
routes.push({
|
|
75
|
+
record,
|
|
76
|
+
segments: Object.freeze(aliasSegments),
|
|
77
|
+
matcher: new SegmentMatcher(matcherPattern(aliasSegments, parameter), segmentFields),
|
|
78
|
+
kind: 'alias',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
46
81
|
}
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
82
|
+
if (slot.definition.shortcut) {
|
|
83
|
+
for (const [rawTrigger, prefill] of Object.entries(slot.definition.shortcut)) {
|
|
84
|
+
const trigger = rawTrigger.trim();
|
|
85
|
+
claim(trigger, `${slot.source} shortcut ${JSON.stringify(trigger)}`);
|
|
86
|
+
shortcuts.set(trigger, {
|
|
87
|
+
record,
|
|
88
|
+
params: Object.freeze(resolveShortcutParams(slot.definition, prefill, `${slot.source} shortcut ${JSON.stringify(trigger)}`)),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
52
91
|
}
|
|
53
92
|
commands.push(record);
|
|
54
93
|
}
|
|
55
|
-
this.#commands = Object.freeze(commands.sort(
|
|
94
|
+
this.#commands = Object.freeze(commands.sort(compareRecords));
|
|
95
|
+
this.#routes = Object.freeze(routes.sort(compareRoutes));
|
|
96
|
+
this.#shortcuts = shortcuts;
|
|
56
97
|
}
|
|
57
98
|
list() {
|
|
58
99
|
return this.#commands.map(toDescriptor);
|
|
@@ -73,12 +114,29 @@ export class CommandIndex {
|
|
|
73
114
|
this.#diagnoseParameter(name);
|
|
74
115
|
throw new Error(`Unknown Command: ${name}`);
|
|
75
116
|
}
|
|
117
|
+
// Host / 无 session:跳过 permit。
|
|
76
118
|
return match.command.slot.definition.execute(createCommandContext(this.snapshot, match.command.slot.owner, args, match.params));
|
|
77
119
|
}
|
|
78
120
|
async dispatch(input, source = undefined) {
|
|
121
|
+
const shortcut = this.#matchShortcut(input);
|
|
122
|
+
if (shortcut) {
|
|
123
|
+
if (!(await this.#permitAllows(shortcut.record, source))) {
|
|
124
|
+
return Object.freeze({ matched: false });
|
|
125
|
+
}
|
|
126
|
+
const value = await shortcut.record.slot.definition.execute(createCommandContext(this.snapshot, shortcut.record.slot.owner, Object.freeze([]), shortcut.params, source, Object.freeze([])));
|
|
127
|
+
return Object.freeze({
|
|
128
|
+
matched: true,
|
|
129
|
+
command: shortcut.record.name,
|
|
130
|
+
owner: shortcut.record.slot.owner,
|
|
131
|
+
value,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
79
134
|
const match = this.#match(input, false);
|
|
80
135
|
if (!match)
|
|
81
136
|
return Object.freeze({ matched: false });
|
|
137
|
+
if (!(await this.#permitAllows(match.command, source))) {
|
|
138
|
+
return Object.freeze({ matched: false });
|
|
139
|
+
}
|
|
82
140
|
const args = textArgs(match.remaining);
|
|
83
141
|
const value = await match.command.slot.definition.execute(createCommandContext(this.snapshot, match.command.slot.owner, args, match.params, source, match.remaining));
|
|
84
142
|
return Object.freeze({
|
|
@@ -88,6 +146,38 @@ export class CommandIndex {
|
|
|
88
146
|
value,
|
|
89
147
|
});
|
|
90
148
|
}
|
|
149
|
+
async #permitAllows(record, source) {
|
|
150
|
+
const permits = record.permit;
|
|
151
|
+
if (!permits || permits.length === 0)
|
|
152
|
+
return true;
|
|
153
|
+
if (!hasImSession(source))
|
|
154
|
+
return true;
|
|
155
|
+
const host = this.#resolveHost();
|
|
156
|
+
if (!host)
|
|
157
|
+
return false;
|
|
158
|
+
const subject = toPermissionSubject(resolveCommandSession(source));
|
|
159
|
+
return host.checkAll(permits, subject);
|
|
160
|
+
}
|
|
161
|
+
#resolveHost() {
|
|
162
|
+
try {
|
|
163
|
+
const resources = this.snapshot.resources.get(this.snapshot.root);
|
|
164
|
+
if (!resources)
|
|
165
|
+
return undefined;
|
|
166
|
+
const host = resources.get(permissionHostToken.id);
|
|
167
|
+
return host && typeof host.check === 'function'
|
|
168
|
+
? host
|
|
169
|
+
: undefined;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
#matchShortcut(input) {
|
|
176
|
+
const text = exactMessageText(input);
|
|
177
|
+
if (text === undefined)
|
|
178
|
+
return undefined;
|
|
179
|
+
return this.#shortcuts.get(text);
|
|
180
|
+
}
|
|
91
181
|
#match(input, exact) {
|
|
92
182
|
const segments = normalizeSegments(typeof input === 'string'
|
|
93
183
|
? input.trim()
|
|
@@ -96,16 +186,30 @@ export class CommandIndex {
|
|
|
96
186
|
: input);
|
|
97
187
|
if (segments.length === 0)
|
|
98
188
|
return undefined;
|
|
99
|
-
for (const
|
|
100
|
-
const result =
|
|
189
|
+
for (const route of this.#routes) {
|
|
190
|
+
const result = route.matcher.match(asMatcherSegments(segments));
|
|
101
191
|
if (!result || !hasCommandBoundary(result.remaining))
|
|
102
192
|
continue;
|
|
193
|
+
const parameter = route.record.parameter;
|
|
194
|
+
const params = { ...result.params };
|
|
195
|
+
if (parameter?.rest) {
|
|
196
|
+
const raw = result.params[parameter.name];
|
|
197
|
+
const coerced = coerceRestValues(parameter, Array.isArray(raw) ? raw : []);
|
|
198
|
+
if (!coerced || (isRequiredParameter(parameter) && coerced.length === 0))
|
|
199
|
+
continue;
|
|
200
|
+
params[parameter.name] = coerced;
|
|
201
|
+
}
|
|
103
202
|
const remaining = normalizeSegments(result.remaining);
|
|
104
203
|
if (exact && remaining.length > 0)
|
|
105
204
|
continue;
|
|
205
|
+
if (parameter && !parameter.rest && parameter.optional === true
|
|
206
|
+
&& parameter.defaultValue === undefined
|
|
207
|
+
&& (params[parameter.name] === '' || params[parameter.name] === null)) {
|
|
208
|
+
delete params[parameter.name];
|
|
209
|
+
}
|
|
106
210
|
return {
|
|
107
|
-
command,
|
|
108
|
-
params: Object.freeze(
|
|
211
|
+
command: route.record,
|
|
212
|
+
params: Object.freeze(params),
|
|
109
213
|
remaining,
|
|
110
214
|
};
|
|
111
215
|
}
|
|
@@ -113,14 +217,14 @@ export class CommandIndex {
|
|
|
113
217
|
}
|
|
114
218
|
#diagnoseParameter(name) {
|
|
115
219
|
const words = splitCommand(name);
|
|
116
|
-
for (const
|
|
117
|
-
const parameter =
|
|
118
|
-
if (!parameter)
|
|
220
|
+
for (const route of this.#routes) {
|
|
221
|
+
const parameter = route.record.parameter;
|
|
222
|
+
if (!parameter || parameter.rest)
|
|
119
223
|
continue;
|
|
120
|
-
const parameterIndex =
|
|
121
|
-
if (words.length !==
|
|
224
|
+
const parameterIndex = route.segments.findIndex((segment) => segment.startsWith('$'));
|
|
225
|
+
if (words.length !== route.segments.length)
|
|
122
226
|
continue;
|
|
123
|
-
if (!
|
|
227
|
+
if (!route.segments.every((segment, index) => index === parameterIndex || segment === words[index]))
|
|
124
228
|
continue;
|
|
125
229
|
const value = words[parameterIndex];
|
|
126
230
|
if (value === undefined || matchesParameter(parameter.type, value))
|
|
@@ -146,6 +250,82 @@ function runtimeSegments(owner, localName) {
|
|
|
146
250
|
const prefix = owner.slice('root/'.length).split('/').join('.');
|
|
147
251
|
return [`${prefix}.${localSegments[0]}`, ...localSegments.slice(1)];
|
|
148
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* 用 alias 词序列替换全部本地静态段,再按 owner 规则重挂前缀;动态段保留。
|
|
255
|
+
*/
|
|
256
|
+
function aliasRuntimeSegments(owner, alias, primarySegments) {
|
|
257
|
+
const aliasTokens = alias.trim().split(/\s+/u).filter(Boolean);
|
|
258
|
+
const dynamicTail = primarySegments.filter((segment) => segment.startsWith('$'));
|
|
259
|
+
if (owner === 'root')
|
|
260
|
+
return [...aliasTokens, ...dynamicTail];
|
|
261
|
+
const prefix = owner.slice('root/'.length).split('/').join('.');
|
|
262
|
+
return [`${prefix}.${aliasTokens[0]}`, ...aliasTokens.slice(1), ...dynamicTail];
|
|
263
|
+
}
|
|
264
|
+
function occupancyKey(segments, parameter) {
|
|
265
|
+
return parameter ? routeShape(segments) : segments.join(' ');
|
|
266
|
+
}
|
|
267
|
+
function normalizeAliasList(alias) {
|
|
268
|
+
if (!alias || alias.length === 0)
|
|
269
|
+
return undefined;
|
|
270
|
+
return Object.freeze(alias.map((entry) => entry.trim().split(/\s+/u).filter(Boolean).join(' ')));
|
|
271
|
+
}
|
|
272
|
+
function resolveShortcutParams(definition, prefill, source) {
|
|
273
|
+
const allowed = new Set();
|
|
274
|
+
const parameter = definition.$parameter;
|
|
275
|
+
if (parameter)
|
|
276
|
+
allowed.add(parameter.name);
|
|
277
|
+
if (definition.params) {
|
|
278
|
+
for (const key of Object.keys(definition.params))
|
|
279
|
+
allowed.add(key);
|
|
280
|
+
}
|
|
281
|
+
for (const key of Object.keys(prefill)) {
|
|
282
|
+
if (!allowed.has(key)) {
|
|
283
|
+
throw new TypeError(`Invalid shortcut params for ${source}: unknown key ${JSON.stringify(key)}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const result = { ...prefill };
|
|
287
|
+
if (parameter) {
|
|
288
|
+
if (result[parameter.name] === undefined) {
|
|
289
|
+
if (parameter.defaultValue !== undefined) {
|
|
290
|
+
result[parameter.name] = parameter.defaultValue;
|
|
291
|
+
}
|
|
292
|
+
else if (isRequiredParameter(parameter)) {
|
|
293
|
+
throw new TypeError(`Invalid shortcut params for ${source}: missing required ${parameter.name}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
else if (allowed.size === 0 && Object.keys(prefill).length > 0) {
|
|
298
|
+
throw new TypeError(`Invalid shortcut params for ${source}: command has no params declaration`);
|
|
299
|
+
}
|
|
300
|
+
if (definition.params) {
|
|
301
|
+
for (const [name, schema] of Object.entries(definition.params)) {
|
|
302
|
+
if (result[name] === undefined && schema.default !== undefined) {
|
|
303
|
+
result[name] = schema.default;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return result;
|
|
308
|
+
}
|
|
309
|
+
function hasImSession(source) {
|
|
310
|
+
if (!source || typeof source !== 'object')
|
|
311
|
+
return false;
|
|
312
|
+
const conversation = source.conversation;
|
|
313
|
+
return !!conversation && typeof conversation === 'object';
|
|
314
|
+
}
|
|
315
|
+
function exactMessageText(input) {
|
|
316
|
+
if (typeof input === 'string') {
|
|
317
|
+
const trimmed = input.trim();
|
|
318
|
+
return trimmed || undefined;
|
|
319
|
+
}
|
|
320
|
+
// 仅纯单 text 段可作整句 shortcut;含 mention/image 等则不走 shortcut。
|
|
321
|
+
if (input.length !== 1)
|
|
322
|
+
return undefined;
|
|
323
|
+
const only = input[0];
|
|
324
|
+
if (!only || only.type !== 'text' || typeof only.data.text !== 'string')
|
|
325
|
+
return undefined;
|
|
326
|
+
const trimmed = only.data.text.trim();
|
|
327
|
+
return trimmed || undefined;
|
|
328
|
+
}
|
|
149
329
|
function assertParameterSegment(segments, parameter, source) {
|
|
150
330
|
const dynamicSegments = segments.filter((segment) => segment.startsWith('$'));
|
|
151
331
|
if (!parameter && dynamicSegments.length === 0)
|
|
@@ -154,15 +334,26 @@ function assertParameterSegment(segments, parameter, source) {
|
|
|
154
334
|
&& dynamicSegments[0] === `$${parameter.name}`
|
|
155
335
|
&& segments.at(-1) === dynamicSegments[0])
|
|
156
336
|
return;
|
|
157
|
-
|
|
337
|
+
const dynamic = dynamicSegments[0] ?? (parameter ? `$${parameter.name}` : '$?');
|
|
338
|
+
throw new Error(`Invalid Command path for ${source}: the dynamic segment "${dynamic}" must be the only dynamic `
|
|
339
|
+
+ `segment and come after a static segment (child plugin commands are prefixed by the plugin `
|
|
340
|
+
+ `path, so a dynamic first segment is never reachable). `
|
|
341
|
+
+ (parameter
|
|
342
|
+
? `Hint: move the file under a static directory, e.g. "commands/add/[${parameter.name}:${parameter.type}].ts".`
|
|
343
|
+
: 'Hint: put the file under a static directory, e.g. "commands/add/<file>.ts".'));
|
|
344
|
+
}
|
|
345
|
+
function isRequiredParameter(parameter) {
|
|
346
|
+
return parameter.optional === true ? false : parameter.defaultValue === undefined;
|
|
158
347
|
}
|
|
159
348
|
function displayName(segments, parameter) {
|
|
160
349
|
return segments.map((segment) => {
|
|
161
350
|
if (!segment.startsWith('$'))
|
|
162
351
|
return segment;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
352
|
+
const label = segment.slice(1);
|
|
353
|
+
const required = !parameter || isRequiredParameter(parameter);
|
|
354
|
+
if (parameter?.rest)
|
|
355
|
+
return required ? `<...${label}>` : `[...${label}]`;
|
|
356
|
+
return required ? `<${label}>` : `[${label}]`;
|
|
166
357
|
}).join(' ');
|
|
167
358
|
}
|
|
168
359
|
function matcherPattern(segments, parameter) {
|
|
@@ -172,25 +363,72 @@ function matcherPattern(segments, parameter) {
|
|
|
172
363
|
if (!parameter)
|
|
173
364
|
throw new Error(`Missing Command parameter metadata: ${segment}`);
|
|
174
365
|
const type = matcherType(parameter.type);
|
|
366
|
+
if (parameter.rest) {
|
|
367
|
+
return `[...${parameter.name}:${isStructuredRestType(parameter.type) ? type : 'text'}]`;
|
|
368
|
+
}
|
|
369
|
+
if (isRequiredParameter(parameter))
|
|
370
|
+
return `<${parameter.name}:${type}>`;
|
|
175
371
|
return parameter.defaultValue === undefined
|
|
176
|
-
?
|
|
372
|
+
? `[${parameter.name}:${type}]`
|
|
177
373
|
: `[${parameter.name}:${type}=${String(parameter.defaultValue)}]`;
|
|
178
374
|
}).join(' ');
|
|
179
375
|
}
|
|
180
376
|
function matcherType(type) {
|
|
181
377
|
return type === 'string' ? 'word' : type;
|
|
182
378
|
}
|
|
379
|
+
function isStructuredRestType(type) {
|
|
380
|
+
return type === 'mention'
|
|
381
|
+
|| type === 'image'
|
|
382
|
+
|| type === 'face'
|
|
383
|
+
|| type === 'reply'
|
|
384
|
+
|| type === 'forward'
|
|
385
|
+
|| type === 'dice'
|
|
386
|
+
|| type === 'rps';
|
|
387
|
+
}
|
|
388
|
+
function coerceRestValues(parameter, values) {
|
|
389
|
+
const type = parameter.type;
|
|
390
|
+
if (type === 'text' || isStructuredRestType(type)) {
|
|
391
|
+
return values;
|
|
392
|
+
}
|
|
393
|
+
const words = values.flatMap((value) => typeof value === 'string' ? value.split(/\s+/u).filter(Boolean) : []);
|
|
394
|
+
if (type === 'string' || type === 'word')
|
|
395
|
+
return words;
|
|
396
|
+
if (type === 'number' || type === 'integer' || type === 'float') {
|
|
397
|
+
const numbers = [];
|
|
398
|
+
for (const [index, word] of words.entries()) {
|
|
399
|
+
const number = Number(word);
|
|
400
|
+
if (!Number.isFinite(number)
|
|
401
|
+
|| (type === 'integer' && !Number.isInteger(number))
|
|
402
|
+
|| (type === 'float' && !word.includes('.')))
|
|
403
|
+
return undefined;
|
|
404
|
+
numbers[index] = number;
|
|
405
|
+
}
|
|
406
|
+
return numbers;
|
|
407
|
+
}
|
|
408
|
+
if (type === 'boolean') {
|
|
409
|
+
if (!words.every((word) => word === 'true' || word === 'false'))
|
|
410
|
+
return undefined;
|
|
411
|
+
return words.map((word) => word === 'true');
|
|
412
|
+
}
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
183
415
|
function routeShape(segments) {
|
|
184
416
|
return segments.map((segment) => segment.startsWith('$') ? '$' : segment).join(' ');
|
|
185
417
|
}
|
|
186
|
-
function
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
418
|
+
function compareRecords(left, right) {
|
|
419
|
+
return left.name.localeCompare(right.name);
|
|
420
|
+
}
|
|
421
|
+
function compareRoutes(left, right) {
|
|
422
|
+
return dynamicWeight(left) - dynamicWeight(right)
|
|
190
423
|
|| staticSegmentCount(right.segments) - staticSegmentCount(left.segments)
|
|
191
424
|
|| right.segments.length - left.segments.length
|
|
192
|
-
|| right.name.length - left.name.length
|
|
193
|
-
|| left.name.localeCompare(right.name);
|
|
425
|
+
|| right.record.name.length - left.record.name.length
|
|
426
|
+
|| left.record.name.localeCompare(right.record.name);
|
|
427
|
+
}
|
|
428
|
+
function dynamicWeight(route) {
|
|
429
|
+
if (!route.record.parameter)
|
|
430
|
+
return 0;
|
|
431
|
+
return route.record.parameter.rest ? 2 : 1;
|
|
194
432
|
}
|
|
195
433
|
function staticSegmentCount(segments) {
|
|
196
434
|
return segments.filter((segment) => !segment.startsWith('$')).length;
|
|
@@ -251,12 +489,9 @@ function textArgs(segments) {
|
|
|
251
489
|
return splitCommand(segment.data.text);
|
|
252
490
|
}));
|
|
253
491
|
}
|
|
254
|
-
function toDescriptor({ slot: _slot, segments: _segments, parameter: _parameter,
|
|
492
|
+
function toDescriptor({ slot: _slot, segments: _segments, parameter: _parameter, ...descriptor }) {
|
|
255
493
|
return descriptor;
|
|
256
494
|
}
|
|
257
|
-
function duplicateCommand(name) {
|
|
258
|
-
return new Error(`Duplicate runtime Command: ${name}`);
|
|
259
|
-
}
|
|
260
495
|
export class CommandParameterValueError extends TypeError {
|
|
261
496
|
constructor(name, type, value) {
|
|
262
497
|
super(`Invalid value for Command parameter ${name}:${type}: ${value}`);
|
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: 'private' | 'group' | 'channel';
|
|
63
|
+
readonly id: string;
|
|
64
|
+
}>;
|
|
65
|
+
readonly threadId?: string;
|
|
66
|
+
}
|
|
33
67
|
/**
|
|
34
68
|
* 命令侧入站消息契约。
|
|
35
69
|
*
|
|
@@ -37,17 +71,39 @@ 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
|
-
/**
|
|
44
|
-
readonly sender?:
|
|
76
|
+
/** 发送者(结构化视图见 CommandContext.sender)。 */
|
|
77
|
+
readonly sender?: {
|
|
78
|
+
readonly id: string;
|
|
79
|
+
readonly name?: string;
|
|
80
|
+
readonly roles?: readonly string[];
|
|
81
|
+
};
|
|
45
82
|
readonly id?: string;
|
|
46
83
|
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
47
84
|
/** 若上游已结构化,优先采用。 */
|
|
48
85
|
readonly scene?: CommandScene;
|
|
49
|
-
|
|
50
|
-
|
|
86
|
+
$reply?(content: unknown): Promise<unknown>;
|
|
87
|
+
$replyFrom?(requester: string, content: unknown): Promise<unknown>;
|
|
88
|
+
/** 向同 Endpoint 的另一个通道发送消息(结构兼容 `Message.$sendTo`)。 */
|
|
89
|
+
$sendTo?(conversation: {
|
|
90
|
+
readonly kind: 'private' | 'group' | 'channel';
|
|
91
|
+
readonly id: string;
|
|
92
|
+
readonly parent?: Readonly<{
|
|
93
|
+
readonly kind: 'private' | 'group' | 'channel';
|
|
94
|
+
readonly id: string;
|
|
95
|
+
}>;
|
|
96
|
+
readonly threadId?: string;
|
|
97
|
+
}, content: unknown): Promise<unknown>;
|
|
98
|
+
/** 私信当前消息的发送者(结构兼容 `Message.$replyToPrivate`)。 */
|
|
99
|
+
$replyToPrivate?(content: unknown, from?: boolean | {
|
|
100
|
+
readonly kind: 'group' | 'channel';
|
|
101
|
+
readonly id: string;
|
|
102
|
+
}): Promise<unknown>;
|
|
103
|
+
/** 向指定群发送消息(结构兼容 `Message.$replyToGroup`)。 */
|
|
104
|
+
$replyToGroup?(groupId: string, content: unknown): Promise<unknown>;
|
|
105
|
+
/** 向指定频道发送消息(结构兼容 `Message.$replyToChannel`)。 */
|
|
106
|
+
$replyToChannel?(channelId: string, guildId: string, content: unknown, threadId?: string): Promise<unknown>;
|
|
51
107
|
}
|
|
52
108
|
/**
|
|
53
109
|
* IM 入站快捷字段。
|
|
@@ -82,6 +138,26 @@ export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput
|
|
|
82
138
|
readonly $feature: typeof commandBrand;
|
|
83
139
|
readonly $parameter?: CommandParameterDefinition;
|
|
84
140
|
readonly description?: string;
|
|
141
|
+
/**
|
|
142
|
+
* Next.js 风格参数声明:动态段文件名(`[name]` 等)的形态配合这里的
|
|
143
|
+
* 类型 / 默认值 / 描述使用。静态命令可忽略本字段。
|
|
144
|
+
*/
|
|
145
|
+
readonly params?: Readonly<Record<string, CommandParamSchema>>;
|
|
146
|
+
/**
|
|
147
|
+
* 本地静态段别名(可多词,如 `'gh issue'`)。替换全部本地静态段后仍挂
|
|
148
|
+
* owner 前缀;不打破子插件命名空间。
|
|
149
|
+
*/
|
|
150
|
+
readonly alias?: readonly string[];
|
|
151
|
+
/**
|
|
152
|
+
* 内置 permit DSL(AND)。单项内逗号为 OR。
|
|
153
|
+
* 例:`adapter(icqq)`、`role(master)`、`group(123,456)`。
|
|
154
|
+
*/
|
|
155
|
+
readonly permit?: readonly string[];
|
|
156
|
+
/**
|
|
157
|
+
* 全局整句快捷方式:触发串(trim 后全文相等)→ 预填 params。
|
|
158
|
+
* 可打破 owner 命名空间。
|
|
159
|
+
*/
|
|
160
|
+
readonly shortcut?: Readonly<Record<string, Readonly<Record<string, CommandParameterValue>>>>;
|
|
85
161
|
execute(context: CommandContext<TConfig, TInput>): TResult | Promise<TResult>;
|
|
86
162
|
}
|
|
87
163
|
declare module '@zhin.js/plugin-runtime' {
|