@zhin.js/command 1.0.7 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/lib/command-index.d.ts +4 -0
- package/lib/command-index.js +204 -51
- package/lib/definition.d.ts +43 -5
- package/lib/definition.js +59 -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 +260 -49
- package/src/definition.ts +102 -22
- 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, } 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,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,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
|
}
|
|
@@ -214,7 +376,6 @@ function matcherPattern(segments, parameter) {
|
|
|
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
|
@@ -59,7 +59,7 @@ export interface CommandConversation {
|
|
|
59
59
|
readonly kind: 'private' | 'group' | 'channel';
|
|
60
60
|
readonly id: string;
|
|
61
61
|
readonly parent?: Readonly<{
|
|
62
|
-
readonly kind: 'group' | 'channel';
|
|
62
|
+
readonly kind: 'private' | 'group' | 'channel';
|
|
63
63
|
readonly id: string;
|
|
64
64
|
}>;
|
|
65
65
|
readonly threadId?: string;
|
|
@@ -73,14 +73,37 @@ export interface CommandConversation {
|
|
|
73
73
|
export interface CommandMessage {
|
|
74
74
|
readonly conversation: CommandConversation;
|
|
75
75
|
readonly content: string;
|
|
76
|
-
/**
|
|
77
|
-
readonly sender?:
|
|
76
|
+
/** 发送者(结构化视图见 CommandContext.sender)。 */
|
|
77
|
+
readonly sender?: {
|
|
78
|
+
readonly id: string;
|
|
79
|
+
readonly name?: string;
|
|
80
|
+
readonly roles?: readonly string[];
|
|
81
|
+
};
|
|
78
82
|
readonly id?: string;
|
|
79
83
|
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
80
84
|
/** 若上游已结构化,优先采用。 */
|
|
81
85
|
readonly scene?: CommandScene;
|
|
82
|
-
|
|
83
|
-
|
|
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>;
|
|
84
107
|
}
|
|
85
108
|
/**
|
|
86
109
|
* IM 入站快捷字段。
|
|
@@ -120,6 +143,21 @@ export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput
|
|
|
120
143
|
* 类型 / 默认值 / 描述使用。静态命令可忽略本字段。
|
|
121
144
|
*/
|
|
122
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>>>>;
|
|
123
161
|
execute(context: CommandContext<TConfig, TInput>): TResult | Promise<TResult>;
|
|
124
162
|
}
|
|
125
163
|
declare module '@zhin.js/plugin-runtime' {
|
package/lib/definition.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createCapabilityContext, } from '@zhin.js/feature-kit';
|
|
2
|
+
import { assertPermitSyntax } from '@zhin.js/permission';
|
|
2
3
|
const commandBrand = 'zhin.command/1';
|
|
3
4
|
export const commandParameterTypes = new Set([
|
|
4
5
|
'string',
|
|
@@ -35,8 +36,55 @@ export function defineCommand(definition) {
|
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
38
|
}
|
|
39
|
+
validateCommandAlias(definition.alias);
|
|
40
|
+
validateCommandPermit(definition.permit);
|
|
41
|
+
validateCommandShortcutShape(definition.shortcut);
|
|
38
42
|
return Object.freeze({ $feature: commandBrand, ...definition });
|
|
39
43
|
}
|
|
44
|
+
function validateCommandAlias(alias) {
|
|
45
|
+
if (alias === undefined)
|
|
46
|
+
return;
|
|
47
|
+
if (!Array.isArray(alias)) {
|
|
48
|
+
throw new TypeError('Command alias must be a readonly string[]');
|
|
49
|
+
}
|
|
50
|
+
for (const [index, entry] of alias.entries()) {
|
|
51
|
+
if (typeof entry !== 'string') {
|
|
52
|
+
throw new TypeError(`Command alias[${index}] must be a string`);
|
|
53
|
+
}
|
|
54
|
+
const tokens = entry.trim().split(/\s+/u).filter(Boolean);
|
|
55
|
+
if (tokens.length === 0) {
|
|
56
|
+
throw new TypeError(`Command alias[${index}] must contain at least one token`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function validateCommandPermit(permit) {
|
|
61
|
+
if (permit === undefined)
|
|
62
|
+
return;
|
|
63
|
+
if (!Array.isArray(permit)) {
|
|
64
|
+
throw new TypeError('Command permit must be a readonly string[]');
|
|
65
|
+
}
|
|
66
|
+
for (const [index, entry] of permit.entries()) {
|
|
67
|
+
if (typeof entry !== 'string') {
|
|
68
|
+
throw new TypeError(`Command permit[${index}] must be a string`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
assertPermitSyntax(permit);
|
|
72
|
+
}
|
|
73
|
+
function validateCommandShortcutShape(shortcut) {
|
|
74
|
+
if (shortcut === undefined)
|
|
75
|
+
return;
|
|
76
|
+
if (!shortcut || typeof shortcut !== 'object' || Array.isArray(shortcut)) {
|
|
77
|
+
throw new TypeError('Command shortcut must be a Record<string, Record<string, value>>');
|
|
78
|
+
}
|
|
79
|
+
for (const [trigger, params] of Object.entries(shortcut)) {
|
|
80
|
+
if (!trigger.trim()) {
|
|
81
|
+
throw new TypeError('Command shortcut keys must be non-empty after trim');
|
|
82
|
+
}
|
|
83
|
+
if (!params || typeof params !== 'object' || Array.isArray(params)) {
|
|
84
|
+
throw new TypeError(`Command shortcut[${JSON.stringify(trigger)}] must be a params Record`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
40
88
|
export function bindCommandParameter(definition, parameter) {
|
|
41
89
|
if (!parameter)
|
|
42
90
|
return definition;
|
|
@@ -75,9 +123,8 @@ export function resolveCommandSession(input) {
|
|
|
75
123
|
? input.metadata
|
|
76
124
|
: undefined;
|
|
77
125
|
const adapter = input.conversation.endpoint.adapter || undefined;
|
|
78
|
-
const endpoint =
|
|
79
|
-
? metadata.endpoint
|
|
80
|
-
: undefined;
|
|
126
|
+
const endpoint = input.endpointId
|
|
127
|
+
|| (typeof metadata?.endpoint === 'string' && metadata.endpoint ? metadata.endpoint : undefined);
|
|
81
128
|
const scene = resolveScene(input, metadata);
|
|
82
129
|
const sender = resolveSender(input, metadata);
|
|
83
130
|
return Object.freeze({
|
|
@@ -104,11 +151,8 @@ function resolveScene(input, metadata) {
|
|
|
104
151
|
});
|
|
105
152
|
}
|
|
106
153
|
const conversation = input.conversation;
|
|
107
|
-
const type =
|
|
108
|
-
|
|
109
|
-
|| conversation.kind;
|
|
110
|
-
const id = (typeof metadata?.channelId === 'string' && metadata.channelId)
|
|
111
|
-
|| conversation.id;
|
|
154
|
+
const type = conversation.kind;
|
|
155
|
+
const id = conversation.id;
|
|
112
156
|
if (!type || !id)
|
|
113
157
|
return undefined;
|
|
114
158
|
const name = firstString(metadata?.channelName, metadata?.group_name, metadata?.groupName, metadata?.sceneName);
|
|
@@ -123,24 +167,21 @@ function resolveSender(input, metadata) {
|
|
|
123
167
|
if (isCommandSender(structured)) {
|
|
124
168
|
return freezeSender(structured);
|
|
125
169
|
}
|
|
126
|
-
|
|
127
|
-
if (isCommandSender(input.sender)) {
|
|
128
|
-
return freezeSender(input.sender);
|
|
129
|
-
}
|
|
130
|
-
const id = typeof input.sender === 'string' && input.sender
|
|
131
|
-
? input.sender
|
|
132
|
-
: firstString(metadata?.user_id, metadata?.userId);
|
|
170
|
+
const id = input.sender?.id || firstString(metadata?.user_id, metadata?.userId);
|
|
133
171
|
if (!id)
|
|
134
172
|
return undefined;
|
|
135
|
-
const name = firstString(metadata?.nickname, metadata?.senderName, metadata?.name);
|
|
136
|
-
const role = resolveRoles(metadata);
|
|
173
|
+
const name = input.sender?.name || firstString(metadata?.nickname, metadata?.senderName, metadata?.name);
|
|
174
|
+
const role = resolveRoles(input, metadata);
|
|
137
175
|
return Object.freeze({
|
|
138
176
|
id,
|
|
139
177
|
...(name !== undefined ? { name } : {}),
|
|
140
178
|
role,
|
|
141
179
|
});
|
|
142
180
|
}
|
|
143
|
-
function resolveRoles(metadata) {
|
|
181
|
+
function resolveRoles(input, metadata) {
|
|
182
|
+
if (input.sender?.roles?.length) {
|
|
183
|
+
return Object.freeze([...input.sender.roles]);
|
|
184
|
+
}
|
|
144
185
|
const roles = [];
|
|
145
186
|
const push = (value) => {
|
|
146
187
|
if (typeof value !== 'string')
|
package/lib/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export * from './command-index.js';
|
|
2
2
|
export * from './definition.js';
|
|
3
|
+
export { assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, isPlatformPermit, parsePermitName, type ParsedPermit, type PermitKind, } from './permit.js';
|
|
3
4
|
export { CommandPathSyntaxError, commandFeatureId, default as commandFeature, } from './provider.js';
|
|
4
5
|
export { default } from './provider.js';
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export * from './command-index.js';
|
|
2
2
|
export * from './definition.js';
|
|
3
|
+
export { assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, isPlatformPermit, parsePermitName, } from './permit.js';
|
|
3
4
|
export { CommandPathSyntaxError, commandFeatureId, default as commandFeature, } from './provider.js';
|
|
4
5
|
export { default } from './provider.js';
|
package/lib/permit.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-export permit 解析/校验 from @zhin.js/permission (SSOT)。
|
|
3
|
+
* 保留 CommandSession 兼容签名供 command-index 过渡使用。
|
|
4
|
+
*/
|
|
5
|
+
export { type PermitKind, type ParsedPermit, parsePermitName, isBuiltinPermit, isPlatformPermit, assertPermitSyntax as assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, } from '@zhin.js/permission';
|
package/lib/permit.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-export permit 解析/校验 from @zhin.js/permission (SSOT)。
|
|
3
|
+
* 保留 CommandSession 兼容签名供 command-index 过渡使用。
|
|
4
|
+
*/
|
|
5
|
+
export { parsePermitName, isBuiltinPermit, isPlatformPermit, assertPermitSyntax as assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, } from '@zhin.js/permission';
|