@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/lib/provider.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { basename, join, parse, sep } from 'node:path';
|
|
2
|
-
import { featureId } from '@zhin.js/plugin-runtime';
|
|
2
|
+
import { featureId, isCapabilityLocalSegment } from '@zhin.js/plugin-runtime';
|
|
3
3
|
import { defineFeatureProvider, } from '@zhin.js/feature-kit';
|
|
4
4
|
import { CommandIndex } from './command-index.js';
|
|
5
5
|
import { bindCommandParameter, parseCommandDefinition, } from './definition.js';
|
|
@@ -38,7 +38,7 @@ async function* discoverCommandDirectory(context, directory, ancestors) {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
for (const entry of entries) {
|
|
41
|
-
if (entry.kind === 'directory' &&
|
|
41
|
+
if (entry.kind === 'directory' && isCapabilityLocalSegment(entry.name)) {
|
|
42
42
|
yield* discoverCommandDirectory(context, join(directory, entry.name), [...ancestors, entry.name]);
|
|
43
43
|
continue;
|
|
44
44
|
}
|
|
@@ -56,18 +56,20 @@ async function* discoverCommandDirectory(context, directory, ancestors) {
|
|
|
56
56
|
};
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
-
function isCommandSegment(value) {
|
|
60
|
-
return /^[a-z0-9][a-z0-9-]*$/.test(value);
|
|
61
|
-
}
|
|
62
59
|
const dynamicCommandFilePatterns = [
|
|
63
60
|
{ pattern: /^\[\[\.\.\.([a-zA-Z][a-zA-Z0-9]*)\]\]\.(?:tsx?|[cm]?js)$/, optional: true, rest: true },
|
|
64
61
|
{ pattern: /^\[\.\.\.([a-zA-Z][a-zA-Z0-9]*)\]\.(?:tsx?|[cm]?js)$/, optional: false, rest: true },
|
|
65
62
|
{ pattern: /^\[\[([a-zA-Z][a-zA-Z0-9]*)\]\]\.(?:tsx?|[cm]?js)$/, optional: true, rest: false },
|
|
66
63
|
{ pattern: /^\[([a-zA-Z][a-zA-Z0-9]*)\]\.(?:tsx?|[cm]?js)$/, optional: false, rest: false },
|
|
67
64
|
];
|
|
65
|
+
const commandModuleExtension = /\.(?:tsx?|[cm]?js)$/u;
|
|
68
66
|
function parseCommandFile(value) {
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
// 静态段:ASCII kebab(hello.ts)或 Unicode 名(赞我.ts);与 isCapabilityLocalSegment 对齐。
|
|
68
|
+
if (commandModuleExtension.test(value)) {
|
|
69
|
+
const localSegment = parse(value).name;
|
|
70
|
+
if (isCapabilityLocalSegment(localSegment)) {
|
|
71
|
+
return { localSegment };
|
|
72
|
+
}
|
|
71
73
|
}
|
|
72
74
|
for (const { pattern, optional, rest } of dynamicCommandFilePatterns) {
|
|
73
75
|
const match = pattern.exec(value);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/command",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"description": "Convention-based Command Feature for Zhin Plugin Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -18,8 +18,9 @@
|
|
|
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.8",
|
|
22
|
+
"@zhin.js/plugin-runtime": "1.1.5",
|
|
23
|
+
"@zhin.js/permission": "1.0.1"
|
|
23
24
|
},
|
|
24
25
|
"devDependencies": {
|
|
25
26
|
"@types/node": "^26.1.2",
|
package/src/command-index.ts
CHANGED
|
@@ -10,12 +10,15 @@ import type {
|
|
|
10
10
|
} from '@zhin.js/plugin-runtime';
|
|
11
11
|
import {
|
|
12
12
|
createCommandContext,
|
|
13
|
+
resolveCommandSession,
|
|
13
14
|
type CommandDefinition,
|
|
14
15
|
type CommandParameterDefinition,
|
|
15
16
|
type CommandParameterType,
|
|
16
17
|
type CommandParameterValue,
|
|
17
18
|
type CommandSegment,
|
|
18
19
|
} from './definition.js';
|
|
20
|
+
import { permissionHostToken, type PermissionHost } from '@zhin.js/permission';
|
|
21
|
+
import { toPermissionSubject } from '@zhin.js/permission';
|
|
19
22
|
|
|
20
23
|
export interface CommandParameterDescriptor extends CommandParameterDefinition {
|
|
21
24
|
readonly required: boolean;
|
|
@@ -26,6 +29,10 @@ export interface CommandDescriptor {
|
|
|
26
29
|
readonly description?: string;
|
|
27
30
|
readonly source: string;
|
|
28
31
|
readonly parameters: readonly CommandParameterDescriptor[];
|
|
32
|
+
readonly alias?: readonly string[];
|
|
33
|
+
readonly permit?: readonly string[];
|
|
34
|
+
/** shortcut 触发键列表(不含预填 params)。 */
|
|
35
|
+
readonly shortcut?: readonly string[];
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
export interface CommandDispatchResult {
|
|
@@ -39,7 +46,18 @@ interface CommandRecord extends CommandDescriptor {
|
|
|
39
46
|
readonly slot: Readonly<CapabilitySlot<CommandDefinition>>;
|
|
40
47
|
readonly segments: readonly string[];
|
|
41
48
|
readonly parameter?: CommandParameterDefinition;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface CommandRoute {
|
|
52
|
+
readonly record: CommandRecord;
|
|
53
|
+
readonly segments: readonly string[];
|
|
42
54
|
readonly matcher: SegmentMatcher;
|
|
55
|
+
readonly kind: 'primary' | 'alias';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface ShortcutEntry {
|
|
59
|
+
readonly record: CommandRecord;
|
|
60
|
+
readonly params: Readonly<Record<string, CommandParameterValue>>;
|
|
43
61
|
}
|
|
44
62
|
|
|
45
63
|
interface CommandMatch {
|
|
@@ -66,19 +84,39 @@ const segmentFields = {
|
|
|
66
84
|
export class CommandIndex {
|
|
67
85
|
readonly $projection = 'zhin.command-index/1' as const;
|
|
68
86
|
readonly #commands: readonly CommandRecord[];
|
|
87
|
+
readonly #routes: readonly CommandRoute[];
|
|
88
|
+
readonly #shortcuts: ReadonlyMap<string, ShortcutEntry>;
|
|
69
89
|
|
|
70
90
|
constructor(
|
|
71
91
|
slots: readonly Readonly<CapabilitySlot<CommandDefinition>>[],
|
|
72
92
|
private readonly snapshot: RuntimeSnapshot,
|
|
73
93
|
) {
|
|
74
94
|
const commands: CommandRecord[] = [];
|
|
75
|
-
const
|
|
76
|
-
const
|
|
95
|
+
const routes: CommandRoute[] = [];
|
|
96
|
+
const occupancy = new Map<string, string>();
|
|
97
|
+
const shortcuts = new Map<string, ShortcutEntry>();
|
|
98
|
+
|
|
99
|
+
const claim = (key: string, source: string): void => {
|
|
100
|
+
const existing = occupancy.get(key);
|
|
101
|
+
if (existing !== undefined) {
|
|
102
|
+
throw new Error(`Duplicate Command route "${key}" (${source} vs ${existing})`);
|
|
103
|
+
}
|
|
104
|
+
occupancy.set(key, source);
|
|
105
|
+
};
|
|
106
|
+
|
|
77
107
|
for (const slot of slots) {
|
|
78
|
-
const
|
|
108
|
+
const primarySegments = runtimeSegments(slot.owner, slot.localName);
|
|
79
109
|
const parameter = slot.definition.$parameter;
|
|
80
|
-
assertParameterSegment(
|
|
81
|
-
const name = displayName(
|
|
110
|
+
assertParameterSegment(primarySegments, parameter, slot.source);
|
|
111
|
+
const name = displayName(primarySegments, parameter);
|
|
112
|
+
const alias = normalizeAliasList(slot.definition.alias);
|
|
113
|
+
const permit = slot.definition.permit
|
|
114
|
+
? Object.freeze([...slot.definition.permit])
|
|
115
|
+
: undefined;
|
|
116
|
+
const shortcutKeys = slot.definition.shortcut
|
|
117
|
+
? Object.freeze(Object.keys(slot.definition.shortcut).map((key) => key.trim()))
|
|
118
|
+
: undefined;
|
|
119
|
+
|
|
82
120
|
const record: CommandRecord = Object.freeze({
|
|
83
121
|
name,
|
|
84
122
|
description: slot.definition.description,
|
|
@@ -87,23 +125,57 @@ export class CommandIndex {
|
|
|
87
125
|
...parameter,
|
|
88
126
|
required: isRequiredParameter(parameter),
|
|
89
127
|
}] : []),
|
|
128
|
+
...(alias ? { alias } : {}),
|
|
129
|
+
...(permit ? { permit } : {}),
|
|
130
|
+
...(shortcutKeys && shortcutKeys.length > 0 ? { shortcut: shortcutKeys } : {}),
|
|
90
131
|
slot,
|
|
91
|
-
segments: Object.freeze(
|
|
132
|
+
segments: Object.freeze(primarySegments),
|
|
92
133
|
parameter,
|
|
93
|
-
matcher: new SegmentMatcher(matcherPattern(segments, parameter), segmentFields),
|
|
94
134
|
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
135
|
+
|
|
136
|
+
claim(occupancyKey(primarySegments, parameter), slot.source);
|
|
137
|
+
routes.push({
|
|
138
|
+
record,
|
|
139
|
+
segments: primarySegments,
|
|
140
|
+
matcher: new SegmentMatcher(matcherPattern(primarySegments, parameter), segmentFields),
|
|
141
|
+
kind: 'primary',
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
if (alias) {
|
|
145
|
+
for (const entry of alias) {
|
|
146
|
+
const aliasSegments = aliasRuntimeSegments(slot.owner, entry, primarySegments);
|
|
147
|
+
assertParameterSegment(aliasSegments, parameter, `${slot.source} alias ${JSON.stringify(entry)}`);
|
|
148
|
+
claim(occupancyKey(aliasSegments, parameter), `${slot.source} alias ${JSON.stringify(entry)}`);
|
|
149
|
+
routes.push({
|
|
150
|
+
record,
|
|
151
|
+
segments: Object.freeze(aliasSegments),
|
|
152
|
+
matcher: new SegmentMatcher(matcherPattern(aliasSegments, parameter), segmentFields),
|
|
153
|
+
kind: 'alias',
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (slot.definition.shortcut) {
|
|
159
|
+
for (const [rawTrigger, prefill] of Object.entries(slot.definition.shortcut)) {
|
|
160
|
+
const trigger = rawTrigger.trim();
|
|
161
|
+
claim(trigger, `${slot.source} shortcut ${JSON.stringify(trigger)}`);
|
|
162
|
+
shortcuts.set(trigger, {
|
|
163
|
+
record,
|
|
164
|
+
params: Object.freeze(resolveShortcutParams(
|
|
165
|
+
slot.definition,
|
|
166
|
+
prefill,
|
|
167
|
+
`${slot.source} shortcut ${JSON.stringify(trigger)}`,
|
|
168
|
+
)),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
103
171
|
}
|
|
172
|
+
|
|
104
173
|
commands.push(record);
|
|
105
174
|
}
|
|
106
|
-
|
|
175
|
+
|
|
176
|
+
this.#commands = Object.freeze(commands.sort(compareRecords));
|
|
177
|
+
this.#routes = Object.freeze(routes.sort(compareRoutes));
|
|
178
|
+
this.#shortcuts = shortcuts;
|
|
107
179
|
}
|
|
108
180
|
|
|
109
181
|
list(): readonly CommandDescriptor[] {
|
|
@@ -125,6 +197,7 @@ export class CommandIndex {
|
|
|
125
197
|
this.#diagnoseParameter(name);
|
|
126
198
|
throw new Error(`Unknown Command: ${name}`);
|
|
127
199
|
}
|
|
200
|
+
// Host / 无 session:跳过 permit。
|
|
128
201
|
return match.command.slot.definition.execute(
|
|
129
202
|
createCommandContext(
|
|
130
203
|
this.snapshot,
|
|
@@ -139,8 +212,34 @@ export class CommandIndex {
|
|
|
139
212
|
input: CommandMatchInput,
|
|
140
213
|
source: unknown = undefined,
|
|
141
214
|
): Promise<CommandDispatchResult> {
|
|
215
|
+
const shortcut = this.#matchShortcut(input);
|
|
216
|
+
if (shortcut) {
|
|
217
|
+
if (!(await this.#permitAllows(shortcut.record, source))) {
|
|
218
|
+
return Object.freeze({ matched: false });
|
|
219
|
+
}
|
|
220
|
+
const value = await shortcut.record.slot.definition.execute(
|
|
221
|
+
createCommandContext(
|
|
222
|
+
this.snapshot,
|
|
223
|
+
shortcut.record.slot.owner,
|
|
224
|
+
Object.freeze([]),
|
|
225
|
+
shortcut.params,
|
|
226
|
+
source,
|
|
227
|
+
Object.freeze([]),
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
return Object.freeze({
|
|
231
|
+
matched: true,
|
|
232
|
+
command: shortcut.record.name,
|
|
233
|
+
owner: shortcut.record.slot.owner,
|
|
234
|
+
value,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
142
238
|
const match = this.#match(input, false);
|
|
143
239
|
if (!match) return Object.freeze({ matched: false });
|
|
240
|
+
if (!(await this.#permitAllows(match.command, source))) {
|
|
241
|
+
return Object.freeze({ matched: false });
|
|
242
|
+
}
|
|
144
243
|
const args = textArgs(match.remaining);
|
|
145
244
|
const value = await match.command.slot.definition.execute(
|
|
146
245
|
createCommandContext(
|
|
@@ -160,6 +259,35 @@ export class CommandIndex {
|
|
|
160
259
|
});
|
|
161
260
|
}
|
|
162
261
|
|
|
262
|
+
async #permitAllows(record: CommandRecord, source: unknown): Promise<boolean> {
|
|
263
|
+
const permits = record.permit;
|
|
264
|
+
if (!permits || permits.length === 0) return true;
|
|
265
|
+
if (!hasImSession(source)) return true;
|
|
266
|
+
const host = this.#resolveHost();
|
|
267
|
+
if (!host) return false;
|
|
268
|
+
const subject = toPermissionSubject(resolveCommandSession(source));
|
|
269
|
+
return host.checkAll(permits, subject);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#resolveHost(): PermissionHost | undefined {
|
|
273
|
+
try {
|
|
274
|
+
const resources = this.snapshot.resources.get(this.snapshot.root);
|
|
275
|
+
if (!resources) return undefined;
|
|
276
|
+
const host = resources.get(permissionHostToken.id);
|
|
277
|
+
return host && typeof (host as PermissionHost).check === 'function'
|
|
278
|
+
? host as PermissionHost
|
|
279
|
+
: undefined;
|
|
280
|
+
} catch {
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
#matchShortcut(input: CommandMatchInput): ShortcutEntry | undefined {
|
|
286
|
+
const text = exactMessageText(input);
|
|
287
|
+
if (text === undefined) return undefined;
|
|
288
|
+
return this.#shortcuts.get(text);
|
|
289
|
+
}
|
|
290
|
+
|
|
163
291
|
#match(input: CommandMatchInput, exact: boolean): CommandMatch | undefined {
|
|
164
292
|
const segments = normalizeSegments(
|
|
165
293
|
typeof input === 'string'
|
|
@@ -170,29 +298,26 @@ export class CommandIndex {
|
|
|
170
298
|
);
|
|
171
299
|
if (segments.length === 0) return undefined;
|
|
172
300
|
|
|
173
|
-
for (const
|
|
174
|
-
const result =
|
|
301
|
+
for (const route of this.#routes) {
|
|
302
|
+
const result = route.matcher.match(asMatcherSegments(segments));
|
|
175
303
|
if (!result || !hasCommandBoundary(result.remaining)) continue;
|
|
176
|
-
const parameter =
|
|
304
|
+
const parameter = route.record.parameter;
|
|
177
305
|
const params: Record<string, CommandParameterValue> = { ...result.params };
|
|
178
306
|
if (parameter?.rest) {
|
|
179
307
|
const raw = result.params[parameter.name];
|
|
180
308
|
const coerced = coerceRestValues(parameter, Array.isArray(raw) ? raw : []);
|
|
181
|
-
// 必需 `[...name]` 捕获所有:零元素视为不匹配;标量逐词转换失败同样不匹配。
|
|
182
309
|
if (!coerced || (isRequiredParameter(parameter) && coerced.length === 0)) continue;
|
|
183
310
|
params[parameter.name] = coerced;
|
|
184
311
|
}
|
|
185
312
|
const remaining = normalizeSegments(result.remaining);
|
|
186
313
|
if (exact && remaining.length > 0) continue;
|
|
187
|
-
// `[[name]]` 无 default 且未命中时,matcher 对 text 回退 ''、其他类型回退 null;
|
|
188
|
-
// 按契约(省略 default 时未匹配为 undefined)删除该键。
|
|
189
314
|
if (parameter && !parameter.rest && parameter.optional === true
|
|
190
315
|
&& parameter.defaultValue === undefined
|
|
191
316
|
&& (params[parameter.name] === '' || params[parameter.name] === null)) {
|
|
192
317
|
delete params[parameter.name];
|
|
193
318
|
}
|
|
194
319
|
return {
|
|
195
|
-
command,
|
|
320
|
+
command: route.record,
|
|
196
321
|
params: Object.freeze(params),
|
|
197
322
|
remaining,
|
|
198
323
|
};
|
|
@@ -202,12 +327,12 @@ export class CommandIndex {
|
|
|
202
327
|
|
|
203
328
|
#diagnoseParameter(name: string): void {
|
|
204
329
|
const words = splitCommand(name);
|
|
205
|
-
for (const
|
|
206
|
-
const parameter =
|
|
330
|
+
for (const route of this.#routes) {
|
|
331
|
+
const parameter = route.record.parameter;
|
|
207
332
|
if (!parameter || parameter.rest) continue;
|
|
208
|
-
const parameterIndex =
|
|
209
|
-
if (words.length !==
|
|
210
|
-
if (!
|
|
333
|
+
const parameterIndex = route.segments.findIndex((segment) => segment.startsWith('$'));
|
|
334
|
+
if (words.length !== route.segments.length) continue;
|
|
335
|
+
if (!route.segments.every((segment, index) =>
|
|
211
336
|
index === parameterIndex || segment === words[index])) continue;
|
|
212
337
|
const value = words[parameterIndex];
|
|
213
338
|
if (value === undefined || matchesParameter(parameter.type, value)) continue;
|
|
@@ -234,6 +359,103 @@ function runtimeSegments(owner: string, localName: string): string[] {
|
|
|
234
359
|
return [`${prefix}.${localSegments[0]}`, ...localSegments.slice(1)];
|
|
235
360
|
}
|
|
236
361
|
|
|
362
|
+
/**
|
|
363
|
+
* 用 alias 词序列替换全部本地静态段,再按 owner 规则重挂前缀;动态段保留。
|
|
364
|
+
*/
|
|
365
|
+
function aliasRuntimeSegments(
|
|
366
|
+
owner: string,
|
|
367
|
+
alias: string,
|
|
368
|
+
primarySegments: readonly string[],
|
|
369
|
+
): string[] {
|
|
370
|
+
const aliasTokens = alias.trim().split(/\s+/u).filter(Boolean);
|
|
371
|
+
const dynamicTail = primarySegments.filter((segment) => segment.startsWith('$'));
|
|
372
|
+
if (owner === 'root') return [...aliasTokens, ...dynamicTail];
|
|
373
|
+
const prefix = owner.slice('root/'.length).split('/').join('.');
|
|
374
|
+
return [`${prefix}.${aliasTokens[0]}`, ...aliasTokens.slice(1), ...dynamicTail];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function occupancyKey(
|
|
378
|
+
segments: readonly string[],
|
|
379
|
+
parameter: CommandParameterDefinition | undefined,
|
|
380
|
+
): string {
|
|
381
|
+
return parameter ? routeShape(segments) : segments.join(' ');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function normalizeAliasList(
|
|
385
|
+
alias: readonly string[] | undefined,
|
|
386
|
+
): readonly string[] | undefined {
|
|
387
|
+
if (!alias || alias.length === 0) return undefined;
|
|
388
|
+
return Object.freeze(alias.map((entry) => entry.trim().split(/\s+/u).filter(Boolean).join(' ')));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function resolveShortcutParams(
|
|
392
|
+
definition: CommandDefinition,
|
|
393
|
+
prefill: Readonly<Record<string, CommandParameterValue>>,
|
|
394
|
+
source: string,
|
|
395
|
+
): Record<string, CommandParameterValue> {
|
|
396
|
+
const allowed = new Set<string>();
|
|
397
|
+
const parameter = definition.$parameter;
|
|
398
|
+
if (parameter) allowed.add(parameter.name);
|
|
399
|
+
if (definition.params) {
|
|
400
|
+
for (const key of Object.keys(definition.params)) allowed.add(key);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
for (const key of Object.keys(prefill)) {
|
|
404
|
+
if (!allowed.has(key)) {
|
|
405
|
+
throw new TypeError(
|
|
406
|
+
`Invalid shortcut params for ${source}: unknown key ${JSON.stringify(key)}`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const result: Record<string, CommandParameterValue> = { ...prefill };
|
|
412
|
+
|
|
413
|
+
if (parameter) {
|
|
414
|
+
if (result[parameter.name] === undefined) {
|
|
415
|
+
if (parameter.defaultValue !== undefined) {
|
|
416
|
+
result[parameter.name] = parameter.defaultValue;
|
|
417
|
+
} else if (isRequiredParameter(parameter)) {
|
|
418
|
+
throw new TypeError(
|
|
419
|
+
`Invalid shortcut params for ${source}: missing required ${parameter.name}`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
} else if (allowed.size === 0 && Object.keys(prefill).length > 0) {
|
|
424
|
+
throw new TypeError(
|
|
425
|
+
`Invalid shortcut params for ${source}: command has no params declaration`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (definition.params) {
|
|
430
|
+
for (const [name, schema] of Object.entries(definition.params)) {
|
|
431
|
+
if (result[name] === undefined && schema.default !== undefined) {
|
|
432
|
+
result[name] = schema.default;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return result;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function hasImSession(source: unknown): boolean {
|
|
441
|
+
if (!source || typeof source !== 'object') return false;
|
|
442
|
+
const conversation = (source as { conversation?: unknown }).conversation;
|
|
443
|
+
return !!conversation && typeof conversation === 'object';
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function exactMessageText(input: CommandMatchInput): string | undefined {
|
|
447
|
+
if (typeof input === 'string') {
|
|
448
|
+
const trimmed = input.trim();
|
|
449
|
+
return trimmed || undefined;
|
|
450
|
+
}
|
|
451
|
+
// 仅纯单 text 段可作整句 shortcut;含 mention/image 等则不走 shortcut。
|
|
452
|
+
if (input.length !== 1) return undefined;
|
|
453
|
+
const only = input[0];
|
|
454
|
+
if (!only || only.type !== 'text' || typeof only.data.text !== 'string') return undefined;
|
|
455
|
+
const trimmed = only.data.text.trim();
|
|
456
|
+
return trimmed || undefined;
|
|
457
|
+
}
|
|
458
|
+
|
|
237
459
|
function assertParameterSegment(
|
|
238
460
|
segments: readonly string[],
|
|
239
461
|
parameter: CommandParameterDefinition | undefined,
|
|
@@ -280,7 +502,6 @@ function matcherPattern(
|
|
|
280
502
|
if (!segment.startsWith('$')) return segment;
|
|
281
503
|
if (!parameter) throw new Error(`Missing Command parameter metadata: ${segment}`);
|
|
282
504
|
const type = matcherType(parameter.type);
|
|
283
|
-
// rest:结构化类型按消息段收集;标量类型先按 text 段收集,再在 #match 里逐词切分转换。
|
|
284
505
|
if (parameter.rest) {
|
|
285
506
|
return `[...${parameter.name}:${isStructuredRestType(parameter.type) ? type : 'text'}]`;
|
|
286
507
|
}
|
|
@@ -295,7 +516,6 @@ function matcherType(type: CommandParameterType): string {
|
|
|
295
516
|
return type === 'string' ? 'word' : type;
|
|
296
517
|
}
|
|
297
518
|
|
|
298
|
-
/** rest 参数中按消息段收集(matcher 原生行为)的结构化类型。 */
|
|
299
519
|
function isStructuredRestType(type: CommandParameterType): boolean {
|
|
300
520
|
return type === 'mention'
|
|
301
521
|
|| type === 'image'
|
|
@@ -306,19 +526,12 @@ function isStructuredRestType(type: CommandParameterType): boolean {
|
|
|
306
526
|
|| type === 'rps';
|
|
307
527
|
}
|
|
308
528
|
|
|
309
|
-
/**
|
|
310
|
-
* 捕获所有参数的取值粒度由类型决定:
|
|
311
|
-
* - `text` / 结构化类型:逐消息段(matcher 原生结果);
|
|
312
|
-
* - `word` / `string`:逐词(空白切分);
|
|
313
|
-
* - `number` / `integer` / `float` / `boolean`:逐词切分后逐个转换,任一失败返回 undefined(不匹配)。
|
|
314
|
-
*/
|
|
315
529
|
function coerceRestValues(
|
|
316
530
|
parameter: CommandParameterDefinition,
|
|
317
531
|
values: readonly unknown[],
|
|
318
532
|
): readonly (string | number | boolean)[] | undefined {
|
|
319
533
|
const type = parameter.type;
|
|
320
534
|
if (type === 'text' || isStructuredRestType(type)) {
|
|
321
|
-
// 逐消息段,保持 matcher 原生提取值(text 为 string,结构化类型可能是 number 等)。
|
|
322
535
|
return values as readonly (string | number | boolean)[];
|
|
323
536
|
}
|
|
324
537
|
const words = values.flatMap((value) =>
|
|
@@ -346,18 +559,21 @@ function routeShape(segments: readonly string[]): string {
|
|
|
346
559
|
return segments.map((segment) => segment.startsWith('$') ? '$' : segment).join(' ');
|
|
347
560
|
}
|
|
348
561
|
|
|
349
|
-
function
|
|
562
|
+
function compareRecords(left: CommandRecord, right: CommandRecord): number {
|
|
563
|
+
return left.name.localeCompare(right.name);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function compareRoutes(left: CommandRoute, right: CommandRoute): number {
|
|
350
567
|
return dynamicWeight(left) - dynamicWeight(right)
|
|
351
568
|
|| staticSegmentCount(right.segments) - staticSegmentCount(left.segments)
|
|
352
569
|
|| right.segments.length - left.segments.length
|
|
353
|
-
|| right.name.length - left.name.length
|
|
354
|
-
|| left.name.localeCompare(right.name);
|
|
570
|
+
|| right.record.name.length - left.record.name.length
|
|
571
|
+
|| left.record.name.localeCompare(right.record.name);
|
|
355
572
|
}
|
|
356
573
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
return command.parameter.rest ? 2 : 1;
|
|
574
|
+
function dynamicWeight(route: CommandRoute): number {
|
|
575
|
+
if (!route.record.parameter) return 0;
|
|
576
|
+
return route.record.parameter.rest ? 2 : 1;
|
|
361
577
|
}
|
|
362
578
|
|
|
363
579
|
function staticSegmentCount(segments: readonly string[]): number {
|
|
@@ -435,16 +651,11 @@ function toDescriptor({
|
|
|
435
651
|
slot: _slot,
|
|
436
652
|
segments: _segments,
|
|
437
653
|
parameter: _parameter,
|
|
438
|
-
matcher: _matcher,
|
|
439
654
|
...descriptor
|
|
440
655
|
}: CommandRecord): CommandDescriptor {
|
|
441
656
|
return descriptor;
|
|
442
657
|
}
|
|
443
658
|
|
|
444
|
-
function duplicateCommand(name: string): Error {
|
|
445
|
-
return new Error(`Duplicate runtime Command: ${name}`);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
659
|
export class CommandParameterValueError extends TypeError {
|
|
449
660
|
constructor(name: string, type: CommandParameterType, value: string) {
|
|
450
661
|
super(`Invalid value for Command parameter ${name}:${type}: ${value}`);
|