@zhin.js/command 1.1.7 → 1.1.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 +54 -1
- package/lib/command-index.d.ts +21 -2
- package/lib/command-index.js +115 -29
- package/lib/command-menu.d.ts +31 -0
- package/lib/command-menu.js +81 -0
- package/lib/definition.d.ts +18 -1
- package/lib/definition.js +30 -5
- package/lib/execution-policy.d.ts +18 -0
- package/lib/execution-policy.js +27 -0
- package/lib/execution-runtime.d.ts +65 -0
- package/lib/execution-runtime.js +167 -0
- package/lib/index.d.ts +3 -2
- package/lib/index.js +3 -2
- package/lib/permission-explanation.d.ts +22 -0
- package/lib/permission-explanation.js +54 -0
- package/lib/permit.d.ts +8 -0
- package/lib/permit.js +11 -0
- package/lib/provider.d.ts +15 -0
- package/lib/provider.js +88 -4
- package/package.json +8 -4
- package/src/command-index.ts +132 -36
- package/src/command-menu.ts +112 -0
- package/src/definition.ts +52 -6
- package/src/execution-policy.ts +39 -0
- package/src/execution-runtime.ts +202 -0
- package/src/index.ts +5 -0
- package/src/permission-explanation.ts +58 -0
- package/src/permit.ts +14 -0
- package/src/provider.ts +124 -4
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/command",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.9",
|
|
4
4
|
"description": "Convention-based Command Feature for Zhin Plugin Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
7
7
|
"types": "./lib/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
+
"./internal/execution": {
|
|
10
|
+
"types": "./lib/execution-runtime.d.ts",
|
|
11
|
+
"import": "./lib/execution-runtime.js"
|
|
12
|
+
},
|
|
9
13
|
".": {
|
|
10
14
|
"types": "./lib/index.d.ts",
|
|
11
15
|
"import": "./lib/index.js"
|
|
@@ -17,10 +21,10 @@
|
|
|
17
21
|
],
|
|
18
22
|
"dependencies": {
|
|
19
23
|
"segment-matcher": "^1.0.5",
|
|
20
|
-
"@zhin.js/feature-kit": "1.1.5",
|
|
21
|
-
"@zhin.js/plugin-runtime": "1.1.14",
|
|
22
24
|
"@zhin.js/interaction": "1.1.2",
|
|
23
|
-
"@zhin.js/
|
|
25
|
+
"@zhin.js/feature-kit": "1.1.7",
|
|
26
|
+
"@zhin.js/permission": "1.1.7",
|
|
27
|
+
"@zhin.js/plugin-runtime": "1.1.15"
|
|
24
28
|
},
|
|
25
29
|
"devDependencies": {
|
|
26
30
|
"@types/node": "^26.1.2",
|
package/src/command-index.ts
CHANGED
|
@@ -3,19 +3,25 @@ import {
|
|
|
3
3
|
TypeMatcherRegistry,
|
|
4
4
|
type MessageSegment,
|
|
5
5
|
} from 'segment-matcher';
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
import {
|
|
7
|
+
htmlRendererToken,
|
|
8
|
+
type CapabilityId,
|
|
9
|
+
type CapabilitySlot,
|
|
10
|
+
type PluginId,
|
|
11
|
+
type RuntimeSnapshot,
|
|
10
12
|
} from '@zhin.js/plugin-runtime';
|
|
11
13
|
import type { UserInteractionFactory } from '@zhin.js/interaction';
|
|
12
|
-
import { bindOperationInput
|
|
14
|
+
import { bindOperationInput } from '@zhin.js/feature-kit';
|
|
13
15
|
import {
|
|
14
16
|
permissionHostToken,
|
|
15
|
-
toPermissionSubject,
|
|
16
17
|
type PermissionHost,
|
|
17
18
|
} from '@zhin.js/permission';
|
|
19
|
+
import { commandAdapterMatches, toCommandPermissionSubject } from './permission-explanation.js';
|
|
18
20
|
import { commandUsage, commandHelp, parseCommandArguments, requestsCommandHelp, type CommandValidationIssue } from './arguments.js';
|
|
21
|
+
import { MENU_PAGE_SIZE, readMenuAppearance, renderCommandMenu, type CommandMenuSegment } from './command-menu.js';
|
|
22
|
+
import { admitCommandExecution, admitDirectCommandExecution } from './execution-runtime.js';
|
|
23
|
+
import { CommandExecutionLimitedError, normalizeExecutionPolicy, type CommandExecutionLimit } from './execution-policy.js';
|
|
24
|
+
export { CommandExecutionLimitedError } from './execution-policy.js';
|
|
19
25
|
import {
|
|
20
26
|
createCommandContext,
|
|
21
27
|
resolveCommandSession,
|
|
@@ -32,6 +38,8 @@ export interface CommandParameterDescriptor extends CommandParameterDefinition {
|
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
export interface CommandDescriptor {
|
|
41
|
+
readonly id: CapabilityId;
|
|
42
|
+
readonly owner: PluginId;
|
|
35
43
|
readonly name: string;
|
|
36
44
|
readonly description?: string;
|
|
37
45
|
readonly adapter?: string;
|
|
@@ -41,18 +49,34 @@ export interface CommandDescriptor {
|
|
|
41
49
|
readonly options: NonNullable<CommandDefinition['options']>;
|
|
42
50
|
readonly alias?: readonly string[];
|
|
43
51
|
readonly permit?: readonly string[];
|
|
52
|
+
readonly execution?: CommandDefinition['execution'];
|
|
44
53
|
/** shortcut 触发键列表(不含预填 params)。 */
|
|
45
54
|
readonly shortcut?: readonly string[];
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
export interface CommandDispatchResult {
|
|
49
58
|
readonly matched: boolean;
|
|
50
|
-
readonly status: 'unmatched' | 'invalid' | 'denied' | 'executed';
|
|
59
|
+
readonly status: 'unmatched' | 'invalid' | 'denied' | 'limited' | 'executed';
|
|
60
|
+
readonly limit?: CommandExecutionLimit;
|
|
51
61
|
readonly issues?: readonly CommandValidationIssue[];
|
|
52
62
|
readonly help?: string;
|
|
53
63
|
readonly command?: string;
|
|
54
64
|
readonly owner?: PluginId;
|
|
55
65
|
readonly value?: unknown;
|
|
66
|
+
readonly menu?: CommandMenuNavigation;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Semantic menu navigation; the IM composition layer supplies platform controls. */
|
|
70
|
+
export interface CommandMenuNavigation {
|
|
71
|
+
readonly keyword: string;
|
|
72
|
+
readonly pluginKey?: string;
|
|
73
|
+
readonly page: number;
|
|
74
|
+
readonly pages: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface CommandMenuResult {
|
|
78
|
+
readonly value: string | CommandMenuSegment;
|
|
79
|
+
readonly menu?: CommandMenuNavigation;
|
|
56
80
|
}
|
|
57
81
|
|
|
58
82
|
interface CommandRecord extends CommandDescriptor {
|
|
@@ -129,7 +153,14 @@ export class CommandIndex {
|
|
|
129
153
|
for (const key of keys) occupancy.set(key, source);
|
|
130
154
|
};
|
|
131
155
|
|
|
156
|
+
const disabledByOwner = new Map<PluginId, readonly string[]>();
|
|
132
157
|
for (const slot of slots) {
|
|
158
|
+
let disabled = disabledByOwner.get(slot.owner);
|
|
159
|
+
if (!disabled) {
|
|
160
|
+
disabled = disabledCommands(this.snapshot, slot.owner);
|
|
161
|
+
disabledByOwner.set(slot.owner, disabled);
|
|
162
|
+
}
|
|
163
|
+
if (isDisabledCommand(slot.localName, disabled)) continue;
|
|
133
164
|
const namespace = commandNamespace(this.snapshot, slot.owner);
|
|
134
165
|
const primarySegments = [...namespace, ...runtimeSegments(slot.localName)];
|
|
135
166
|
const parameter = slot.definition.$parameter;
|
|
@@ -143,9 +174,13 @@ export class CommandIndex {
|
|
|
143
174
|
? Object.freeze(Object.keys(slot.definition.shortcut).map((key) => key.trim()))
|
|
144
175
|
: undefined;
|
|
145
176
|
|
|
177
|
+
const execution = normalizeExecutionPolicy(slot.definition.execution);
|
|
146
178
|
const record: CommandRecord = Object.freeze({
|
|
179
|
+
id: slot.id,
|
|
180
|
+
owner: slot.owner,
|
|
147
181
|
name,
|
|
148
182
|
description: slot.definition.description,
|
|
183
|
+
...(execution ? { execution } : {}),
|
|
149
184
|
...(slot.definition.adapter ? { adapter: slot.definition.adapter } : {}),
|
|
150
185
|
source: slot.source,
|
|
151
186
|
usage: commandUsage(name, slot.definition),
|
|
@@ -240,8 +275,15 @@ export class CommandIndex {
|
|
|
240
275
|
const supplied = [...match.remaining, ...(args.length > 0 ? [{ type: 'text', data: { text: args.join(' ') } }] : [])];
|
|
241
276
|
const parsed = parseCommandArguments(definition, supplied, resolveDynamicParams(match.params, undefined), {});
|
|
242
277
|
if (parsed.issues.length > 0) throw new CommandArgumentValidationError(parsed.issues, commandHelp(match.command.name, definition));
|
|
243
|
-
|
|
244
|
-
|
|
278
|
+
// No inbound source: execution policy applies per command, never as a blanket refusal.
|
|
279
|
+
const admission = admitDirectCommandExecution(match.command.slot.id, match.command.execution, operationSnapshot);
|
|
280
|
+
if (admission.limit) throw new CommandExecutionLimitedError(admission.limit);
|
|
281
|
+
try {
|
|
282
|
+
return await definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner,
|
|
283
|
+
textArgs(parsed.remaining), parsed.params, undefined, parsed.remaining, undefined, definition.adapter, parsed.options));
|
|
284
|
+
} finally {
|
|
285
|
+
admission.release();
|
|
286
|
+
}
|
|
245
287
|
}
|
|
246
288
|
|
|
247
289
|
async dispatch(
|
|
@@ -252,14 +294,14 @@ export class CommandIndex {
|
|
|
252
294
|
operationSnapshot: RuntimeSnapshot = this.snapshot,
|
|
253
295
|
): Promise<CommandDispatchResult> {
|
|
254
296
|
if (this.#menu) {
|
|
255
|
-
const
|
|
256
|
-
if (
|
|
297
|
+
const menuResult = this.#dispatchMenu(input, commandPrefix, operationSnapshot);
|
|
298
|
+
if (menuResult !== undefined) {
|
|
257
299
|
return Object.freeze({
|
|
258
300
|
matched: true,
|
|
259
301
|
status: 'executed',
|
|
260
302
|
command: this.#menu.keyword,
|
|
261
303
|
owner: operationSnapshot.root,
|
|
262
|
-
|
|
304
|
+
...menuResult,
|
|
263
305
|
});
|
|
264
306
|
}
|
|
265
307
|
}
|
|
@@ -289,6 +331,8 @@ export class CommandIndex {
|
|
|
289
331
|
const parsed = parseCommandArguments(definition, match.remaining, resolveDynamicParams(match.params, authorSource), resolveCommandSession(authorSource));
|
|
290
332
|
if (parsed.issues.length > 0) return Object.freeze({ matched: true, status: 'invalid', command: match.command.name,
|
|
291
333
|
owner: match.command.slot.owner, issues: parsed.issues, help });
|
|
334
|
+
const limit = admitCommandExecution(authorSource, match.command.slot.id, match.command.slot.owner, match.command.execution, operationSnapshot);
|
|
335
|
+
if (limit) return Object.freeze({ matched: true, status: 'limited', command: match.command.name, owner: match.command.slot.owner, limit });
|
|
292
336
|
const value = await definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner,
|
|
293
337
|
textArgs(parsed.remaining), parsed.params, authorSource, parsed.remaining, interactionFactory?.(authorSource), definition.adapter, parsed.options));
|
|
294
338
|
return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value });
|
|
@@ -315,9 +359,7 @@ export class CommandIndex {
|
|
|
315
359
|
if (!source || typeof source !== 'object') return false;
|
|
316
360
|
const host = this.#resolveHost(operationSnapshot);
|
|
317
361
|
if (!host) return false;
|
|
318
|
-
const
|
|
319
|
-
const session = resolveCommandSession(source);
|
|
320
|
-
const subject = { ...projected, ...session, ...(session.sender ? { sender: { ...projected.sender, ...session.sender } } : {}) };
|
|
362
|
+
const subject = toCommandPermissionSubject(source);
|
|
321
363
|
if (!subject.sender?.id && !subject.scene?.id && !subject.adapter && !subject.endpoint) return false;
|
|
322
364
|
return host.checkAll(permits, subject);
|
|
323
365
|
}
|
|
@@ -404,50 +446,68 @@ export class CommandIndex {
|
|
|
404
446
|
// 内置菜单命令
|
|
405
447
|
// ==========================================================================
|
|
406
448
|
|
|
407
|
-
#dispatchMenu(input: CommandMatchInput, commandPrefix
|
|
449
|
+
#dispatchMenu(input: CommandMatchInput, commandPrefix: string, snapshot: RuntimeSnapshot): CommandMenuResult | undefined {
|
|
408
450
|
const text = typeof input === 'string' ? input.trim() : exactMessageText(input);
|
|
409
451
|
if (text === undefined) return undefined;
|
|
410
452
|
const menu = this.#menu;
|
|
411
453
|
if (!menu) return undefined;
|
|
412
454
|
const keyword = menu.keyword;
|
|
413
|
-
if (text === keyword) return this.#buildMenu(undefined, commandPrefix, keyword);
|
|
455
|
+
if (text === keyword) return this.#buildMenu(undefined, commandPrefix, keyword, snapshot);
|
|
414
456
|
if (text.startsWith(keyword + ' ')) {
|
|
415
457
|
const key = text.slice(keyword.length + 1).trim();
|
|
416
|
-
|
|
417
|
-
|
|
458
|
+
const args = key.split(/\s+/u);
|
|
459
|
+
const flag = args.indexOf('--page');
|
|
460
|
+
if (flag !== -1) {
|
|
461
|
+
const pageText = args[flag + 1] ?? '';
|
|
462
|
+
if (flag > 1 || args.length !== flag + 2 || !/^[1-9]\d*$/u.test(pageText)
|
|
463
|
+
|| !Number.isSafeInteger(Number(pageText))) {
|
|
464
|
+
return { value: `页码必须是正整数。用法:${commandPrefix}${keyword} [插件名] --page <页码>` };
|
|
465
|
+
}
|
|
466
|
+
return this.#buildMenu(flag === 1 ? args[0] : undefined, commandPrefix, keyword, snapshot, Number(pageText));
|
|
467
|
+
}
|
|
468
|
+
if (key) return this.#buildMenu(key, commandPrefix, keyword, snapshot);
|
|
469
|
+
return this.#buildMenu(undefined, commandPrefix, keyword, snapshot);
|
|
418
470
|
}
|
|
419
471
|
return undefined;
|
|
420
472
|
}
|
|
421
473
|
|
|
422
|
-
#buildMenu(pluginKey: string | undefined, commandPrefix: string, keyword: string):
|
|
423
|
-
const root =
|
|
474
|
+
#buildMenu(pluginKey: string | undefined, commandPrefix: string, keyword: string, snapshot: RuntimeSnapshot, page = 1): CommandMenuResult {
|
|
475
|
+
const root = snapshot.root;
|
|
424
476
|
const targetId = pluginKey
|
|
425
477
|
? `${root}/${pluginKey.split('.').join('/')}` as PluginId
|
|
426
478
|
: root;
|
|
427
479
|
|
|
428
|
-
const node =
|
|
429
|
-
if (!node) return `未找到插件: ${pluginKey}
|
|
480
|
+
const node = snapshot.tree.get(targetId);
|
|
481
|
+
if (!node) return { value: `未找到插件: ${pluginKey}` };
|
|
430
482
|
|
|
431
483
|
const directCommands = this.#commands.filter((cmd) => cmd.slot.owner === targetId);
|
|
432
484
|
const children = node.children
|
|
433
|
-
.map((childId) =>
|
|
485
|
+
.map((childId) => snapshot.tree.get(childId))
|
|
434
486
|
.filter((child): child is NonNullable<typeof child> => !!child);
|
|
435
487
|
|
|
488
|
+
const pages = Math.max(1, Math.ceil((directCommands.length + children.length) / MENU_PAGE_SIZE));
|
|
489
|
+
const invocation = `${commandPrefix}${keyword}${pluginKey ? ` ${pluginKey}` : ''}`;
|
|
490
|
+
if (page > pages) return { value: `页码超出范围:共 ${pages} 页。请使用「${invocation} --page <页码>」。` };
|
|
491
|
+
const offset = (page - 1) * MENU_PAGE_SIZE;
|
|
492
|
+
const pageCommands = directCommands.slice(offset, offset + MENU_PAGE_SIZE);
|
|
493
|
+
const pageChildren = children.slice(Math.max(0, offset - directCommands.length), Math.max(0, offset + MENU_PAGE_SIZE - directCommands.length));
|
|
494
|
+
|
|
436
495
|
const displayKey = pluginKey ?? node.instanceKey;
|
|
437
496
|
const lines: string[] = [`=== ${displayKey} 指令菜单 ===`];
|
|
497
|
+
if (pages > 1) lines.push(`第 ${page}/${pages} 页 · ${directCommands.length} 条指令 · ${children.length} 个子插件`);
|
|
438
498
|
|
|
439
|
-
if (
|
|
499
|
+
if (pageCommands.length > 0) {
|
|
440
500
|
lines.push('');
|
|
441
|
-
for (const cmd of
|
|
501
|
+
for (const cmd of pageCommands) {
|
|
442
502
|
const desc = cmd.description ? ` - ${cmd.description}` : '';
|
|
443
503
|
lines.push(` ${commandPrefix}${cmd.name}${desc}`);
|
|
444
504
|
}
|
|
445
505
|
}
|
|
446
506
|
|
|
447
|
-
if (
|
|
507
|
+
if (pageChildren.length > 0) {
|
|
448
508
|
lines.push('');
|
|
449
509
|
lines.push('子插件:');
|
|
450
|
-
for (const child of
|
|
510
|
+
for (const child of pageChildren) {
|
|
451
511
|
const childKey = pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey;
|
|
452
512
|
const label = child.metadata?.displayName ?? child.instanceKey;
|
|
453
513
|
lines.push(` ${childKey} (${label})`);
|
|
@@ -459,12 +519,27 @@ export class CommandIndex {
|
|
|
459
519
|
lines.push('(暂无指令和子插件)');
|
|
460
520
|
}
|
|
461
521
|
|
|
462
|
-
|
|
522
|
+
const hints: string[] = [];
|
|
523
|
+
if (children.length > 0) hints.push(`提示:使用「${commandPrefix}${keyword} <插件名>」查看子插件的指令`);
|
|
524
|
+
if (page > 1) hints.push(`上一页:${invocation} --page ${page - 1}`);
|
|
525
|
+
if (page < pages) hints.push(`下一页:${invocation} --page ${page + 1}`);
|
|
526
|
+
const hint = hints.length ? hints.join('\n') : undefined;
|
|
527
|
+
if (hint) {
|
|
463
528
|
lines.push('');
|
|
464
|
-
lines.push(
|
|
529
|
+
lines.push(hint);
|
|
465
530
|
}
|
|
466
531
|
|
|
467
|
-
|
|
532
|
+
const text = lines.join('\n');
|
|
533
|
+
const menu = Object.freeze({ keyword, ...(pluginKey ? { pluginKey } : {}), page, pages });
|
|
534
|
+
if (!snapshot.resources.get(root)?.has(htmlRendererToken.id)) return { value: text, menu };
|
|
535
|
+
const config = snapshot.config.get(root) as Record<string, unknown> | undefined;
|
|
536
|
+
return { menu, value: renderCommandMenu(displayKey,
|
|
537
|
+
pageCommands.map(cmd => ({ name: `${commandPrefix}${cmd.name}`, description: cmd.description })),
|
|
538
|
+
pageChildren.map(child => ({
|
|
539
|
+
name: pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey,
|
|
540
|
+
description: child.metadata?.displayName ?? child.instanceKey,
|
|
541
|
+
})), hint, text, { page, pages, commandTotal: directCommands.length, pluginTotal: children.length,
|
|
542
|
+
appearance: readMenuAppearance(config?.menuStyle) }) };
|
|
468
543
|
}
|
|
469
544
|
}
|
|
470
545
|
|
|
@@ -497,6 +572,27 @@ function commandNamespace(snapshot: RuntimeSnapshot, owner: PluginId): readonly
|
|
|
497
572
|
return Object.freeze([...segments]);
|
|
498
573
|
}
|
|
499
574
|
|
|
575
|
+
/**
|
|
576
|
+
* 插件实例配置 disabledCommands 列出的指令不进入路由表。条目写插件本地的静态路径
|
|
577
|
+
* (不含 commandNamespace 与参数段,如 `fanyi`、`netease login`);写上级路径会连同
|
|
578
|
+
* 其子命令一起禁用。未匹配任何指令的条目被忽略。
|
|
579
|
+
*/
|
|
580
|
+
function disabledCommands(snapshot: RuntimeSnapshot, owner: PluginId): readonly string[] {
|
|
581
|
+
const config = snapshot.config.get(owner) as Readonly<Record<string, unknown>> | undefined;
|
|
582
|
+
const value = config?.disabledCommands;
|
|
583
|
+
if (value === undefined) return Object.freeze([]);
|
|
584
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
|
|
585
|
+
throw new TypeError(`Invalid disabledCommands for ${owner}: expected an array of command names`);
|
|
586
|
+
}
|
|
587
|
+
return Object.freeze(value.map((entry: string) => splitCommand(entry).join(' ')).filter(Boolean));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function isDisabledCommand(localName: string, disabled: readonly string[]): boolean {
|
|
591
|
+
if (disabled.length === 0) return false;
|
|
592
|
+
const path = runtimeSegments(localName).filter((segment) => !segment.startsWith('$')).join(' ');
|
|
593
|
+
return disabled.some((entry) => path === entry || path.startsWith(`${entry} `));
|
|
594
|
+
}
|
|
595
|
+
|
|
500
596
|
function isCommandNamespaceSegment(value: string): boolean {
|
|
501
597
|
return /^[\p{L}\p{N}][\p{L}\p{N}_-]*$/u.test(value);
|
|
502
598
|
}
|
|
@@ -636,7 +732,11 @@ function exactMessageText(input: CommandMatchInput): string | undefined {
|
|
|
636
732
|
return trimmed || undefined;
|
|
637
733
|
}
|
|
638
734
|
|
|
639
|
-
|
|
735
|
+
/**
|
|
736
|
+
* Route-shape rule for a convention command: at most one dynamic segment, and it is the final one.
|
|
737
|
+
* Shared by the runtime index and {@link bindCommandSource}.
|
|
738
|
+
*/
|
|
739
|
+
export function assertParameterSegment(
|
|
640
740
|
segments: readonly string[],
|
|
641
741
|
parameter: CommandParameterDefinition | undefined,
|
|
642
742
|
source: string,
|
|
@@ -839,10 +939,6 @@ function toDescriptor({
|
|
|
839
939
|
return descriptor;
|
|
840
940
|
}
|
|
841
941
|
|
|
842
|
-
function commandAdapterMatches(record: CommandRecord, source: unknown): boolean {
|
|
843
|
-
return !record.adapter || operationClientAdapter(source) === record.adapter;
|
|
844
|
-
}
|
|
845
|
-
|
|
846
942
|
export class CommandParameterValueError extends TypeError {
|
|
847
943
|
constructor(name: string, type: CommandParameterType, value: string) {
|
|
848
944
|
super(`Invalid value for Command parameter ${name}:${type}: ${value}`);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
interface MenuEntry {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
readonly description?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
interface MenuAppearance {
|
|
7
|
+
readonly title?: string;
|
|
8
|
+
readonly subtitle?: string;
|
|
9
|
+
readonly accentColor?: string;
|
|
10
|
+
readonly backgroundImage?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CommandMenuSegment {
|
|
14
|
+
readonly type: 'html';
|
|
15
|
+
readonly data: {
|
|
16
|
+
readonly html: string;
|
|
17
|
+
readonly text: string;
|
|
18
|
+
readonly width: number;
|
|
19
|
+
readonly fileName: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const MENU_PAGE_SIZE = 30;
|
|
24
|
+
|
|
25
|
+
const escape = (value: string) => value.replace(/[&<>"']/gu, char => ({
|
|
26
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
27
|
+
})[char] ?? char);
|
|
28
|
+
|
|
29
|
+
/** Configuration is text/data, never an interpolated CSS or HTML fragment. */
|
|
30
|
+
export function readMenuAppearance(value: unknown): MenuAppearance {
|
|
31
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
32
|
+
const input = value as Record<string, unknown>;
|
|
33
|
+
const result: { title?: string; subtitle?: string; accentColor?: string; backgroundImage?: string } = {};
|
|
34
|
+
if (typeof input.title === 'string') result.title = input.title.slice(0, 100);
|
|
35
|
+
if (typeof input.subtitle === 'string') result.subtitle = input.subtitle.slice(0, 160);
|
|
36
|
+
if (typeof input.accentColor === 'string' && /^#[\da-f]{6}$/iu.test(input.accentColor)) result.accentColor = input.accentColor;
|
|
37
|
+
if (typeof input.backgroundImage === 'string') {
|
|
38
|
+
try {
|
|
39
|
+
const url = new URL(input.backgroundImage);
|
|
40
|
+
if (url.protocol === 'https:' || url.protocol === 'http:'
|
|
41
|
+
|| /^data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/u.test(url.href)) result.backgroundImage = url.href;
|
|
42
|
+
} catch { /* Invalid optional artwork falls back to the bundled illustration. */ }
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Self-contained artwork: no network requests or extra asset/package dependency.
|
|
48
|
+
const landscape = `<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1400" viewBox="0 0 1080 1400">
|
|
49
|
+
<defs><linearGradient id="sky" x2=".8" y2="1"><stop stop-color="#162635"/><stop offset=".48" stop-color="#344b57"/><stop offset="1" stop-color="#70837c"/></linearGradient><radialGradient id="glow"><stop stop-color="#eddbc0" stop-opacity=".3"/><stop offset="1" stop-color="#d6d7c3" stop-opacity="0"/></radialGradient><linearGradient id="hill" x2="0" y2="1"><stop stop-color="#b2c2b5" stop-opacity=".34"/><stop offset="1" stop-color="#213e45" stop-opacity=".1"/></linearGradient></defs>
|
|
50
|
+
<path fill="url(#sky)" d="M0 0h1080v1400H0z"/><circle cx="820" cy="184" r="290" fill="url(#glow)"/><circle cx="823" cy="167" r="92" fill="#e3dfc9"/><circle cx="802" cy="146" r="10" fill="#cbcdbb" opacity=".3"/><circle cx="854" cy="194" r="19" fill="#cbcdbb" opacity=".3"/>
|
|
51
|
+
<g fill="#e7d5ac" opacity=".7"><circle cx="113" cy="72" r="1.5"/><circle cx="360" cy="104" r="1"/><circle cx="624" cy="55" r="2"/><circle cx="713" cy="265" r="1.2"/><circle cx="982" cy="332" r="2"/><circle cx="463" cy="43" r="1.4"/><circle cx="540" cy="202" r="1.4"/><circle cx="96" cy="410" r="2"/></g>
|
|
52
|
+
<g fill="none" stroke="#abac8a" stroke-width="2" opacity=".6"><path d="M1080 0Q959 77 902 267M1012 45Q916 92 866 106M984 78Q1016 164 1004 229M946 146Q866 156 838 204M917 217Q947 257 945 311"/></g>
|
|
53
|
+
<g fill="#d1bc93" opacity=".7"><ellipse cx="916" cy="84" rx="8" ry="21" transform="rotate(60 916 84)"/><ellipse cx="975" cy="115" rx="8" ry="20" transform="rotate(-25 975 115)"/><ellipse cx="876" cy="172" rx="8" ry="20" transform="rotate(58 876 172)"/><ellipse cx="1006" cy="208" rx="8" ry="20"/><ellipse cx="932" cy="270" rx="8" ry="20" transform="rotate(-25 932 270)"/></g>
|
|
54
|
+
<path d="M0 615 171 375 293 517 441 315 619 564 796 417 1080 651V1400H0Z" fill="url(#hill)"/><path d="m0 867 263-305 194 263 173-167 216 142 234-183v783H0Z" fill="#243e48" opacity=".46"/><path d="M0 1155q260-270 540-160t540-125v530H0Z" fill="#17343d" opacity=".65"/>
|
|
55
|
+
<g fill="none" stroke="#b1c1b2" stroke-opacity=".14"><path d="M0 455q210-85 424-12t656-4M0 474q210-85 424-12t656-4M0 1095q310-83 600-9t480-30M0 1124q310-83 600-9t480-30"/></g></svg>`;
|
|
56
|
+
|
|
57
|
+
const symbols = [
|
|
58
|
+
'<path d="m16 3 4 9 9 4-9 4-4 9-4-9-9-4 9-4Z"/><path d="m16 10 2 6-2 6-2-6Z"/>',
|
|
59
|
+
'<circle cx="16" cy="16" r="11"/><path d="m10 19 6-12 6 12-6-3Z"/>',
|
|
60
|
+
'<path d="m16 3 12 7v12l-12 7-12-7V10Z"/><path d="m4 10 12 7 12-7M16 17v12"/>',
|
|
61
|
+
'<circle cx="16" cy="16" r="5"/><path d="m16 2 3 7 8-3-3 8 7 2-7 3 3 8-8-3-3 7-3-7-8 3 3-8-7-3 7-2-3-8 8 3Z"/>',
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
function icon(name: string, plugin = false): string {
|
|
65
|
+
const hash = [...name].reduce((value, char) => (value + (char.codePointAt(0) ?? 0)) % symbols.length, 0);
|
|
66
|
+
return `<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.15">${plugin ? symbols[2] : symbols[hash]}</svg>`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Presentation only: the unified outbound pipeline owns image rendering and fallback. */
|
|
70
|
+
export function renderCommandMenu(
|
|
71
|
+
title: string,
|
|
72
|
+
commands: readonly MenuEntry[],
|
|
73
|
+
children: readonly MenuEntry[],
|
|
74
|
+
hint: string | undefined,
|
|
75
|
+
text: string,
|
|
76
|
+
options: { readonly page?: number; readonly pages?: number; readonly commandTotal?: number; readonly pluginTotal?: number; readonly appearance?: MenuAppearance } = {},
|
|
77
|
+
): CommandMenuSegment {
|
|
78
|
+
const appearance = options.appearance ?? {};
|
|
79
|
+
const background = appearance.backgroundImage ?? `data:image/svg+xml,${encodeURIComponent(landscape)}`;
|
|
80
|
+
// CSS string escaping additionally prevents an artwork URL from closing <style>.
|
|
81
|
+
const cssUrl = JSON.stringify(background).replace(/</gu, '\\3c ').replace(/>/gu, '\\3e ');
|
|
82
|
+
const entries = (items: readonly MenuEntry[], plugin: boolean) => items.map(item =>
|
|
83
|
+
`<div class="entry"><div class="icon">${icon(item.name, plugin)}</div><div class="copy"><div class="name">${escape(item.name)}</div>${item.description
|
|
84
|
+
? `<div class="description">${escape(item.description)}</div>` : ''}</div></div>`).join('');
|
|
85
|
+
const section = (label: string, items: readonly MenuEntry[], total: number, plugin = false) => items.length === 0 ? ''
|
|
86
|
+
: `<section><div class="section-head"><h2>${label}</h2><span>${items.length} / ${total}</span></div><div class="grid">${entries(items, plugin)}</div></section>`;
|
|
87
|
+
const page = options.page ?? 1;
|
|
88
|
+
const pages = options.pages ?? 1;
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
type: 'html',
|
|
92
|
+
data: {
|
|
93
|
+
width: 1080, fileName: 'command-menu.png', text,
|
|
94
|
+
html: `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><style>
|
|
95
|
+
*{box-sizing:border-box}body{margin:0;background:#253b46;color:#f4f0e6;font-family:"PingFang SC","Microsoft YaHei",sans-serif;font-size:18px;line-height:1.5;--accent:${appearance.accentColor ?? '#e7c993'}}
|
|
96
|
+
main{padding:42px 28px 24px;background-image:linear-gradient(180deg,#0c202a0a,#0c202a55),url(${cssUrl});background-size:cover;background-position:center top}
|
|
97
|
+
header{min-height:210px;padding:10px 22px 30px;position:relative}.eyebrow{font-size:12px;letter-spacing:5px;color:#f4e4c4;margin:0 0 17px}
|
|
98
|
+
h1{margin:0 0 10px;font-family:"Songti SC","Noto Serif CJK SC",serif;font-size:55px;font-weight:800;letter-spacing:3px;text-shadow:0 2px 16px #0c202a66;max-width:760px;line-height:1.2}
|
|
99
|
+
.subtitle{font-size:17px;color:#e0e6e0;letter-spacing:2px;max-width:700px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.summary{margin:18px 0 0;display:flex;align-items:center;gap:16px;font-size:13px;letter-spacing:1px;color:#e0dfcf}.summary i{width:22px;height:1px;background:var(--accent)}
|
|
100
|
+
section{margin:0 0 20px;padding:5px;background:#dbe9e516;border:1px solid #f0e2c722;border-radius:19px;box-shadow:0 8px 25px #08192312}.section-head{padding:14px 18px;display:flex;justify-content:space-between;align-items:center;background:#142a34db;border-radius:13px 13px 0 0}
|
|
101
|
+
h2{margin:0;font-size:20px;font-weight:700;letter-spacing:1px;color:var(--accent)}.section-head span{font-size:12px;letter-spacing:2px;color:#b7c7c5}
|
|
102
|
+
.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));background:#20363fdb;border-radius:0 0 13px 13px;overflow:hidden}
|
|
103
|
+
.entry{display:flex;align-items:center;gap:12px;padding:19px 17px;min-width:0;min-height:106px;border-top:1px solid #e7e6d614;border-right:1px solid #e7e6d614;background:linear-gradient(120deg,#ffffff08,#ffffff00)}.entry:nth-child(3n){border-right:0}
|
|
104
|
+
.icon{width:35px;height:35px;flex:0 0 35px;color:var(--accent);filter:drop-shadow(0 1px 6px #d9ba7c22)}.icon svg{display:block;width:100%;height:100%}.copy{min-width:0}.name{font-size:18px;font-weight:700;color:var(--accent)}.description{margin:4px 0 0;color:#e1e8e5;font-size:15px;line-height:1.45}
|
|
105
|
+
.name,.description,h1{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere;white-space:pre-wrap}
|
|
106
|
+
footer{margin:6px 5px 0;padding:15px 18px;display:flex;align-items:center;justify-content:space-between;gap:22px;background:#142a34da;border:1px solid #f0e2c71c;border-radius:13px}.hint{font-size:14px;color:#cfdbd7;white-space:pre-wrap;overflow-wrap:anywhere}.page{font-size:20px;color:var(--accent);white-space:nowrap;font-family:Georgia,serif}.signature{margin:16px 0 0;text-align:center;font-size:10px;letter-spacing:4px;color:#c4d0c899}.empty{margin:0 0 20px;padding:28px;background:#142a34da;border-radius:16px;color:#dbe4de}
|
|
107
|
+
</style></head><body><main><header><div class="eyebrow">ZHIN / COMMAND ATLAS</div><h1>${escape(appearance.title || `${title} · 指令指南`)}</h1><div class="subtitle">${escape(appearance.subtitle ?? '每一个灵感,都有回应。')}</div><div class="summary"><i></i>${options.commandTotal ?? commands.length} 条指令<span>·</span>${options.pluginTotal ?? children.length} 个子插件<span>·</span>${escape(title)}</div></header>
|
|
108
|
+
${section('指令索引', commands, options.commandTotal ?? commands.length)}${section('插件导航', children, options.pluginTotal ?? children.length, true)}${commands.length || children.length ? '' : '<p class="empty">暂无指令和子插件</p>'}
|
|
109
|
+
<footer><div class="hint">${escape(hint ?? '查看参数与用法:指令名 --help')}</div><div class="page">${String(page).padStart(2, '0')} / ${String(pages).padStart(2, '0')}</div></footer><div class="signature">ZHIN · MAKE EVERY MESSAGE MATTER</div></main></body></html>`,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
package/src/definition.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { PluginId, RuntimeSnapshot } from '@zhin.js/plugin-runtime';
|
|
|
6
6
|
import {
|
|
7
7
|
createCapabilityContext,
|
|
8
8
|
readOperationClient,
|
|
9
|
+
readOperationInputSignal,
|
|
9
10
|
type AdapterClient,
|
|
10
11
|
type RegisteredAdapterName,
|
|
11
12
|
type CapabilityContext,
|
|
@@ -13,6 +14,8 @@ import {
|
|
|
13
14
|
import { assertPermitSyntax } from '@zhin.js/permission';
|
|
14
15
|
import { validateArgumentSchemas } from './arguments.js';
|
|
15
16
|
import type { UserInteraction } from '@zhin.js/interaction';
|
|
17
|
+
import { normalizeExecutionPolicy, type CommandExecutionPolicy } from './execution-policy.js';
|
|
18
|
+
export type { CommandExecutionPolicy, CommandExecutionLimit } from './execution-policy.js';
|
|
16
19
|
|
|
17
20
|
const commandBrand = 'zhin.command/1' as const;
|
|
18
21
|
|
|
@@ -309,6 +312,8 @@ export interface CommandDefinition<
|
|
|
309
312
|
* 例:`adapter(icqq)`、`role(master)`、`group(123,456)`。
|
|
310
313
|
*/
|
|
311
314
|
readonly permit?: readonly string[];
|
|
315
|
+
/** Optional full-operation cooldown, concurrency and one-shot progress feedback. */
|
|
316
|
+
readonly execution?: Readonly<CommandExecutionPolicy>;
|
|
312
317
|
/**
|
|
313
318
|
* 全局整句快捷方式:触发串(trim 后全文相等)→ 预填 params。
|
|
314
319
|
* 与普通路由一样是全局用户输入,冲突会在 generation 构建时拒绝。
|
|
@@ -355,6 +360,19 @@ type TypedCommandContext<TConfig, TInput extends CommandMessage, TAdapter extend
|
|
|
355
360
|
readonly options: InferCommandOptions<O>;
|
|
356
361
|
};
|
|
357
362
|
|
|
363
|
+
/**
|
|
364
|
+
* `defineCommand.withConfig<Config>()` 返回的定义函数:config 类型已固定,params / options / 返回值
|
|
365
|
+
* 仍从定义推断。
|
|
366
|
+
* @public
|
|
367
|
+
*/
|
|
368
|
+
export type CommandDefiner<TConfig, TInput extends CommandMessage = CommandMessage> = <
|
|
369
|
+
TResult = unknown,
|
|
370
|
+
const P extends CommandParams = {},
|
|
371
|
+
const O extends CommandOptions = {},
|
|
372
|
+
>(
|
|
373
|
+
definition: CommandAuthoringDefinition<TConfig, TResult, TInput, P, O>,
|
|
374
|
+
) => Readonly<CommandDefinition<TConfig, TResult, TInput, string | undefined>>;
|
|
375
|
+
|
|
358
376
|
export function defineCommand<
|
|
359
377
|
TConfig = unknown,
|
|
360
378
|
TResult = unknown,
|
|
@@ -383,11 +401,34 @@ export function defineCommand<
|
|
|
383
401
|
validateCommandAlias(definition.alias);
|
|
384
402
|
validateCommandPermit(definition.permit);
|
|
385
403
|
validateCommandShortcutShape(definition.shortcut);
|
|
386
|
-
|
|
404
|
+
const execution = normalizeExecutionPolicy(definition.execution);
|
|
405
|
+
return Object.freeze({ $feature: commandBrand, ...definition, ...(execution ? { execution } : {}) }) as Readonly<
|
|
387
406
|
CommandDefinition<TConfig, TResult, TInput, string | undefined>
|
|
388
407
|
>;
|
|
389
408
|
}
|
|
390
409
|
|
|
410
|
+
/**
|
|
411
|
+
* 先固定插件 config 类型,再定义命令;params / options / 返回值仍从定义推断。
|
|
412
|
+
*
|
|
413
|
+
* ```ts
|
|
414
|
+
* export default defineCommand.withConfig<Config>()({
|
|
415
|
+
* params: { count: { type: 'integer' } },
|
|
416
|
+
* execute({ params, config }) { params.count; config.prefix },
|
|
417
|
+
* })
|
|
418
|
+
* ```
|
|
419
|
+
*
|
|
420
|
+
* `defineCommand<Config>({...})` 仍然可用,但 TypeScript 不支持部分推断,显式写了
|
|
421
|
+
* 类型参数后 params 会退化成 `Record<string, CommandParameterValue>`。
|
|
422
|
+
* 这里是独立的属性而不是 `defineCommand()` 重载:`defineCommand` 常被当作值传给
|
|
423
|
+
* `createEndpointCommands(spec, defineCommand)` 这类工厂,重载会让它推断错签名。
|
|
424
|
+
*/
|
|
425
|
+
defineCommand.withConfig = function withConfig<
|
|
426
|
+
TConfig = unknown,
|
|
427
|
+
TInput extends CommandMessage = CommandMessage,
|
|
428
|
+
>(): CommandDefiner<TConfig, TInput> {
|
|
429
|
+
return (definition) => defineCommand(definition);
|
|
430
|
+
};
|
|
431
|
+
|
|
391
432
|
function validateAdapterName(adapter: string | undefined): void {
|
|
392
433
|
if (adapter !== undefined && (typeof adapter !== 'string' || adapter.trim() === '')) {
|
|
393
434
|
throw new TypeError('Command adapter must be a non-empty string');
|
|
@@ -464,6 +505,7 @@ export function parseCommandDefinition(value: unknown): CommandDefinition {
|
|
|
464
505
|
throw new TypeError('Command module must default-export defineCommand(...)');
|
|
465
506
|
}
|
|
466
507
|
validateAdapterName(definition.adapter);
|
|
508
|
+
normalizeExecutionPolicy(definition.execution);
|
|
467
509
|
return definition as CommandDefinition;
|
|
468
510
|
}
|
|
469
511
|
|
|
@@ -496,7 +538,7 @@ export function createCommandContext(
|
|
|
496
538
|
adapter?: string,
|
|
497
539
|
options: Readonly<Record<string, CommandParameterValue>> = Object.freeze({}),
|
|
498
540
|
): CommandContext {
|
|
499
|
-
const context = createCapabilityContext(snapshot, ownerId);
|
|
541
|
+
const context = createCapabilityContext(snapshot, ownerId, undefined, readOperationInputSignal(input));
|
|
500
542
|
const session = resolveCommandSession(input);
|
|
501
543
|
const parameterValues: Record<string, CommandParameterValue> = { __proto__: null, ...params };
|
|
502
544
|
const optionValues: Record<string, CommandParameterValue> = { __proto__: null, ...options };
|
|
@@ -534,7 +576,7 @@ export function resolveCommandSession(input: unknown): CommandSession {
|
|
|
534
576
|
|| (typeof metadata?.endpoint === 'string' && metadata.endpoint ? metadata.endpoint : undefined);
|
|
535
577
|
|
|
536
578
|
const scene = resolveScene(input, metadata);
|
|
537
|
-
const sender =
|
|
579
|
+
const sender = resolveCommandSender(input, metadata);
|
|
538
580
|
|
|
539
581
|
return Object.freeze({
|
|
540
582
|
...(adapter ? { adapter } : {}),
|
|
@@ -583,8 +625,12 @@ function resolveScene(
|
|
|
583
625
|
});
|
|
584
626
|
}
|
|
585
627
|
|
|
586
|
-
|
|
587
|
-
|
|
628
|
+
/**
|
|
629
|
+
* Pure canonical sender projection shared with read-only command condition simulation.
|
|
630
|
+
* @internal Command dispatcher projection helper.
|
|
631
|
+
*/
|
|
632
|
+
export function resolveCommandSender(
|
|
633
|
+
input: Pick<CommandMessage, 'sender'>,
|
|
588
634
|
metadata: Readonly<Record<string, unknown>> | undefined,
|
|
589
635
|
): CommandSender | undefined {
|
|
590
636
|
const structured = (input as { readonly from?: unknown }).from;
|
|
@@ -606,7 +652,7 @@ function resolveSender(
|
|
|
606
652
|
}
|
|
607
653
|
|
|
608
654
|
function resolveRoles(
|
|
609
|
-
input: CommandMessage,
|
|
655
|
+
input: Pick<CommandMessage, 'sender'>,
|
|
610
656
|
metadata: Readonly<Record<string, unknown>> | undefined,
|
|
611
657
|
): readonly string[] {
|
|
612
658
|
if (input.sender?.roles?.length) {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Optional per-capability budget, isolated by sender and the complete conversation identity. */
|
|
2
|
+
export interface CommandExecutionPolicy {
|
|
3
|
+
readonly cooldownMs?: number;
|
|
4
|
+
readonly maxConcurrent?: number;
|
|
5
|
+
/** One best-effort progress message while business logic is waiting; disabled by default. */
|
|
6
|
+
readonly progressAfterMs?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface CommandExecutionLimit {
|
|
10
|
+
readonly reason: 'cooldown' | 'busy' | 'capacity' | 'operation_unavailable' | 'identity_unavailable' | 'service_unavailable';
|
|
11
|
+
/** Exact remaining cooldown; a busy operation has no predictable completion time. */
|
|
12
|
+
readonly retryAfterMs?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class CommandExecutionLimitedError extends Error {
|
|
16
|
+
readonly limit: CommandExecutionLimit;
|
|
17
|
+
constructor(limit: CommandExecutionLimit) {
|
|
18
|
+
super(`Command execution limited: ${limit.reason}`);
|
|
19
|
+
this.name = 'CommandExecutionLimitedError';
|
|
20
|
+
this.limit = limit;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @internal Validate at declaration and projection, without coercion or silent clamping. */
|
|
25
|
+
export function normalizeExecutionPolicy(value: unknown): Readonly<CommandExecutionPolicy> | undefined {
|
|
26
|
+
if (value === undefined) return undefined;
|
|
27
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('Command execution must be an object');
|
|
28
|
+
const limits = { cooldownMs: 86_400_000, maxConcurrent: 1000, progressAfterMs: 3_600_000 };
|
|
29
|
+
const result: Record<string, number> = {};
|
|
30
|
+
for (const [key, item] of Object.entries(value)) {
|
|
31
|
+
if (!Object.hasOwn(limits, key) || !Number.isSafeInteger(item) || (item as number) <= 0
|
|
32
|
+
|| (item as number) > limits[key as keyof typeof limits]) {
|
|
33
|
+
throw new TypeError(`Command execution.${key} must be a positive bounded integer`);
|
|
34
|
+
}
|
|
35
|
+
result[key] = item as number;
|
|
36
|
+
}
|
|
37
|
+
if (Object.keys(result).length === 0) throw new TypeError('Command execution requires at least one policy');
|
|
38
|
+
return Object.freeze(result);
|
|
39
|
+
}
|