@zhin.js/command 1.1.6 → 1.1.8
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 +16 -2
- package/lib/command-index.js +74 -27
- package/lib/command-menu.d.ts +31 -0
- package/lib/command-menu.js +81 -0
- package/lib/definition.d.ts +9 -1
- package/lib/definition.js +12 -5
- package/lib/execution-policy.d.ts +18 -0
- package/lib/execution-policy.js +27 -0
- package/lib/execution-runtime.d.ts +50 -0
- package/lib/execution-runtime.js +134 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/permission-explanation.d.ts +22 -0
- package/lib/permission-explanation.js +54 -0
- package/package.json +10 -7
- package/src/command-index.ts +92 -33
- package/src/command-menu.ts +112 -0
- package/src/definition.ts +17 -6
- package/src/execution-policy.ts +39 -0
- package/src/execution-runtime.ts +165 -0
- package/src/index.ts +1 -0
- package/src/permission-explanation.ts +58 -0
|
@@ -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 构建时拒绝。
|
|
@@ -383,7 +388,8 @@ export function defineCommand<
|
|
|
383
388
|
validateCommandAlias(definition.alias);
|
|
384
389
|
validateCommandPermit(definition.permit);
|
|
385
390
|
validateCommandShortcutShape(definition.shortcut);
|
|
386
|
-
|
|
391
|
+
const execution = normalizeExecutionPolicy(definition.execution);
|
|
392
|
+
return Object.freeze({ $feature: commandBrand, ...definition, ...(execution ? { execution } : {}) }) as Readonly<
|
|
387
393
|
CommandDefinition<TConfig, TResult, TInput, string | undefined>
|
|
388
394
|
>;
|
|
389
395
|
}
|
|
@@ -464,6 +470,7 @@ export function parseCommandDefinition(value: unknown): CommandDefinition {
|
|
|
464
470
|
throw new TypeError('Command module must default-export defineCommand(...)');
|
|
465
471
|
}
|
|
466
472
|
validateAdapterName(definition.adapter);
|
|
473
|
+
normalizeExecutionPolicy(definition.execution);
|
|
467
474
|
return definition as CommandDefinition;
|
|
468
475
|
}
|
|
469
476
|
|
|
@@ -496,7 +503,7 @@ export function createCommandContext(
|
|
|
496
503
|
adapter?: string,
|
|
497
504
|
options: Readonly<Record<string, CommandParameterValue>> = Object.freeze({}),
|
|
498
505
|
): CommandContext {
|
|
499
|
-
const context = createCapabilityContext(snapshot, ownerId);
|
|
506
|
+
const context = createCapabilityContext(snapshot, ownerId, undefined, readOperationInputSignal(input));
|
|
500
507
|
const session = resolveCommandSession(input);
|
|
501
508
|
const parameterValues: Record<string, CommandParameterValue> = { __proto__: null, ...params };
|
|
502
509
|
const optionValues: Record<string, CommandParameterValue> = { __proto__: null, ...options };
|
|
@@ -534,7 +541,7 @@ export function resolveCommandSession(input: unknown): CommandSession {
|
|
|
534
541
|
|| (typeof metadata?.endpoint === 'string' && metadata.endpoint ? metadata.endpoint : undefined);
|
|
535
542
|
|
|
536
543
|
const scene = resolveScene(input, metadata);
|
|
537
|
-
const sender =
|
|
544
|
+
const sender = resolveCommandSender(input, metadata);
|
|
538
545
|
|
|
539
546
|
return Object.freeze({
|
|
540
547
|
...(adapter ? { adapter } : {}),
|
|
@@ -583,8 +590,12 @@ function resolveScene(
|
|
|
583
590
|
});
|
|
584
591
|
}
|
|
585
592
|
|
|
586
|
-
|
|
587
|
-
|
|
593
|
+
/**
|
|
594
|
+
* Pure canonical sender projection shared with read-only command condition simulation.
|
|
595
|
+
* @internal Command dispatcher projection helper.
|
|
596
|
+
*/
|
|
597
|
+
export function resolveCommandSender(
|
|
598
|
+
input: Pick<CommandMessage, 'sender'>,
|
|
588
599
|
metadata: Readonly<Record<string, unknown>> | undefined,
|
|
589
600
|
): CommandSender | undefined {
|
|
590
601
|
const structured = (input as { readonly from?: unknown }).from;
|
|
@@ -606,7 +617,7 @@ function resolveSender(
|
|
|
606
617
|
}
|
|
607
618
|
|
|
608
619
|
function resolveRoles(
|
|
609
|
-
input: CommandMessage,
|
|
620
|
+
input: Pick<CommandMessage, 'sender'>,
|
|
610
621
|
metadata: Readonly<Record<string, unknown>> | undefined,
|
|
611
622
|
): readonly string[] {
|
|
612
623
|
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
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/** Internal composition contract. Not part of the command authoring facade. */
|
|
2
|
+
import { createToken, type PluginId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
|
|
3
|
+
import type { CommandExecutionLimit, CommandExecutionPolicy } from './execution-policy.js';
|
|
4
|
+
|
|
5
|
+
interface ConversationIdentity {
|
|
6
|
+
readonly endpoint: { readonly adapter: string; readonly id: string };
|
|
7
|
+
readonly kind: string;
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly parent?: { readonly kind: string; readonly id: string };
|
|
10
|
+
readonly threadId?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface AdmissionReader {
|
|
14
|
+
admit(source: unknown, capability: string, owner: PluginId, policy: CommandExecutionPolicy, snapshot: RuntimeSnapshot): CommandExecutionLimit | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** @internal Admission-only Root resource; no bind or finish authority is published. */
|
|
18
|
+
export const commandExecutionAdmissionToken = createToken<AdmissionReader>('zhin.command.execution-admission');
|
|
19
|
+
|
|
20
|
+
/** @internal Held only by Core's actual leased operation. */
|
|
21
|
+
export interface CommandOperationController {
|
|
22
|
+
bind(view: object, owner: PluginId): void;
|
|
23
|
+
/** Core registers actual started delivery promises, never a caller-supplied completion callback. */
|
|
24
|
+
trackSend(send: Promise<unknown>): void;
|
|
25
|
+
/** Stop pending feedback and drain already-started feedback before the final reply. */
|
|
26
|
+
stopProgress(): Promise<void>;
|
|
27
|
+
/** Called only by the real outer operation's finally, after all final sends settle. */
|
|
28
|
+
finish(): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Entry { active: number; cooldownUntil: number }
|
|
32
|
+
interface Operation {
|
|
33
|
+
snapshot?: RuntimeSnapshot;
|
|
34
|
+
key?: readonly string[];
|
|
35
|
+
active: boolean;
|
|
36
|
+
progressStopped: boolean;
|
|
37
|
+
admitted: boolean;
|
|
38
|
+
signal: AbortSignal;
|
|
39
|
+
reply?: (owner: PluginId, text: string) => Promise<unknown>;
|
|
40
|
+
readonly releases: (() => void)[];
|
|
41
|
+
readonly timers: Set<ReturnType<typeof setTimeout>>;
|
|
42
|
+
readonly feedback: Promise<void>[];
|
|
43
|
+
readonly accepted: Set<string>;
|
|
44
|
+
readonly sends: Set<Promise<void>>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @internal Root-instance-owned state shared by projections and draining retired generations. */
|
|
48
|
+
export class CommandExecutionBudget {
|
|
49
|
+
readonly #entries = new Map<string, Entry>();
|
|
50
|
+
readonly #views = new WeakMap<object, { operation: Operation; owner: PluginId }>();
|
|
51
|
+
readonly #clock: () => number;
|
|
52
|
+
readonly #capacity: number;
|
|
53
|
+
readonly admission: AdmissionReader;
|
|
54
|
+
|
|
55
|
+
constructor(options: { readonly clock?: () => number; readonly capacity?: number } = {}) {
|
|
56
|
+
this.#clock = options.clock ?? (() => performance.now());
|
|
57
|
+
this.#capacity = options.capacity ?? 10_000;
|
|
58
|
+
if (!Number.isSafeInteger(this.#capacity) || this.#capacity < 1) throw new TypeError('Invalid command budget capacity');
|
|
59
|
+
this.admission = Object.freeze<AdmissionReader>({ admit: (source, capability, owner, policy, snapshot) => this.#admit(source, capability, owner, policy, snapshot) });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
openOperation(input: {
|
|
63
|
+
readonly snapshot: RuntimeSnapshot;
|
|
64
|
+
readonly conversation: ConversationIdentity;
|
|
65
|
+
readonly senderId?: string;
|
|
66
|
+
readonly retirementSignal: AbortSignal;
|
|
67
|
+
readonly reply: (owner: PluginId, text: string) => Promise<unknown>;
|
|
68
|
+
}): CommandOperationController {
|
|
69
|
+
const c = input.conversation;
|
|
70
|
+
const values = [c.endpoint.adapter, c.endpoint.id, c.kind, c.id, c.parent?.kind ?? '', c.parent?.id ?? '', c.threadId ?? '', input.senderId ?? ''];
|
|
71
|
+
const valid = values.slice(0, 4).every(nonempty) && ['private', 'group', 'channel'].includes(c.kind) && nonempty(input.senderId)
|
|
72
|
+
&& (c.parent === undefined || (nonempty(c.parent.kind) && nonempty(c.parent.id)))
|
|
73
|
+
&& (c.threadId === undefined || nonempty(c.threadId)) && values.every(value => value.length <= 1024);
|
|
74
|
+
const operation: Operation = {
|
|
75
|
+
snapshot: input.snapshot, key: valid ? Object.freeze(values) : undefined,
|
|
76
|
+
active: true, progressStopped: false, admitted: false, signal: input.retirementSignal,
|
|
77
|
+
reply: input.reply, releases: [], timers: new Set(), feedback: [], accepted: new Set(), sends: new Set(),
|
|
78
|
+
};
|
|
79
|
+
const stop = () => {
|
|
80
|
+
operation.progressStopped = true;
|
|
81
|
+
for (const timer of operation.timers) clearTimeout(timer);
|
|
82
|
+
operation.timers.clear();
|
|
83
|
+
};
|
|
84
|
+
input.retirementSignal.addEventListener('abort', stop, { once: true });
|
|
85
|
+
return Object.freeze({
|
|
86
|
+
bind: (view: object, owner: PluginId) => { if (operation.active) this.#views.set(view, { operation, owner }); },
|
|
87
|
+
trackSend: (send: Promise<unknown>) => {
|
|
88
|
+
if (!operation.active) return;
|
|
89
|
+
const settled = send.then(() => { operation.sends.delete(settled); }, () => { operation.sends.delete(settled); });
|
|
90
|
+
operation.sends.add(settled);
|
|
91
|
+
},
|
|
92
|
+
stopProgress: async () => { stop(); await Promise.all(operation.feedback); },
|
|
93
|
+
finish: async () => {
|
|
94
|
+
stop();
|
|
95
|
+
await Promise.all(operation.feedback);
|
|
96
|
+
// New sends may start while an earlier send is draining. Do not release from an initial snapshot alone.
|
|
97
|
+
if (operation.admitted) while (operation.sends.size > 0) await Promise.all(operation.sends);
|
|
98
|
+
if (!operation.active) return;
|
|
99
|
+
operation.active = false;
|
|
100
|
+
operation.signal.removeEventListener('abort', stop);
|
|
101
|
+
for (const release of operation.releases) release();
|
|
102
|
+
operation.releases.length = 0;
|
|
103
|
+
operation.feedback.length = 0;
|
|
104
|
+
operation.accepted.clear();
|
|
105
|
+
operation.sends.clear();
|
|
106
|
+
operation.reply = undefined;
|
|
107
|
+
operation.snapshot = undefined;
|
|
108
|
+
operation.key = undefined;
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#admit(source: unknown, capability: string, owner: PluginId, policy: CommandExecutionPolicy, snapshot: RuntimeSnapshot): CommandExecutionLimit | undefined {
|
|
114
|
+
const binding = source !== null && typeof source === 'object' ? this.#views.get(source) : undefined;
|
|
115
|
+
const operation = binding?.operation;
|
|
116
|
+
if (!operation?.active || operation.snapshot !== snapshot || binding?.owner !== owner || operation.signal.aborted) return limited('operation_unavailable');
|
|
117
|
+
if (!operation.key) return limited('identity_unavailable');
|
|
118
|
+
const key = JSON.stringify([capability, ...operation.key]);
|
|
119
|
+
// Re-entering the same capability is a second attempt, rather than a second free execution.
|
|
120
|
+
const now = this.#clock();
|
|
121
|
+
let entry = this.#entries.get(key);
|
|
122
|
+
if (entry && policy.maxConcurrent !== undefined && entry.active >= policy.maxConcurrent) return limited('busy');
|
|
123
|
+
if (entry && entry.cooldownUntil > now) return Object.freeze({ reason: 'cooldown', retryAfterMs: Math.ceil(entry.cooldownUntil - now) });
|
|
124
|
+
if (!entry) {
|
|
125
|
+
if (this.#entries.size >= this.#capacity) {
|
|
126
|
+
for (const [oldKey, old] of this.#entries) if (old.active === 0 && old.cooldownUntil <= now) this.#entries.delete(oldKey);
|
|
127
|
+
if (this.#entries.size >= this.#capacity) return limited('capacity');
|
|
128
|
+
}
|
|
129
|
+
entry = { active: 0, cooldownUntil: 0 };
|
|
130
|
+
this.#entries.set(key, entry);
|
|
131
|
+
}
|
|
132
|
+
entry.active++;
|
|
133
|
+
operation.admitted = true;
|
|
134
|
+
entry.cooldownUntil = now + (policy.cooldownMs ?? 0);
|
|
135
|
+
const acceptedEntry = entry;
|
|
136
|
+
operation.releases.push(() => {
|
|
137
|
+
acceptedEntry.active--;
|
|
138
|
+
if (acceptedEntry.active === 0 && acceptedEntry.cooldownUntil <= this.#clock()) this.#entries.delete(key);
|
|
139
|
+
});
|
|
140
|
+
if (policy.progressAfterMs !== undefined && !operation.progressStopped && !operation.accepted.has(capability)) {
|
|
141
|
+
const timer = setTimeout(() => {
|
|
142
|
+
operation.timers.delete(timer);
|
|
143
|
+
if (!operation.active || operation.progressStopped || operation.signal.aborted || !operation.reply) return;
|
|
144
|
+
// Observe failure once. Never retry uncertain delivery and never expose the exception.
|
|
145
|
+
let send: Promise<void>;
|
|
146
|
+
try { send = Promise.resolve(operation.reply(owner, '正在处理中,请稍候。')).then(() => {}, () => {}); }
|
|
147
|
+
catch { send = Promise.resolve(); }
|
|
148
|
+
operation.feedback.push(send);
|
|
149
|
+
}, policy.progressAfterMs);
|
|
150
|
+
operation.timers.add(timer);
|
|
151
|
+
}
|
|
152
|
+
operation.accepted.add(capability);
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function nonempty(value: unknown): value is string { return typeof value === 'string' && value.length > 0; }
|
|
158
|
+
function limited(reason: CommandExecutionLimit['reason']): CommandExecutionLimit { return Object.freeze({ reason }); }
|
|
159
|
+
|
|
160
|
+
/** @internal Projection reads the Root resource; ordinary authoring values cannot bind an operation. */
|
|
161
|
+
export function admitCommandExecution(source: unknown, capability: string, owner: PluginId, policy: CommandExecutionPolicy | undefined, snapshot: RuntimeSnapshot): CommandExecutionLimit | undefined {
|
|
162
|
+
if (!policy) return undefined;
|
|
163
|
+
const reader = snapshot.resources.get(snapshot.root)?.get(commandExecutionAdmissionToken.id) as AdmissionReader | undefined;
|
|
164
|
+
return reader ? reader.admit(source, capability, owner, policy, snapshot) : limited('service_unavailable');
|
|
165
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { operationClientAdapter } from '@zhin.js/feature-kit';
|
|
2
|
+
import { explainPermitRules, toPermissionSubject, type PermissionSubject, type PermissionConditionExplanation } from '@zhin.js/permission';
|
|
3
|
+
import { resolveCommandSender, resolveCommandSession, type CommandMessage } from './definition.js';
|
|
4
|
+
import type { CommandDescriptor } from './command-index.js';
|
|
5
|
+
|
|
6
|
+
/** The exact subject projection used by the real command permit gate. No factories or checkers run. */
|
|
7
|
+
export function toCommandPermissionSubject(source: unknown): PermissionSubject {
|
|
8
|
+
const projected = toPermissionSubject(source);
|
|
9
|
+
const session = resolveCommandSession(source);
|
|
10
|
+
return { ...projected, ...session, ...(session.sender ? { sender: { ...projected.sender, ...session.sender } } : {}) };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Exact authoring adapter restriction, distinct from builtin adapter(...) matching. */
|
|
14
|
+
export function commandAdapterMatches(command: Readonly<{ adapter?: string }>, source: unknown): boolean {
|
|
15
|
+
return !command.adapter || operationClientAdapter(source) === command.adapter;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type CommandPermissionHostStatus = 'present' | 'missing' | 'unverified';
|
|
19
|
+
|
|
20
|
+
/** Read-only hypothetical conditions. A match is not permission to execute the command. */
|
|
21
|
+
export function explainCommandPermissions(
|
|
22
|
+
command: Pick<CommandDescriptor, 'adapter' | 'permit'>,
|
|
23
|
+
options: Readonly<{ source?: unknown; rolesKnown?: boolean; permissionHost: CommandPermissionHostStatus }>,
|
|
24
|
+
) {
|
|
25
|
+
const projected = toCommandPermissionSubject(options.source);
|
|
26
|
+
const inputSender = options.source && typeof options.source === 'object'
|
|
27
|
+
? (options.source as Pick<CommandMessage, 'sender'>).sender : undefined;
|
|
28
|
+
const structuredRoles = inputSender && typeof inputSender === 'object'
|
|
29
|
+
&& Array.isArray((inputSender as { readonly role?: unknown }).role);
|
|
30
|
+
// A sender-only assumption has no synthetic conversation. Apply the real canonical
|
|
31
|
+
// sender defaults without inventing an Endpoint or treating unknown roles as known.
|
|
32
|
+
const senderOnly = options.source && typeof options.source === 'object' && !('conversation' in options.source) && !structuredRoles
|
|
33
|
+
? resolveCommandSender(options.source as Pick<CommandMessage, 'sender'>, undefined) : undefined;
|
|
34
|
+
const subject = senderOnly ? { ...projected, sender: senderOnly } : projected;
|
|
35
|
+
const defaultedRoles = options.rolesKnown && Array.isArray(inputSender?.roles) && inputSender.roles.length === 0
|
|
36
|
+
&& subject.sender?.role.length === 1 && subject.sender.role[0] === 'user';
|
|
37
|
+
const actualAdapter = operationClientAdapter(options.source);
|
|
38
|
+
const adapter: PermissionConditionExplanation = !command.adapter
|
|
39
|
+
? { outcome: 'not_evaluated', reason: '命令未声明 adapter 约束' }
|
|
40
|
+
: !actualAdapter ? { outcome: 'unknown', reason: '未选择当前 Endpoint,无法判断命令的精确 adapter 约束' }
|
|
41
|
+
: { outcome: commandAdapterMatches(command, options.source) ? 'match' : 'mismatch', reason: '命令 adapter 与当前 Endpoint 声明的原生适配器精确比较;未探测在线状态' };
|
|
42
|
+
const rules = explainPermitRules(command.permit ?? [], subject, { rolesKnown: options.rolesKnown });
|
|
43
|
+
const conditions = [...(command.adapter ? [adapter] : []), ...rules];
|
|
44
|
+
let summary: PermissionConditionExplanation;
|
|
45
|
+
if (conditions.some(item => item.outcome === 'mismatch')) summary = { outcome: 'mismatch', reason: '至少一个声明条件与假设输入不符;这不是实际准入检查' };
|
|
46
|
+
else if ((rules.length > 0 && options.permissionHost !== 'present') || conditions.some(item => item.outcome === 'unknown')) {
|
|
47
|
+
summary = { outcome: 'unknown', reason: rules.length > 0 && options.permissionHost !== 'present'
|
|
48
|
+
? 'PermissionHost 缺失或无法确认;不能推断命令可执行' : '存在缺少依据的条件,不能推断命令可执行' };
|
|
49
|
+
} else if (rules.length === 0) summary = { outcome: 'not_evaluated', reason: '命令未声明 permit 限制;这不表示已获得执行权限' };
|
|
50
|
+
else if (conditions.some(item => item.outcome === 'not_evaluated')) summary = { outcome: 'not_evaluated', reason: '存在未执行的平台或自定义条件,不能推断命令可执行' };
|
|
51
|
+
else summary = { outcome: 'match', reason: '已解释的 builtin 条件与假设输入匹配;不代表真实授权或命令可执行' };
|
|
52
|
+
return Object.freeze({ permissionHost: options.permissionHost, adapter: Object.freeze(adapter), rules, summary: Object.freeze(summary),
|
|
53
|
+
unconstrained: Object.freeze(['已解释的 builtin 规则不检查具体 Endpoint、parent 或 thread;保留完整目标不等于这些维度受规则限制']),
|
|
54
|
+
assumptions: Object.freeze(['身份与角色仅用于模拟,未向平台核实', '未执行命令、动态默认值、平台或自定义 checker',
|
|
55
|
+
...(defaultedRoles
|
|
56
|
+
? ['canonical 输入未附角色时按命令运行时默认 user 解释;这不是已核验的平台角色'] : [])]),
|
|
57
|
+
});
|
|
58
|
+
}
|