@zhin.js/command 1.0.7 → 1.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/lib/command-index.d.ts +4 -0
- package/lib/command-index.js +207 -54
- package/lib/definition.d.ts +70 -7
- package/lib/definition.js +71 -18
- 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.js +9 -7
- package/package.json +4 -3
- package/src/command-index.ts +267 -54
- package/src/definition.ts +143 -24
- package/src/index.ts +10 -0
- package/src/permit.ts +14 -0
- package/src/provider.ts +10 -8
package/README.md
CHANGED
|
@@ -3,14 +3,21 @@
|
|
|
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
|
```
|
|
@@ -38,6 +45,12 @@ commands/search/[...kw].ts -> search <...kw> (params: { kw: { type: 'text
|
|
|
38
45
|
- `scene`:`{ id, type, name? }` 场景对象。
|
|
39
46
|
- `sender`:`{ id, name?, role: string[] }` 发送者对象。
|
|
40
47
|
|
|
48
|
+
可选声明字段:
|
|
49
|
+
|
|
50
|
+
- `alias`:替换全部本地静态段并重挂 owner 前缀(不打破子插件命名空间)。
|
|
51
|
+
- `permit`:内置 DSL(`adapter|group|private|channel|user|role`);失败为静默未命中。
|
|
52
|
+
- `shortcut`:全局整句精确匹配 → 预填 `params`(可打破命名空间)。
|
|
53
|
+
|
|
41
54
|
单文件插件可在 `setup({ addCommand })` 中调用
|
|
42
55
|
`addCommand('hello', defineCommand(...))`。它与目录发现共用 CommandIndex;拆成文件后
|
|
43
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, resolveDynamicParams, } 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,16 +17,33 @@ 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,
|
|
@@ -33,26 +52,48 @@ export class CommandIndex {
|
|
|
33
52
|
...parameter,
|
|
34
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,14 +114,31 @@ export class CommandIndex {
|
|
|
73
114
|
this.#diagnoseParameter(name);
|
|
74
115
|
throw new Error(`Unknown Command: ${name}`);
|
|
75
116
|
}
|
|
76
|
-
|
|
117
|
+
// Host / 无 session:跳过 permit;无 source 时函数默认值得到空 session。
|
|
118
|
+
return match.command.slot.definition.execute(createCommandContext(this.snapshot, match.command.slot.owner, args, resolveDynamicParams(match.params, undefined)));
|
|
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([]), resolveDynamicParams(shortcut.params, source), 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
|
-
const value = await match.command.slot.definition.execute(createCommandContext(this.snapshot, match.command.slot.owner, args, match.params, source, match.remaining));
|
|
141
|
+
const value = await match.command.slot.definition.execute(createCommandContext(this.snapshot, match.command.slot.owner, args, resolveDynamicParams(match.params, source), source, match.remaining));
|
|
84
142
|
return Object.freeze({
|
|
85
143
|
matched: true,
|
|
86
144
|
command: match.command.name,
|
|
@@ -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,15 @@ 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;
|
|
103
|
-
const parameter =
|
|
193
|
+
const parameter = route.record.parameter;
|
|
104
194
|
const params = { ...result.params };
|
|
105
195
|
if (parameter?.rest) {
|
|
106
196
|
const raw = result.params[parameter.name];
|
|
107
197
|
const coerced = coerceRestValues(parameter, Array.isArray(raw) ? raw : []);
|
|
108
|
-
// 必需 `[...name]` 捕获所有:零元素视为不匹配;标量逐词转换失败同样不匹配。
|
|
109
198
|
if (!coerced || (isRequiredParameter(parameter) && coerced.length === 0))
|
|
110
199
|
continue;
|
|
111
200
|
params[parameter.name] = coerced;
|
|
@@ -113,15 +202,13 @@ export class CommandIndex {
|
|
|
113
202
|
const remaining = normalizeSegments(result.remaining);
|
|
114
203
|
if (exact && remaining.length > 0)
|
|
115
204
|
continue;
|
|
116
|
-
// `[[name]]` 无 default 且未命中时,matcher 对 text 回退 ''、其他类型回退 null;
|
|
117
|
-
// 按契约(省略 default 时未匹配为 undefined)删除该键。
|
|
118
205
|
if (parameter && !parameter.rest && parameter.optional === true
|
|
119
206
|
&& parameter.defaultValue === undefined
|
|
120
207
|
&& (params[parameter.name] === '' || params[parameter.name] === null)) {
|
|
121
208
|
delete params[parameter.name];
|
|
122
209
|
}
|
|
123
210
|
return {
|
|
124
|
-
command,
|
|
211
|
+
command: route.record,
|
|
125
212
|
params: Object.freeze(params),
|
|
126
213
|
remaining,
|
|
127
214
|
};
|
|
@@ -130,14 +217,14 @@ export class CommandIndex {
|
|
|
130
217
|
}
|
|
131
218
|
#diagnoseParameter(name) {
|
|
132
219
|
const words = splitCommand(name);
|
|
133
|
-
for (const
|
|
134
|
-
const parameter =
|
|
220
|
+
for (const route of this.#routes) {
|
|
221
|
+
const parameter = route.record.parameter;
|
|
135
222
|
if (!parameter || parameter.rest)
|
|
136
223
|
continue;
|
|
137
|
-
const parameterIndex =
|
|
138
|
-
if (words.length !==
|
|
224
|
+
const parameterIndex = route.segments.findIndex((segment) => segment.startsWith('$'));
|
|
225
|
+
if (words.length !== route.segments.length)
|
|
139
226
|
continue;
|
|
140
|
-
if (!
|
|
227
|
+
if (!route.segments.every((segment, index) => index === parameterIndex || segment === words[index]))
|
|
141
228
|
continue;
|
|
142
229
|
const value = words[parameterIndex];
|
|
143
230
|
if (value === undefined || matchesParameter(parameter.type, value))
|
|
@@ -163,6 +250,82 @@ function runtimeSegments(owner, localName) {
|
|
|
163
250
|
const prefix = owner.slice('root/'.length).split('/').join('.');
|
|
164
251
|
return [`${prefix}.${localSegments[0]}`, ...localSegments.slice(1)];
|
|
165
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
|
+
}
|
|
166
329
|
function assertParameterSegment(segments, parameter, source) {
|
|
167
330
|
const dynamicSegments = segments.filter((segment) => segment.startsWith('$'));
|
|
168
331
|
if (!parameter && dynamicSegments.length === 0)
|
|
@@ -200,7 +363,6 @@ function matcherPattern(segments, parameter) {
|
|
|
200
363
|
if (!parameter)
|
|
201
364
|
throw new Error(`Missing Command parameter metadata: ${segment}`);
|
|
202
365
|
const type = matcherType(parameter.type);
|
|
203
|
-
// rest:结构化类型按消息段收集;标量类型先按 text 段收集,再在 #match 里逐词切分转换。
|
|
204
366
|
if (parameter.rest) {
|
|
205
367
|
return `[...${parameter.name}:${isStructuredRestType(parameter.type) ? type : 'text'}]`;
|
|
206
368
|
}
|
|
@@ -208,13 +370,12 @@ function matcherPattern(segments, parameter) {
|
|
|
208
370
|
return `<${parameter.name}:${type}>`;
|
|
209
371
|
return parameter.defaultValue === undefined
|
|
210
372
|
? `[${parameter.name}:${type}]`
|
|
211
|
-
: `[${parameter.name}:${type}=${String(parameter.defaultValue)}]`;
|
|
373
|
+
: `[${parameter.name}:${type}=${typeof parameter.defaultValue === 'function' ? '<dynamic>' : String(parameter.defaultValue)}]`;
|
|
212
374
|
}).join(' ');
|
|
213
375
|
}
|
|
214
376
|
function matcherType(type) {
|
|
215
377
|
return type === 'string' ? 'word' : type;
|
|
216
378
|
}
|
|
217
|
-
/** rest 参数中按消息段收集(matcher 原生行为)的结构化类型。 */
|
|
218
379
|
function isStructuredRestType(type) {
|
|
219
380
|
return type === 'mention'
|
|
220
381
|
|| type === 'image'
|
|
@@ -224,16 +385,9 @@ function isStructuredRestType(type) {
|
|
|
224
385
|
|| type === 'dice'
|
|
225
386
|
|| type === 'rps';
|
|
226
387
|
}
|
|
227
|
-
/**
|
|
228
|
-
* 捕获所有参数的取值粒度由类型决定:
|
|
229
|
-
* - `text` / 结构化类型:逐消息段(matcher 原生结果);
|
|
230
|
-
* - `word` / `string`:逐词(空白切分);
|
|
231
|
-
* - `number` / `integer` / `float` / `boolean`:逐词切分后逐个转换,任一失败返回 undefined(不匹配)。
|
|
232
|
-
*/
|
|
233
388
|
function coerceRestValues(parameter, values) {
|
|
234
389
|
const type = parameter.type;
|
|
235
390
|
if (type === 'text' || isStructuredRestType(type)) {
|
|
236
|
-
// 逐消息段,保持 matcher 原生提取值(text 为 string,结构化类型可能是 number 等)。
|
|
237
391
|
return values;
|
|
238
392
|
}
|
|
239
393
|
const words = values.flatMap((value) => typeof value === 'string' ? value.split(/\s+/u).filter(Boolean) : []);
|
|
@@ -261,18 +415,20 @@ function coerceRestValues(parameter, values) {
|
|
|
261
415
|
function routeShape(segments) {
|
|
262
416
|
return segments.map((segment) => segment.startsWith('$') ? '$' : segment).join(' ');
|
|
263
417
|
}
|
|
264
|
-
function
|
|
418
|
+
function compareRecords(left, right) {
|
|
419
|
+
return left.name.localeCompare(right.name);
|
|
420
|
+
}
|
|
421
|
+
function compareRoutes(left, right) {
|
|
265
422
|
return dynamicWeight(left) - dynamicWeight(right)
|
|
266
423
|
|| staticSegmentCount(right.segments) - staticSegmentCount(left.segments)
|
|
267
424
|
|| right.segments.length - left.segments.length
|
|
268
|
-
|| right.name.length - left.name.length
|
|
269
|
-
|| left.name.localeCompare(right.name);
|
|
425
|
+
|| right.record.name.length - left.record.name.length
|
|
426
|
+
|| left.record.name.localeCompare(right.record.name);
|
|
270
427
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
if (!command.parameter)
|
|
428
|
+
function dynamicWeight(route) {
|
|
429
|
+
if (!route.record.parameter)
|
|
274
430
|
return 0;
|
|
275
|
-
return
|
|
431
|
+
return route.record.parameter.rest ? 2 : 1;
|
|
276
432
|
}
|
|
277
433
|
function staticSegmentCount(segments) {
|
|
278
434
|
return segments.filter((segment) => !segment.startsWith('$')).length;
|
|
@@ -333,12 +489,9 @@ function textArgs(segments) {
|
|
|
333
489
|
return splitCommand(segment.data.text);
|
|
334
490
|
}));
|
|
335
491
|
}
|
|
336
|
-
function toDescriptor({ slot: _slot, segments: _segments, parameter: _parameter,
|
|
492
|
+
function toDescriptor({ slot: _slot, segments: _segments, parameter: _parameter, ...descriptor }) {
|
|
337
493
|
return descriptor;
|
|
338
494
|
}
|
|
339
|
-
function duplicateCommand(name) {
|
|
340
|
-
return new Error(`Duplicate runtime Command: ${name}`);
|
|
341
|
-
}
|
|
342
495
|
export class CommandParameterValueError extends TypeError {
|
|
343
496
|
constructor(name, type, value) {
|
|
344
497
|
super(`Invalid value for Command parameter ${name}:${type}: ${value}`);
|
package/lib/definition.d.ts
CHANGED
|
@@ -3,6 +3,26 @@ 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
5
|
export type CommandParameterValue = string | number | boolean | ReadonlyArray<string | number | boolean> | Readonly<Record<string, unknown>> | null;
|
|
6
|
+
/**
|
|
7
|
+
* 可从运行时会话上下文动态解析的参数值。
|
|
8
|
+
*
|
|
9
|
+
* 静态值直接使用;函数值在命令派发时接收 {@link CommandSession},
|
|
10
|
+
* 返回最终的 {@link CommandParameterValue}。适用于 shortcut 预填
|
|
11
|
+
* 和 params.default。
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* defineCommand({
|
|
15
|
+
* params: {
|
|
16
|
+
* user_id: { type: 'string', default: (s) => s.sender?.id ?? '' },
|
|
17
|
+
* },
|
|
18
|
+
* shortcut: {
|
|
19
|
+
* '查看我的信息': { user_id: (s) => s.sender?.id ?? '' },
|
|
20
|
+
* },
|
|
21
|
+
* execute: ({ params }) => `profile:${params.user_id}`,
|
|
22
|
+
* })
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export type CommandDynamicValue = CommandParameterValue | ((session: CommandSession) => CommandParameterValue);
|
|
6
26
|
export declare const commandParameterTypes: ReadonlySet<CommandParameterType>;
|
|
7
27
|
/**
|
|
8
28
|
* Next.js 风格参数声明(`defineCommand({ params: ... })`)。
|
|
@@ -11,7 +31,7 @@ export declare const commandParameterTypes: ReadonlySet<CommandParameterType>;
|
|
|
11
31
|
*/
|
|
12
32
|
export interface CommandParamSchema {
|
|
13
33
|
readonly type: CommandParameterType;
|
|
14
|
-
readonly default?:
|
|
34
|
+
readonly default?: CommandDynamicValue;
|
|
15
35
|
readonly description?: string;
|
|
16
36
|
}
|
|
17
37
|
/** Minimal structural contract shared with canonical IM segments. */
|
|
@@ -24,7 +44,7 @@ export interface CommandSegment {
|
|
|
24
44
|
export interface CommandParameterDefinition {
|
|
25
45
|
readonly name: string;
|
|
26
46
|
readonly type: CommandParameterType;
|
|
27
|
-
readonly defaultValue?:
|
|
47
|
+
readonly defaultValue?: CommandDynamicValue;
|
|
28
48
|
/** `[[name]]` / `[[...name]]` 可选段;缺省按 `defaultValue === undefined` 推断。 */
|
|
29
49
|
readonly optional?: boolean;
|
|
30
50
|
/** `[...name]` / `[[...name]]` 捕获所有段,运行时值为 `string[]`。 */
|
|
@@ -59,7 +79,7 @@ export interface CommandConversation {
|
|
|
59
79
|
readonly kind: 'private' | 'group' | 'channel';
|
|
60
80
|
readonly id: string;
|
|
61
81
|
readonly parent?: Readonly<{
|
|
62
|
-
readonly kind: 'group' | 'channel';
|
|
82
|
+
readonly kind: 'private' | 'group' | 'channel';
|
|
63
83
|
readonly id: string;
|
|
64
84
|
}>;
|
|
65
85
|
readonly threadId?: string;
|
|
@@ -73,14 +93,37 @@ export interface CommandConversation {
|
|
|
73
93
|
export interface CommandMessage {
|
|
74
94
|
readonly conversation: CommandConversation;
|
|
75
95
|
readonly content: string;
|
|
76
|
-
/**
|
|
77
|
-
readonly sender?:
|
|
96
|
+
/** 发送者(结构化视图见 CommandContext.sender)。 */
|
|
97
|
+
readonly sender?: {
|
|
98
|
+
readonly id: string;
|
|
99
|
+
readonly name?: string;
|
|
100
|
+
readonly roles?: readonly string[];
|
|
101
|
+
};
|
|
78
102
|
readonly id?: string;
|
|
79
103
|
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
80
104
|
/** 若上游已结构化,优先采用。 */
|
|
81
105
|
readonly scene?: CommandScene;
|
|
82
|
-
|
|
83
|
-
|
|
106
|
+
$reply?(content: unknown): Promise<unknown>;
|
|
107
|
+
$replyFrom?(requester: string, content: unknown): Promise<unknown>;
|
|
108
|
+
/** 向同 Endpoint 的另一个通道发送消息(结构兼容 `Message.$sendTo`)。 */
|
|
109
|
+
$sendTo?(conversation: {
|
|
110
|
+
readonly kind: 'private' | 'group' | 'channel';
|
|
111
|
+
readonly id: string;
|
|
112
|
+
readonly parent?: Readonly<{
|
|
113
|
+
readonly kind: 'private' | 'group' | 'channel';
|
|
114
|
+
readonly id: string;
|
|
115
|
+
}>;
|
|
116
|
+
readonly threadId?: string;
|
|
117
|
+
}, content: unknown): Promise<unknown>;
|
|
118
|
+
/** 私信当前消息的发送者(结构兼容 `Message.$replyToPrivate`)。 */
|
|
119
|
+
$replyToPrivate?(content: unknown, from?: boolean | {
|
|
120
|
+
readonly kind: 'group' | 'channel';
|
|
121
|
+
readonly id: string;
|
|
122
|
+
}): Promise<unknown>;
|
|
123
|
+
/** 向指定群发送消息(结构兼容 `Message.$replyToGroup`)。 */
|
|
124
|
+
$replyToGroup?(groupId: string, content: unknown): Promise<unknown>;
|
|
125
|
+
/** 向指定频道发送消息(结构兼容 `Message.$replyToChannel`)。 */
|
|
126
|
+
$replyToChannel?(channelId: string, guildId: string, content: unknown, threadId?: string): Promise<unknown>;
|
|
84
127
|
}
|
|
85
128
|
/**
|
|
86
129
|
* IM 入站快捷字段。
|
|
@@ -120,6 +163,21 @@ export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput
|
|
|
120
163
|
* 类型 / 默认值 / 描述使用。静态命令可忽略本字段。
|
|
121
164
|
*/
|
|
122
165
|
readonly params?: Readonly<Record<string, CommandParamSchema>>;
|
|
166
|
+
/**
|
|
167
|
+
* 本地静态段别名(可多词,如 `'gh issue'`)。替换全部本地静态段后仍挂
|
|
168
|
+
* owner 前缀;不打破子插件命名空间。
|
|
169
|
+
*/
|
|
170
|
+
readonly alias?: readonly string[];
|
|
171
|
+
/**
|
|
172
|
+
* 内置 permit DSL(AND)。单项内逗号为 OR。
|
|
173
|
+
* 例:`adapter(icqq)`、`role(master)`、`group(123,456)`。
|
|
174
|
+
*/
|
|
175
|
+
readonly permit?: readonly string[];
|
|
176
|
+
/**
|
|
177
|
+
* 全局整句快捷方式:触发串(trim 后全文相等)→ 预填 params。
|
|
178
|
+
* 可打破 owner 命名空间。
|
|
179
|
+
*/
|
|
180
|
+
readonly shortcut?: Readonly<Record<string, Readonly<Record<string, CommandDynamicValue>>>>;
|
|
123
181
|
execute(context: CommandContext<TConfig, TInput>): TResult | Promise<TResult>;
|
|
124
182
|
}
|
|
125
183
|
declare module '@zhin.js/plugin-runtime' {
|
|
@@ -134,6 +192,11 @@ declare module '@zhin.js/plugin-runtime' {
|
|
|
134
192
|
export declare function defineCommand<TConfig = unknown, TResult = unknown, TInput extends CommandMessage = CommandMessage>(definition: Omit<CommandDefinition<TConfig, TResult, TInput>, '$feature' | '$parameter'>): Readonly<CommandDefinition<TConfig, TResult, TInput>>;
|
|
135
193
|
export declare function bindCommandParameter<TConfig, TResult, TInput extends CommandMessage>(definition: CommandDefinition<TConfig, TResult, TInput>, parameter: CommandParameterDefinition | undefined): Readonly<CommandDefinition<TConfig, TResult, TInput>>;
|
|
136
194
|
export declare function parseCommandDefinition(value: unknown): CommandDefinition;
|
|
195
|
+
/**
|
|
196
|
+
* 将动态参数值(可能包含函数)批量解析为静态值。
|
|
197
|
+
* 函数值接收从 `source`(通常是 IM Runtime `Message`)解析出的 {@link CommandSession}。
|
|
198
|
+
*/
|
|
199
|
+
export declare function resolveDynamicParams(params: Readonly<Record<string, CommandDynamicValue>>, source: unknown): Readonly<Record<string, CommandParameterValue>>;
|
|
137
200
|
export declare function createCommandContext(snapshot: RuntimeSnapshot, ownerId: PluginId, args: readonly string[], params?: Readonly<Record<string, CommandParameterValue>>, input?: unknown, segments?: readonly Readonly<CommandSegment>[]): CommandContext;
|
|
138
201
|
/**
|
|
139
202
|
* 从派发来源(通常是 Runtime `Message`)解析入站快捷字段。
|