@zhin.js/command 1.1.8 → 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/lib/command-index.d.ts +5 -0
- package/lib/command-index.js +45 -6
- package/lib/definition.d.ts +9 -0
- package/lib/definition.js +18 -0
- package/lib/execution-runtime.d.ts +15 -0
- package/lib/execution-runtime.js +52 -19
- package/lib/index.d.ts +2 -2
- package/lib/index.js +2 -2
- 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 +2 -2
- package/src/command-index.ts +43 -6
- package/src/definition.ts +35 -0
- package/src/execution-runtime.ts +53 -16
- package/src/index.ts +4 -0
- package/src/permit.ts +14 -0
- package/src/provider.ts +124 -4
package/lib/command-index.d.ts
CHANGED
|
@@ -56,6 +56,11 @@ export declare class CommandIndex {
|
|
|
56
56
|
dispatch(input: CommandMatchInput, source?: unknown, interactionFactory?: UserInteractionFactory, commandPrefix?: string, operationSnapshot?: RuntimeSnapshot): Promise<CommandDispatchResult>;
|
|
57
57
|
}
|
|
58
58
|
export declare function isCommandIndex(value: unknown): value is CommandIndex;
|
|
59
|
+
/**
|
|
60
|
+
* Route-shape rule for a convention command: at most one dynamic segment, and it is the final one.
|
|
61
|
+
* Shared by the runtime index and {@link bindCommandSource}.
|
|
62
|
+
*/
|
|
63
|
+
export declare function assertParameterSegment(segments: readonly string[], parameter: CommandParameterDefinition | undefined, source: string): void;
|
|
59
64
|
export declare class CommandParameterValueError extends TypeError {
|
|
60
65
|
constructor(name: string, type: CommandParameterType, value: string);
|
|
61
66
|
}
|
package/lib/command-index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { permissionHostToken, } from '@zhin.js/permission';
|
|
|
5
5
|
import { commandAdapterMatches, toCommandPermissionSubject } from './permission-explanation.js';
|
|
6
6
|
import { commandUsage, commandHelp, parseCommandArguments, requestsCommandHelp } from './arguments.js';
|
|
7
7
|
import { MENU_PAGE_SIZE, readMenuAppearance, renderCommandMenu } from './command-menu.js';
|
|
8
|
-
import { admitCommandExecution } from './execution-runtime.js';
|
|
8
|
+
import { admitCommandExecution, admitDirectCommandExecution } from './execution-runtime.js';
|
|
9
9
|
import { CommandExecutionLimitedError, normalizeExecutionPolicy } from './execution-policy.js';
|
|
10
10
|
export { CommandExecutionLimitedError } from './execution-policy.js';
|
|
11
11
|
import { createCommandContext, resolveCommandSession, resolveDynamicParams, } from './definition.js';
|
|
@@ -45,7 +45,15 @@ export class CommandIndex {
|
|
|
45
45
|
for (const key of keys)
|
|
46
46
|
occupancy.set(key, source);
|
|
47
47
|
};
|
|
48
|
+
const disabledByOwner = new Map();
|
|
48
49
|
for (const slot of slots) {
|
|
50
|
+
let disabled = disabledByOwner.get(slot.owner);
|
|
51
|
+
if (!disabled) {
|
|
52
|
+
disabled = disabledCommands(this.snapshot, slot.owner);
|
|
53
|
+
disabledByOwner.set(slot.owner, disabled);
|
|
54
|
+
}
|
|
55
|
+
if (isDisabledCommand(slot.localName, disabled))
|
|
56
|
+
continue;
|
|
49
57
|
const namespace = commandNamespace(this.snapshot, slot.owner);
|
|
50
58
|
const primarySegments = [...namespace, ...runtimeSegments(slot.localName)];
|
|
51
59
|
const parameter = slot.definition.$parameter;
|
|
@@ -148,10 +156,16 @@ export class CommandIndex {
|
|
|
148
156
|
const parsed = parseCommandArguments(definition, supplied, resolveDynamicParams(match.params, undefined), {});
|
|
149
157
|
if (parsed.issues.length > 0)
|
|
150
158
|
throw new CommandArgumentValidationError(parsed.issues, commandHelp(match.command.name, definition));
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
159
|
+
// No inbound source: execution policy applies per command, never as a blanket refusal.
|
|
160
|
+
const admission = admitDirectCommandExecution(match.command.slot.id, match.command.execution, operationSnapshot);
|
|
161
|
+
if (admission.limit)
|
|
162
|
+
throw new CommandExecutionLimitedError(admission.limit);
|
|
163
|
+
try {
|
|
164
|
+
return await definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner, textArgs(parsed.remaining), parsed.params, undefined, parsed.remaining, undefined, definition.adapter, parsed.options));
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
admission.release();
|
|
168
|
+
}
|
|
155
169
|
}
|
|
156
170
|
async dispatch(input, source = undefined, interactionFactory, commandPrefix = '', operationSnapshot = this.snapshot) {
|
|
157
171
|
if (this.#menu) {
|
|
@@ -440,6 +454,27 @@ function commandNamespace(snapshot, owner) {
|
|
|
440
454
|
}
|
|
441
455
|
return Object.freeze([...segments]);
|
|
442
456
|
}
|
|
457
|
+
/**
|
|
458
|
+
* 插件实例配置 disabledCommands 列出的指令不进入路由表。条目写插件本地的静态路径
|
|
459
|
+
* (不含 commandNamespace 与参数段,如 `fanyi`、`netease login`);写上级路径会连同
|
|
460
|
+
* 其子命令一起禁用。未匹配任何指令的条目被忽略。
|
|
461
|
+
*/
|
|
462
|
+
function disabledCommands(snapshot, owner) {
|
|
463
|
+
const config = snapshot.config.get(owner);
|
|
464
|
+
const value = config?.disabledCommands;
|
|
465
|
+
if (value === undefined)
|
|
466
|
+
return Object.freeze([]);
|
|
467
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
|
|
468
|
+
throw new TypeError(`Invalid disabledCommands for ${owner}: expected an array of command names`);
|
|
469
|
+
}
|
|
470
|
+
return Object.freeze(value.map((entry) => splitCommand(entry).join(' ')).filter(Boolean));
|
|
471
|
+
}
|
|
472
|
+
function isDisabledCommand(localName, disabled) {
|
|
473
|
+
if (disabled.length === 0)
|
|
474
|
+
return false;
|
|
475
|
+
const path = runtimeSegments(localName).filter((segment) => !segment.startsWith('$')).join(' ');
|
|
476
|
+
return disabled.some((entry) => path === entry || path.startsWith(`${entry} `));
|
|
477
|
+
}
|
|
443
478
|
function isCommandNamespaceSegment(value) {
|
|
444
479
|
return /^[\p{L}\p{N}][\p{L}\p{N}_-]*$/u.test(value);
|
|
445
480
|
}
|
|
@@ -566,7 +601,11 @@ function exactMessageText(input) {
|
|
|
566
601
|
const trimmed = only.data.text.trim();
|
|
567
602
|
return trimmed || undefined;
|
|
568
603
|
}
|
|
569
|
-
|
|
604
|
+
/**
|
|
605
|
+
* Route-shape rule for a convention command: at most one dynamic segment, and it is the final one.
|
|
606
|
+
* Shared by the runtime index and {@link bindCommandSource}.
|
|
607
|
+
*/
|
|
608
|
+
export function assertParameterSegment(segments, parameter, source) {
|
|
570
609
|
const dynamicSegments = segments.filter((segment) => segment.startsWith('$'));
|
|
571
610
|
if (!parameter && dynamicSegments.length === 0)
|
|
572
611
|
return;
|
package/lib/definition.d.ts
CHANGED
|
@@ -282,7 +282,16 @@ type TypedCommandContext<TConfig, TInput extends CommandMessage, TAdapter extend
|
|
|
282
282
|
readonly params: keyof P extends never ? Readonly<Record<string, CommandParameterValue>> : InferCommandParams<P>;
|
|
283
283
|
readonly options: InferCommandOptions<O>;
|
|
284
284
|
};
|
|
285
|
+
/**
|
|
286
|
+
* `defineCommand.withConfig<Config>()` 返回的定义函数:config 类型已固定,params / options / 返回值
|
|
287
|
+
* 仍从定义推断。
|
|
288
|
+
* @public
|
|
289
|
+
*/
|
|
290
|
+
export type CommandDefiner<TConfig, TInput extends CommandMessage = CommandMessage> = <TResult = unknown, const P extends CommandParams = {}, const O extends CommandOptions = {}>(definition: CommandAuthoringDefinition<TConfig, TResult, TInput, P, O>) => Readonly<CommandDefinition<TConfig, TResult, TInput, string | undefined>>;
|
|
285
291
|
export declare function defineCommand<TConfig = unknown, TResult = unknown, TInput extends CommandMessage = CommandMessage, const P extends CommandParams = {}, const O extends CommandOptions = {}>(definition: CommandAuthoringDefinition<TConfig, TResult, TInput, P, O>): Readonly<CommandDefinition<TConfig, TResult, TInput, string | undefined>>;
|
|
292
|
+
export declare namespace defineCommand {
|
|
293
|
+
var withConfig: <TConfig = unknown, TInput extends CommandMessage = CommandMessage>() => CommandDefiner<TConfig, TInput>;
|
|
294
|
+
}
|
|
286
295
|
/** @internal Convention-loader assembly helper. */
|
|
287
296
|
export declare function bindCommandParameter<TConfig, TResult, TInput extends CommandMessage, TAdapter extends string | undefined>(definition: CommandDefinition<TConfig, TResult, TInput, TAdapter>, parameter: CommandParameterDefinition | undefined): Readonly<CommandDefinition<TConfig, TResult, TInput, TAdapter>>;
|
|
288
297
|
/** @internal Runtime validation for convention-discovered modules. */
|
package/lib/definition.js
CHANGED
|
@@ -43,6 +43,24 @@ export function defineCommand(definition) {
|
|
|
43
43
|
const execution = normalizeExecutionPolicy(definition.execution);
|
|
44
44
|
return Object.freeze({ $feature: commandBrand, ...definition, ...(execution ? { execution } : {}) });
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* 先固定插件 config 类型,再定义命令;params / options / 返回值仍从定义推断。
|
|
48
|
+
*
|
|
49
|
+
* ```ts
|
|
50
|
+
* export default defineCommand.withConfig<Config>()({
|
|
51
|
+
* params: { count: { type: 'integer' } },
|
|
52
|
+
* execute({ params, config }) { params.count; config.prefix },
|
|
53
|
+
* })
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* `defineCommand<Config>({...})` 仍然可用,但 TypeScript 不支持部分推断,显式写了
|
|
57
|
+
* 类型参数后 params 会退化成 `Record<string, CommandParameterValue>`。
|
|
58
|
+
* 这里是独立的属性而不是 `defineCommand()` 重载:`defineCommand` 常被当作值传给
|
|
59
|
+
* `createEndpointCommands(spec, defineCommand)` 这类工厂,重载会让它推断错签名。
|
|
60
|
+
*/
|
|
61
|
+
defineCommand.withConfig = function withConfig() {
|
|
62
|
+
return (definition) => defineCommand(definition);
|
|
63
|
+
};
|
|
46
64
|
function validateAdapterName(adapter) {
|
|
47
65
|
if (adapter !== undefined && (typeof adapter !== 'string' || adapter.trim() === '')) {
|
|
48
66
|
throw new TypeError('Command adapter must be a non-empty string');
|
|
@@ -16,7 +16,20 @@ interface ConversationIdentity {
|
|
|
16
16
|
}
|
|
17
17
|
interface AdmissionReader {
|
|
18
18
|
admit(source: unknown, capability: string, owner: PluginId, policy: CommandExecutionPolicy, snapshot: RuntimeSnapshot): CommandExecutionLimit | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Programmatic call with no inbound source (e.g. `CommandIndex.execute`). The policy is keyed by command only:
|
|
21
|
+
* there is no sender or conversation to budget, and no reply channel for progress feedback.
|
|
22
|
+
* On success the caller must invoke `release` once the execution settles.
|
|
23
|
+
*/
|
|
24
|
+
admitDirect(capability: string, policy: CommandExecutionPolicy): DirectAdmission;
|
|
19
25
|
}
|
|
26
|
+
type DirectAdmission = {
|
|
27
|
+
readonly limit: CommandExecutionLimit;
|
|
28
|
+
readonly release?: undefined;
|
|
29
|
+
} | {
|
|
30
|
+
readonly limit?: undefined;
|
|
31
|
+
readonly release: () => void;
|
|
32
|
+
};
|
|
20
33
|
/** @internal Admission-only Root resource; no bind or finish authority is published. */
|
|
21
34
|
export declare const commandExecutionAdmissionToken: import("@zhin.js/plugin-runtime").Token<AdmissionReader>;
|
|
22
35
|
/** @internal Held only by Core's actual leased operation. */
|
|
@@ -47,4 +60,6 @@ export declare class CommandExecutionBudget {
|
|
|
47
60
|
}
|
|
48
61
|
/** @internal Projection reads the Root resource; ordinary authoring values cannot bind an operation. */
|
|
49
62
|
export declare function admitCommandExecution(source: unknown, capability: string, owner: PluginId, policy: CommandExecutionPolicy | undefined, snapshot: RuntimeSnapshot): CommandExecutionLimit | undefined;
|
|
63
|
+
/** @internal Programmatic (source-less) admission: keyed by command only, never refused for lacking a source. */
|
|
64
|
+
export declare function admitDirectCommandExecution(capability: string, policy: CommandExecutionPolicy | undefined, snapshot: RuntimeSnapshot): DirectAdmission;
|
|
50
65
|
export {};
|
package/lib/execution-runtime.js
CHANGED
|
@@ -14,7 +14,8 @@ export class CommandExecutionBudget {
|
|
|
14
14
|
this.#capacity = options.capacity ?? 10_000;
|
|
15
15
|
if (!Number.isSafeInteger(this.#capacity) || this.#capacity < 1)
|
|
16
16
|
throw new TypeError('Invalid command budget capacity');
|
|
17
|
-
this.admission = Object.freeze({ admit: (source, capability, owner, policy, snapshot) => this.#admit(source, capability, owner, policy, snapshot)
|
|
17
|
+
this.admission = Object.freeze({ admit: (source, capability, owner, policy, snapshot) => this.#admit(source, capability, owner, policy, snapshot),
|
|
18
|
+
admitDirect: (capability, policy) => this.#admitDirect(capability, policy) });
|
|
18
19
|
}
|
|
19
20
|
openOperation(input) {
|
|
20
21
|
const c = input.conversation;
|
|
@@ -67,41 +68,66 @@ export class CommandExecutionBudget {
|
|
|
67
68
|
},
|
|
68
69
|
});
|
|
69
70
|
}
|
|
70
|
-
#
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (!operation.key)
|
|
76
|
-
return limited('identity_unavailable');
|
|
77
|
-
const key = JSON.stringify([capability, ...operation.key]);
|
|
78
|
-
// Re-entering the same capability is a second attempt, rather than a second free execution.
|
|
71
|
+
#admitDirect(capability, policy) {
|
|
72
|
+
return this.#acquire(JSON.stringify([capability, 'direct']), policy);
|
|
73
|
+
}
|
|
74
|
+
/** Shared entry accounting: concurrency, cooldown and capacity. Returns a one-shot release on success. */
|
|
75
|
+
#acquire(key, policy) {
|
|
79
76
|
const now = this.#clock();
|
|
80
77
|
let entry = this.#entries.get(key);
|
|
81
78
|
if (entry && policy.maxConcurrent !== undefined && entry.active >= policy.maxConcurrent)
|
|
82
|
-
return limited('busy');
|
|
79
|
+
return { limit: limited('busy') };
|
|
83
80
|
if (entry && entry.cooldownUntil > now)
|
|
84
|
-
return Object.freeze({ reason: 'cooldown', retryAfterMs: Math.ceil(entry.cooldownUntil - now) });
|
|
81
|
+
return { limit: Object.freeze({ reason: 'cooldown', retryAfterMs: Math.ceil(entry.cooldownUntil - now) }) };
|
|
85
82
|
if (!entry) {
|
|
86
83
|
if (this.#entries.size >= this.#capacity) {
|
|
87
84
|
for (const [oldKey, old] of this.#entries)
|
|
88
85
|
if (old.active === 0 && old.cooldownUntil <= now)
|
|
89
86
|
this.#entries.delete(oldKey);
|
|
87
|
+
if (this.#entries.size >= this.#capacity) {
|
|
88
|
+
// Still full of cooling identities: drop the oldest idle one. Forgetting a cooldown is acceptable;
|
|
89
|
+
// refusing every new identity is not. Only entries with running operations are never evicted.
|
|
90
|
+
for (const [oldKey, old] of this.#entries)
|
|
91
|
+
if (old.active === 0) {
|
|
92
|
+
this.#entries.delete(oldKey);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
90
96
|
if (this.#entries.size >= this.#capacity)
|
|
91
|
-
return limited('capacity');
|
|
97
|
+
return { limit: limited('capacity') };
|
|
92
98
|
}
|
|
93
99
|
entry = { active: 0, cooldownUntil: 0 };
|
|
94
100
|
this.#entries.set(key, entry);
|
|
95
101
|
}
|
|
96
102
|
entry.active++;
|
|
97
|
-
operation.admitted = true;
|
|
98
103
|
entry.cooldownUntil = now + (policy.cooldownMs ?? 0);
|
|
99
104
|
const acceptedEntry = entry;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
let released = false;
|
|
106
|
+
return {
|
|
107
|
+
release: () => {
|
|
108
|
+
if (released)
|
|
109
|
+
return;
|
|
110
|
+
released = true;
|
|
111
|
+
acceptedEntry.active--;
|
|
112
|
+
if (acceptedEntry.active === 0 && acceptedEntry.cooldownUntil <= this.#clock())
|
|
113
|
+
this.#entries.delete(key);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
#admit(source, capability, owner, policy, snapshot) {
|
|
118
|
+
const binding = source !== null && typeof source === 'object' ? this.#views.get(source) : undefined;
|
|
119
|
+
const operation = binding?.operation;
|
|
120
|
+
if (!operation?.active || operation.snapshot !== snapshot || binding?.owner !== owner || operation.signal.aborted)
|
|
121
|
+
return limited('operation_unavailable');
|
|
122
|
+
if (!operation.key)
|
|
123
|
+
return limited('identity_unavailable');
|
|
124
|
+
const key = JSON.stringify([capability, ...operation.key]);
|
|
125
|
+
// Re-entering the same capability is a second attempt, rather than a second free execution.
|
|
126
|
+
const acquired = this.#acquire(key, policy);
|
|
127
|
+
if (acquired.limit)
|
|
128
|
+
return acquired.limit;
|
|
129
|
+
operation.admitted = true;
|
|
130
|
+
operation.releases.push(acquired.release);
|
|
105
131
|
if (policy.progressAfterMs !== undefined && !operation.progressStopped && !operation.accepted.has(capability)) {
|
|
106
132
|
const timer = setTimeout(() => {
|
|
107
133
|
operation.timers.delete(timer);
|
|
@@ -132,3 +158,10 @@ export function admitCommandExecution(source, capability, owner, policy, snapsho
|
|
|
132
158
|
const reader = snapshot.resources.get(snapshot.root)?.get(commandExecutionAdmissionToken.id);
|
|
133
159
|
return reader ? reader.admit(source, capability, owner, policy, snapshot) : limited('service_unavailable');
|
|
134
160
|
}
|
|
161
|
+
/** @internal Programmatic (source-less) admission: keyed by command only, never refused for lacking a source. */
|
|
162
|
+
export function admitDirectCommandExecution(capability, policy, snapshot) {
|
|
163
|
+
if (!policy)
|
|
164
|
+
return { release: () => { } };
|
|
165
|
+
const reader = snapshot.resources.get(snapshot.root)?.get(commandExecutionAdmissionToken.id);
|
|
166
|
+
return reader ? reader.admitDirect(capability, policy) : { limit: limited('service_unavailable') };
|
|
167
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export * from './command-index.js';
|
|
|
7
7
|
/** @public Command authoring contract and `defineCommand`. */
|
|
8
8
|
export * from './definition.js';
|
|
9
9
|
export * from './permission-explanation.js';
|
|
10
|
-
export { assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, isPlatformPermit, parsePermitName, type ParsedPermit, type PermitKind, } from './permit.js';
|
|
11
|
-
export { CommandPathSyntaxError, commandFeatureId, default as commandFeature, } from './provider.js';
|
|
10
|
+
export { CONVERSATION_MANAGER_PERMIT, canManageConversation, assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, isPlatformPermit, parsePermitName, type ParsedPermit, type PermitKind, } from './permit.js';
|
|
11
|
+
export { bindCommandSource, CommandPathSyntaxError, commandFeatureId, type BoundCommandSource, default as commandFeature, } from './provider.js';
|
|
12
12
|
export { default } from './provider.js';
|
|
13
13
|
export type { CommandValidationIssue } from './arguments.js';
|
package/lib/index.js
CHANGED
|
@@ -7,6 +7,6 @@ export * from './command-index.js';
|
|
|
7
7
|
/** @public Command authoring contract and `defineCommand`. */
|
|
8
8
|
export * from './definition.js';
|
|
9
9
|
export * from './permission-explanation.js';
|
|
10
|
-
export { assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, isPlatformPermit, parsePermitName, } from './permit.js';
|
|
11
|
-
export { CommandPathSyntaxError, commandFeatureId, default as commandFeature, } from './provider.js';
|
|
10
|
+
export { CONVERSATION_MANAGER_PERMIT, canManageConversation, assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, isPlatformPermit, parsePermitName, } from './permit.js';
|
|
11
|
+
export { bindCommandSource, CommandPathSyntaxError, commandFeatureId, default as commandFeature, } from './provider.js';
|
|
12
12
|
export { default } from './provider.js';
|
package/lib/permit.d.ts
CHANGED
|
@@ -2,4 +2,12 @@
|
|
|
2
2
|
* Re-export permit 解析/校验 from @zhin.js/permission (SSOT)。
|
|
3
3
|
* 保留 CommandSession 兼容签名供 command-index 过渡使用。
|
|
4
4
|
*/
|
|
5
|
+
import { type PermissionSubject } from '@zhin.js/permission';
|
|
6
|
+
/** 会话管理者:群主 / 群管理员 / 机器人主人(适配器投影的规范角色名)。 */
|
|
7
|
+
export declare const CONVERSATION_MANAGER_PERMIT = "role(owner,admin,master)";
|
|
8
|
+
/**
|
|
9
|
+
* 「私聊,或 owner / admin / master」。`permit` 列表只支持 AND,
|
|
10
|
+
* 订阅类命令需要这个 OR 规则时用它,并以命令会话的 `scene` / `sender.role` 为输入。
|
|
11
|
+
*/
|
|
12
|
+
export declare function canManageConversation(subject: PermissionSubject): boolean;
|
|
5
13
|
export { type PermitKind, type ParsedPermit, parsePermitName, isBuiltinPermit, isPlatformPermit, assertPermitSyntax as assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, } from '@zhin.js/permission';
|
package/lib/permit.js
CHANGED
|
@@ -2,4 +2,15 @@
|
|
|
2
2
|
* Re-export permit 解析/校验 from @zhin.js/permission (SSOT)。
|
|
3
3
|
* 保留 CommandSession 兼容签名供 command-index 过渡使用。
|
|
4
4
|
*/
|
|
5
|
+
import { checkBuiltinPermit } from '@zhin.js/permission';
|
|
6
|
+
/** 会话管理者:群主 / 群管理员 / 机器人主人(适配器投影的规范角色名)。 */
|
|
7
|
+
export const CONVERSATION_MANAGER_PERMIT = 'role(owner,admin,master)';
|
|
8
|
+
/**
|
|
9
|
+
* 「私聊,或 owner / admin / master」。`permit` 列表只支持 AND,
|
|
10
|
+
* 订阅类命令需要这个 OR 规则时用它,并以命令会话的 `scene` / `sender.role` 为输入。
|
|
11
|
+
*/
|
|
12
|
+
export function canManageConversation(subject) {
|
|
13
|
+
return checkBuiltinPermit('private()', subject)
|
|
14
|
+
|| checkBuiltinPermit(CONVERSATION_MANAGER_PERMIT, subject);
|
|
15
|
+
}
|
|
5
16
|
export { parsePermitName, isBuiltinPermit, isPlatformPermit, assertPermitSyntax as assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, } from '@zhin.js/permission';
|
package/lib/provider.d.ts
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { CommandIndex } from './command-index.js';
|
|
2
2
|
import { type CommandDefinition } from './definition.js';
|
|
3
3
|
export declare const commandFeatureId: import("@zhin.js/plugin-runtime").FeatureId;
|
|
4
|
+
export interface BoundCommandSource<TDefinition> {
|
|
5
|
+
/** Plugin-local name the runtime registers, e.g. `term/$word`. */
|
|
6
|
+
readonly localName: string;
|
|
7
|
+
/** The definition with `$parameter` bound from the directory shape, as the runtime loads it. */
|
|
8
|
+
readonly definition: TDefinition;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Apply the runtime's convention-path rules to one command module: directory syntax
|
|
12
|
+
* (`[name]`, `[[name]]`, `[...name]`, `[[...name]]`), the `params` declaration the directory
|
|
13
|
+
* implies, and the route shape (one dynamic segment, last). `source` is the command's `index`
|
|
14
|
+
* file or its directory; everything after the last `commands` directory is the command path.
|
|
15
|
+
* Throws the same errors discovery, loading and the command index throw.
|
|
16
|
+
*/
|
|
17
|
+
export declare function bindCommandSource<TDefinition extends CommandDefinition>(value: TDefinition, source: string): BoundCommandSource<TDefinition>;
|
|
18
|
+
export declare function bindCommandSource(value: unknown, source: string): BoundCommandSource<CommandDefinition>;
|
|
4
19
|
export declare class CommandPathSyntaxError extends TypeError {
|
|
5
20
|
constructor(file: string, detail?: string);
|
|
6
21
|
}
|
package/lib/provider.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { basename, dirname, join, sep } from 'node:path';
|
|
1
|
+
import { basename, dirname, join, relative, sep } from 'node:path';
|
|
2
2
|
import { featureId, isCapabilityLocalSegment } from '@zhin.js/plugin-runtime';
|
|
3
3
|
import { defineFeatureProvider, } from '@zhin.js/feature-kit';
|
|
4
|
-
import { CommandIndex } from './command-index.js';
|
|
4
|
+
import { CommandIndex, assertParameterSegment } from './command-index.js';
|
|
5
5
|
import { bindCommandParameter, parseCommandDefinition, } from './definition.js';
|
|
6
6
|
export const commandFeatureId = featureId('zhin.command');
|
|
7
7
|
const commandFiles = {
|
|
@@ -21,6 +21,8 @@ const commandFiles = {
|
|
|
21
21
|
async load(source, context) {
|
|
22
22
|
const module = await context.host.loadModule(source.source);
|
|
23
23
|
const definition = parseCommandDefinition(module.default);
|
|
24
|
+
const commandDirectories = commandDirectoriesOf(context.packageRoot, source.source);
|
|
25
|
+
assertSingleTrailingParameterDirectory(commandDirectories, source.source);
|
|
24
26
|
const file = parseCommandDirectory(basename(dirname(source.source)));
|
|
25
27
|
return bindCommandParameter(definition, resolveParameter(definition, file, source.source));
|
|
26
28
|
},
|
|
@@ -77,6 +79,63 @@ function parseCommandDirectory(value) {
|
|
|
77
79
|
}
|
|
78
80
|
return undefined;
|
|
79
81
|
}
|
|
82
|
+
/** Directory segments between `commands/` and the command's `index.*` file. */
|
|
83
|
+
function commandDirectoriesOf(packageRoot, source) {
|
|
84
|
+
const commandsRoot = join(packageRoot, 'commands');
|
|
85
|
+
const commandDirectory = dirname(source);
|
|
86
|
+
return relative(commandsRoot, commandDirectory)
|
|
87
|
+
.split(sep)
|
|
88
|
+
.filter((segment) => segment !== '');
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* A command path may contain at most one parameter directory, and it must be the
|
|
92
|
+
* last directory before `index.*`. Further arguments belong in `defineCommand({ params })`.
|
|
93
|
+
*/
|
|
94
|
+
function assertSingleTrailingParameterDirectory(directories, source) {
|
|
95
|
+
const parameterDirectories = [];
|
|
96
|
+
directories.forEach((directory, position) => {
|
|
97
|
+
const hint = parseCommandDirectory(directory)?.parameter;
|
|
98
|
+
if (hint)
|
|
99
|
+
parameterDirectories.push({ position, directory, hint });
|
|
100
|
+
});
|
|
101
|
+
const [keptDirectory, ...movedDirectories] = parameterDirectories;
|
|
102
|
+
if (!keptDirectory)
|
|
103
|
+
return;
|
|
104
|
+
const lastPosition = directories.length - 1;
|
|
105
|
+
if (movedDirectories.length === 0 && keptDirectory.position === lastPosition)
|
|
106
|
+
return;
|
|
107
|
+
throw new CommandPathSyntaxError(source, describeParameterDirectoryRule(directories, keptDirectory, movedDirectories));
|
|
108
|
+
}
|
|
109
|
+
function describeParameterDirectoryRule(directories, keptDirectory, movedDirectories) {
|
|
110
|
+
const parameterDirectories = [keptDirectory, ...movedDirectories];
|
|
111
|
+
const currentPath = `commands/${directories.join('/')}/index.ts`;
|
|
112
|
+
const foundDirectories = parameterDirectories.map((entry) => entry.directory).join(', ');
|
|
113
|
+
const staticDirectories = directories.filter((_directory, position) => !parameterDirectories.some((entry) => entry.position === position));
|
|
114
|
+
const suggestedPath = `commands/${[...staticDirectories, keptDirectory.directory].join('/')}/index.ts`;
|
|
115
|
+
const suggestedParams = parameterDirectories
|
|
116
|
+
.map((entry) => describeParamDeclaration(entry.hint))
|
|
117
|
+
.join(', ');
|
|
118
|
+
const movedNote = movedDirectories.length > 0
|
|
119
|
+
? `move ${movedDirectories.map((entry) => entry.directory).join(', ')} into defineCommand({ params })`
|
|
120
|
+
: 'move the parameter directory to the end of the path';
|
|
121
|
+
return `only the last directory of a command path may be a parameter directory `
|
|
122
|
+
+ `([name], [[name]], [...name] or [[...name]]), and a command may have at most one; `
|
|
123
|
+
+ `found ${foundDirectories} in ${currentPath}. `
|
|
124
|
+
+ `Fix: ${movedNote}, e.g. ${suggestedPath} with `
|
|
125
|
+
+ `defineCommand({ params: { ${suggestedParams} } }) `
|
|
126
|
+
+ `(the directory parameter must be listed first). `
|
|
127
|
+
+ 'See https://zhin.js.org/authoring/commands#目录参数规则';
|
|
128
|
+
}
|
|
129
|
+
function describeParamDeclaration(hint) {
|
|
130
|
+
const type = hint.rest ? 'text' : 'string';
|
|
131
|
+
const flags = [];
|
|
132
|
+
if (hint.optional)
|
|
133
|
+
flags.push('optional: true');
|
|
134
|
+
if (hint.rest)
|
|
135
|
+
flags.push('rest: true');
|
|
136
|
+
const flagText = flags.map((flag) => `, ${flag}`).join('');
|
|
137
|
+
return `${hint.name}: { type: '${type}'${flagText} }`;
|
|
138
|
+
}
|
|
80
139
|
/** 把文件名形态与 `definition.params` 合并成完整参数定义。 */
|
|
81
140
|
function resolveParameter(definition, file, source) {
|
|
82
141
|
const hint = file?.parameter;
|
|
@@ -86,8 +145,13 @@ function resolveParameter(definition, file, source) {
|
|
|
86
145
|
if (!schema) {
|
|
87
146
|
throw new CommandPathSyntaxError(source, `missing params.${hint.name} declaration in defineCommand({ params })`);
|
|
88
147
|
}
|
|
89
|
-
|
|
90
|
-
|
|
148
|
+
const declaredNames = Object.keys(definition.params ?? {});
|
|
149
|
+
if (declaredNames[0] !== hint.name) {
|
|
150
|
+
const reorderedNames = [hint.name, ...declaredNames.filter((name) => name !== hint.name)];
|
|
151
|
+
throw new CommandPathSyntaxError(source, `params.${hint.name} must be the first parameter declaration: the parameter directory `
|
|
152
|
+
+ `binds the first argument after the command name, so it has to come first in `
|
|
153
|
+
+ `defineCommand({ params }). Current order: { ${declaredNames.join(', ')} }; `
|
|
154
|
+
+ `reorder to { ${reorderedNames.join(', ')} }`);
|
|
91
155
|
}
|
|
92
156
|
if (schema.rest !== undefined && schema.rest !== hint.rest
|
|
93
157
|
|| schema.optional !== undefined && schema.optional !== hint.optional) {
|
|
@@ -119,6 +183,26 @@ function preferredCommandIndex(entries, preferJavaScript) {
|
|
|
119
183
|
.sort((left, right) => commandFilePriority(left.name, preferJavaScript)
|
|
120
184
|
- commandFilePriority(right.name, preferJavaScript))[0]?.name;
|
|
121
185
|
}
|
|
186
|
+
export function bindCommandSource(value, source) {
|
|
187
|
+
const directory = /^index\.(?:tsx?|[cm]?js)$/u.test(basename(source)) ? dirname(source) : source;
|
|
188
|
+
const parts = directory.split(/[\\/]/u).filter(Boolean);
|
|
189
|
+
const commandsAt = parts.lastIndexOf('commands');
|
|
190
|
+
if (commandsAt < 0 || commandsAt === parts.length - 1) {
|
|
191
|
+
throw new CommandPathSyntaxError(source, 'expected a path under commands/, e.g. commands/foo/index.ts');
|
|
192
|
+
}
|
|
193
|
+
const segments = parts.slice(commandsAt + 1).map((name) => {
|
|
194
|
+
const segment = parseCommandDirectory(name);
|
|
195
|
+
if (!segment) {
|
|
196
|
+
throw new CommandPathSyntaxError(source, `"${name}" is not a command directory name, so the runtime does not discover it`);
|
|
197
|
+
}
|
|
198
|
+
return segment;
|
|
199
|
+
});
|
|
200
|
+
const definition = parseCommandDefinition(value);
|
|
201
|
+
const bound = bindCommandParameter(definition, resolveParameter(definition, segments.at(-1), source));
|
|
202
|
+
const localName = segments.map((segment) => segment.localSegment).join('/');
|
|
203
|
+
assertParameterSegment(localName.split('/'), bound.$parameter, source);
|
|
204
|
+
return Object.freeze({ localName, definition: bound });
|
|
205
|
+
}
|
|
122
206
|
export class CommandPathSyntaxError extends TypeError {
|
|
123
207
|
constructor(file, detail = 'expected commands/foo/index.ts or commands/foo/[name]/index.ts') {
|
|
124
208
|
super(`Invalid Command path ${file}: ${detail}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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",
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
],
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"segment-matcher": "^1.0.5",
|
|
24
|
-
"@zhin.js/feature-kit": "1.1.6",
|
|
25
24
|
"@zhin.js/interaction": "1.1.2",
|
|
25
|
+
"@zhin.js/feature-kit": "1.1.7",
|
|
26
26
|
"@zhin.js/permission": "1.1.7",
|
|
27
27
|
"@zhin.js/plugin-runtime": "1.1.15"
|
|
28
28
|
},
|
package/src/command-index.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
import { commandAdapterMatches, toCommandPermissionSubject } from './permission-explanation.js';
|
|
20
20
|
import { commandUsage, commandHelp, parseCommandArguments, requestsCommandHelp, type CommandValidationIssue } from './arguments.js';
|
|
21
21
|
import { MENU_PAGE_SIZE, readMenuAppearance, renderCommandMenu, type CommandMenuSegment } from './command-menu.js';
|
|
22
|
-
import { admitCommandExecution } from './execution-runtime.js';
|
|
22
|
+
import { admitCommandExecution, admitDirectCommandExecution } from './execution-runtime.js';
|
|
23
23
|
import { CommandExecutionLimitedError, normalizeExecutionPolicy, type CommandExecutionLimit } from './execution-policy.js';
|
|
24
24
|
export { CommandExecutionLimitedError } from './execution-policy.js';
|
|
25
25
|
import {
|
|
@@ -153,7 +153,14 @@ export class CommandIndex {
|
|
|
153
153
|
for (const key of keys) occupancy.set(key, source);
|
|
154
154
|
};
|
|
155
155
|
|
|
156
|
+
const disabledByOwner = new Map<PluginId, readonly string[]>();
|
|
156
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;
|
|
157
164
|
const namespace = commandNamespace(this.snapshot, slot.owner);
|
|
158
165
|
const primarySegments = [...namespace, ...runtimeSegments(slot.localName)];
|
|
159
166
|
const parameter = slot.definition.$parameter;
|
|
@@ -268,10 +275,15 @@ export class CommandIndex {
|
|
|
268
275
|
const supplied = [...match.remaining, ...(args.length > 0 ? [{ type: 'text', data: { text: args.join(' ') } }] : [])];
|
|
269
276
|
const parsed = parseCommandArguments(definition, supplied, resolveDynamicParams(match.params, undefined), {});
|
|
270
277
|
if (parsed.issues.length > 0) throw new CommandArgumentValidationError(parsed.issues, commandHelp(match.command.name, definition));
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
+
}
|
|
275
287
|
}
|
|
276
288
|
|
|
277
289
|
async dispatch(
|
|
@@ -560,6 +572,27 @@ function commandNamespace(snapshot: RuntimeSnapshot, owner: PluginId): readonly
|
|
|
560
572
|
return Object.freeze([...segments]);
|
|
561
573
|
}
|
|
562
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
|
+
|
|
563
596
|
function isCommandNamespaceSegment(value: string): boolean {
|
|
564
597
|
return /^[\p{L}\p{N}][\p{L}\p{N}_-]*$/u.test(value);
|
|
565
598
|
}
|
|
@@ -699,7 +732,11 @@ function exactMessageText(input: CommandMatchInput): string | undefined {
|
|
|
699
732
|
return trimmed || undefined;
|
|
700
733
|
}
|
|
701
734
|
|
|
702
|
-
|
|
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(
|
|
703
740
|
segments: readonly string[],
|
|
704
741
|
parameter: CommandParameterDefinition | undefined,
|
|
705
742
|
source: string,
|
package/src/definition.ts
CHANGED
|
@@ -360,6 +360,19 @@ type TypedCommandContext<TConfig, TInput extends CommandMessage, TAdapter extend
|
|
|
360
360
|
readonly options: InferCommandOptions<O>;
|
|
361
361
|
};
|
|
362
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
|
+
|
|
363
376
|
export function defineCommand<
|
|
364
377
|
TConfig = unknown,
|
|
365
378
|
TResult = unknown,
|
|
@@ -394,6 +407,28 @@ export function defineCommand<
|
|
|
394
407
|
>;
|
|
395
408
|
}
|
|
396
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
|
+
|
|
397
432
|
function validateAdapterName(adapter: string | undefined): void {
|
|
398
433
|
if (adapter !== undefined && (typeof adapter !== 'string' || adapter.trim() === '')) {
|
|
399
434
|
throw new TypeError('Command adapter must be a non-empty string');
|
package/src/execution-runtime.ts
CHANGED
|
@@ -12,8 +12,16 @@ interface ConversationIdentity {
|
|
|
12
12
|
|
|
13
13
|
interface AdmissionReader {
|
|
14
14
|
admit(source: unknown, capability: string, owner: PluginId, policy: CommandExecutionPolicy, snapshot: RuntimeSnapshot): CommandExecutionLimit | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Programmatic call with no inbound source (e.g. `CommandIndex.execute`). The policy is keyed by command only:
|
|
17
|
+
* there is no sender or conversation to budget, and no reply channel for progress feedback.
|
|
18
|
+
* On success the caller must invoke `release` once the execution settles.
|
|
19
|
+
*/
|
|
20
|
+
admitDirect(capability: string, policy: CommandExecutionPolicy): DirectAdmission;
|
|
15
21
|
}
|
|
16
22
|
|
|
23
|
+
type DirectAdmission = { readonly limit: CommandExecutionLimit; readonly release?: undefined } | { readonly limit?: undefined; readonly release: () => void };
|
|
24
|
+
|
|
17
25
|
/** @internal Admission-only Root resource; no bind or finish authority is published. */
|
|
18
26
|
export const commandExecutionAdmissionToken = createToken<AdmissionReader>('zhin.command.execution-admission');
|
|
19
27
|
|
|
@@ -56,7 +64,8 @@ export class CommandExecutionBudget {
|
|
|
56
64
|
this.#clock = options.clock ?? (() => performance.now());
|
|
57
65
|
this.#capacity = options.capacity ?? 10_000;
|
|
58
66
|
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)
|
|
67
|
+
this.admission = Object.freeze<AdmissionReader>({ admit: (source, capability, owner, policy, snapshot) => this.#admit(source, capability, owner, policy, snapshot),
|
|
68
|
+
admitDirect: (capability, policy) => this.#admitDirect(capability, policy) });
|
|
60
69
|
}
|
|
61
70
|
|
|
62
71
|
openOperation(input: {
|
|
@@ -110,33 +119,54 @@ export class CommandExecutionBudget {
|
|
|
110
119
|
});
|
|
111
120
|
}
|
|
112
121
|
|
|
113
|
-
#
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
// Re-entering the same capability is a second attempt, rather than a second free execution.
|
|
122
|
+
#admitDirect(capability: string, policy: CommandExecutionPolicy): DirectAdmission {
|
|
123
|
+
return this.#acquire(JSON.stringify([capability, 'direct']), policy);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Shared entry accounting: concurrency, cooldown and capacity. Returns a one-shot release on success. */
|
|
127
|
+
#acquire(key: string, policy: CommandExecutionPolicy): DirectAdmission {
|
|
120
128
|
const now = this.#clock();
|
|
121
129
|
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) });
|
|
130
|
+
if (entry && policy.maxConcurrent !== undefined && entry.active >= policy.maxConcurrent) return { limit: limited('busy') };
|
|
131
|
+
if (entry && entry.cooldownUntil > now) return { limit: Object.freeze({ reason: 'cooldown', retryAfterMs: Math.ceil(entry.cooldownUntil - now) }) };
|
|
124
132
|
if (!entry) {
|
|
125
133
|
if (this.#entries.size >= this.#capacity) {
|
|
126
134
|
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)
|
|
135
|
+
if (this.#entries.size >= this.#capacity) {
|
|
136
|
+
// Still full of cooling identities: drop the oldest idle one. Forgetting a cooldown is acceptable;
|
|
137
|
+
// refusing every new identity is not. Only entries with running operations are never evicted.
|
|
138
|
+
for (const [oldKey, old] of this.#entries) if (old.active === 0) { this.#entries.delete(oldKey); break; }
|
|
139
|
+
}
|
|
140
|
+
if (this.#entries.size >= this.#capacity) return { limit: limited('capacity') };
|
|
128
141
|
}
|
|
129
142
|
entry = { active: 0, cooldownUntil: 0 };
|
|
130
143
|
this.#entries.set(key, entry);
|
|
131
144
|
}
|
|
132
145
|
entry.active++;
|
|
133
|
-
operation.admitted = true;
|
|
134
146
|
entry.cooldownUntil = now + (policy.cooldownMs ?? 0);
|
|
135
147
|
const acceptedEntry = entry;
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
148
|
+
let released = false;
|
|
149
|
+
return {
|
|
150
|
+
release: () => {
|
|
151
|
+
if (released) return;
|
|
152
|
+
released = true;
|
|
153
|
+
acceptedEntry.active--;
|
|
154
|
+
if (acceptedEntry.active === 0 && acceptedEntry.cooldownUntil <= this.#clock()) this.#entries.delete(key);
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#admit(source: unknown, capability: string, owner: PluginId, policy: CommandExecutionPolicy, snapshot: RuntimeSnapshot): CommandExecutionLimit | undefined {
|
|
160
|
+
const binding = source !== null && typeof source === 'object' ? this.#views.get(source) : undefined;
|
|
161
|
+
const operation = binding?.operation;
|
|
162
|
+
if (!operation?.active || operation.snapshot !== snapshot || binding?.owner !== owner || operation.signal.aborted) return limited('operation_unavailable');
|
|
163
|
+
if (!operation.key) return limited('identity_unavailable');
|
|
164
|
+
const key = JSON.stringify([capability, ...operation.key]);
|
|
165
|
+
// Re-entering the same capability is a second attempt, rather than a second free execution.
|
|
166
|
+
const acquired = this.#acquire(key, policy);
|
|
167
|
+
if (acquired.limit) return acquired.limit;
|
|
168
|
+
operation.admitted = true;
|
|
169
|
+
operation.releases.push(acquired.release);
|
|
140
170
|
if (policy.progressAfterMs !== undefined && !operation.progressStopped && !operation.accepted.has(capability)) {
|
|
141
171
|
const timer = setTimeout(() => {
|
|
142
172
|
operation.timers.delete(timer);
|
|
@@ -163,3 +193,10 @@ export function admitCommandExecution(source: unknown, capability: string, owner
|
|
|
163
193
|
const reader = snapshot.resources.get(snapshot.root)?.get(commandExecutionAdmissionToken.id) as AdmissionReader | undefined;
|
|
164
194
|
return reader ? reader.admit(source, capability, owner, policy, snapshot) : limited('service_unavailable');
|
|
165
195
|
}
|
|
196
|
+
|
|
197
|
+
/** @internal Programmatic (source-less) admission: keyed by command only, never refused for lacking a source. */
|
|
198
|
+
export function admitDirectCommandExecution(capability: string, policy: CommandExecutionPolicy | undefined, snapshot: RuntimeSnapshot): DirectAdmission {
|
|
199
|
+
if (!policy) return { release: () => {} };
|
|
200
|
+
const reader = snapshot.resources.get(snapshot.root)?.get(commandExecutionAdmissionToken.id) as AdmissionReader | undefined;
|
|
201
|
+
return reader ? reader.admitDirect(capability, policy) : { limit: limited('service_unavailable') };
|
|
202
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,8 @@ export * from './command-index.js';
|
|
|
8
8
|
export * from './definition.js';
|
|
9
9
|
export * from './permission-explanation.js';
|
|
10
10
|
export {
|
|
11
|
+
CONVERSATION_MANAGER_PERMIT,
|
|
12
|
+
canManageConversation,
|
|
11
13
|
assertBuiltinPermits,
|
|
12
14
|
checkBuiltinPermit,
|
|
13
15
|
checkBuiltinPermitList,
|
|
@@ -18,8 +20,10 @@ export {
|
|
|
18
20
|
type PermitKind,
|
|
19
21
|
} from './permit.js';
|
|
20
22
|
export {
|
|
23
|
+
bindCommandSource,
|
|
21
24
|
CommandPathSyntaxError,
|
|
22
25
|
commandFeatureId,
|
|
26
|
+
type BoundCommandSource,
|
|
23
27
|
default as commandFeature,
|
|
24
28
|
} from './provider.js';
|
|
25
29
|
export { default } from './provider.js';
|
package/src/permit.ts
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
* Re-export permit 解析/校验 from @zhin.js/permission (SSOT)。
|
|
3
3
|
* 保留 CommandSession 兼容签名供 command-index 过渡使用。
|
|
4
4
|
*/
|
|
5
|
+
import { checkBuiltinPermit, type PermissionSubject } from '@zhin.js/permission';
|
|
6
|
+
|
|
7
|
+
/** 会话管理者:群主 / 群管理员 / 机器人主人(适配器投影的规范角色名)。 */
|
|
8
|
+
export const CONVERSATION_MANAGER_PERMIT = 'role(owner,admin,master)';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 「私聊,或 owner / admin / master」。`permit` 列表只支持 AND,
|
|
12
|
+
* 订阅类命令需要这个 OR 规则时用它,并以命令会话的 `scene` / `sender.role` 为输入。
|
|
13
|
+
*/
|
|
14
|
+
export function canManageConversation(subject: PermissionSubject): boolean {
|
|
15
|
+
return checkBuiltinPermit('private()', subject)
|
|
16
|
+
|| checkBuiltinPermit(CONVERSATION_MANAGER_PERMIT, subject);
|
|
17
|
+
}
|
|
18
|
+
|
|
5
19
|
export {
|
|
6
20
|
type PermitKind,
|
|
7
21
|
type ParsedPermit,
|
package/src/provider.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { basename, dirname, join, sep } from 'node:path';
|
|
1
|
+
import { basename, dirname, join, relative, sep } from 'node:path';
|
|
2
2
|
import { featureId, isCapabilityLocalSegment } from '@zhin.js/plugin-runtime';
|
|
3
3
|
import {
|
|
4
4
|
defineFeatureProvider,
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
type DiscoveredSource,
|
|
7
7
|
type SourceConvention,
|
|
8
8
|
} from '@zhin.js/feature-kit';
|
|
9
|
-
import { CommandIndex } from './command-index.js';
|
|
9
|
+
import { CommandIndex, assertParameterSegment } from './command-index.js';
|
|
10
10
|
import {
|
|
11
11
|
bindCommandParameter,
|
|
12
12
|
parseCommandDefinition,
|
|
@@ -32,6 +32,8 @@ const commandFiles: SourceConvention = {
|
|
|
32
32
|
async load(source, context) {
|
|
33
33
|
const module = await context.host.loadModule<{ default?: unknown }>(source.source);
|
|
34
34
|
const definition = parseCommandDefinition(module.default);
|
|
35
|
+
const commandDirectories = commandDirectoriesOf(context.packageRoot, source.source);
|
|
36
|
+
assertSingleTrailingParameterDirectory(commandDirectories, source.source);
|
|
35
37
|
const file = parseCommandDirectory(basename(dirname(source.source)));
|
|
36
38
|
return bindCommandParameter(definition, resolveParameter(definition, file, source.source));
|
|
37
39
|
},
|
|
@@ -109,6 +111,76 @@ function parseCommandDirectory(value: string): ParsedCommandFile | undefined {
|
|
|
109
111
|
return undefined;
|
|
110
112
|
}
|
|
111
113
|
|
|
114
|
+
/** Directory segments between `commands/` and the command's `index.*` file. */
|
|
115
|
+
function commandDirectoriesOf(packageRoot: string, source: string): string[] {
|
|
116
|
+
const commandsRoot = join(packageRoot, 'commands');
|
|
117
|
+
const commandDirectory = dirname(source);
|
|
118
|
+
return relative(commandsRoot, commandDirectory)
|
|
119
|
+
.split(sep)
|
|
120
|
+
.filter((segment) => segment !== '');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface ParameterDirectory {
|
|
124
|
+
readonly position: number;
|
|
125
|
+
readonly directory: string;
|
|
126
|
+
readonly hint: CommandParameterHint;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A command path may contain at most one parameter directory, and it must be the
|
|
131
|
+
* last directory before `index.*`. Further arguments belong in `defineCommand({ params })`.
|
|
132
|
+
*/
|
|
133
|
+
function assertSingleTrailingParameterDirectory(directories: readonly string[], source: string): void {
|
|
134
|
+
const parameterDirectories: ParameterDirectory[] = [];
|
|
135
|
+
directories.forEach((directory, position) => {
|
|
136
|
+
const hint = parseCommandDirectory(directory)?.parameter;
|
|
137
|
+
if (hint) parameterDirectories.push({ position, directory, hint });
|
|
138
|
+
});
|
|
139
|
+
const [keptDirectory, ...movedDirectories] = parameterDirectories;
|
|
140
|
+
if (!keptDirectory) return;
|
|
141
|
+
const lastPosition = directories.length - 1;
|
|
142
|
+
if (movedDirectories.length === 0 && keptDirectory.position === lastPosition) return;
|
|
143
|
+
throw new CommandPathSyntaxError(
|
|
144
|
+
source,
|
|
145
|
+
describeParameterDirectoryRule(directories, keptDirectory, movedDirectories),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function describeParameterDirectoryRule(
|
|
150
|
+
directories: readonly string[],
|
|
151
|
+
keptDirectory: ParameterDirectory,
|
|
152
|
+
movedDirectories: readonly ParameterDirectory[],
|
|
153
|
+
): string {
|
|
154
|
+
const parameterDirectories = [keptDirectory, ...movedDirectories];
|
|
155
|
+
const currentPath = `commands/${directories.join('/')}/index.ts`;
|
|
156
|
+
const foundDirectories = parameterDirectories.map((entry) => entry.directory).join(', ');
|
|
157
|
+
const staticDirectories = directories.filter((_directory, position) =>
|
|
158
|
+
!parameterDirectories.some((entry) => entry.position === position));
|
|
159
|
+
const suggestedPath = `commands/${[...staticDirectories, keptDirectory.directory].join('/')}/index.ts`;
|
|
160
|
+
const suggestedParams = parameterDirectories
|
|
161
|
+
.map((entry) => describeParamDeclaration(entry.hint))
|
|
162
|
+
.join(', ');
|
|
163
|
+
const movedNote = movedDirectories.length > 0
|
|
164
|
+
? `move ${movedDirectories.map((entry) => entry.directory).join(', ')} into defineCommand({ params })`
|
|
165
|
+
: 'move the parameter directory to the end of the path';
|
|
166
|
+
return `only the last directory of a command path may be a parameter directory `
|
|
167
|
+
+ `([name], [[name]], [...name] or [[...name]]), and a command may have at most one; `
|
|
168
|
+
+ `found ${foundDirectories} in ${currentPath}. `
|
|
169
|
+
+ `Fix: ${movedNote}, e.g. ${suggestedPath} with `
|
|
170
|
+
+ `defineCommand({ params: { ${suggestedParams} } }) `
|
|
171
|
+
+ `(the directory parameter must be listed first). `
|
|
172
|
+
+ 'See https://zhin.js.org/authoring/commands#目录参数规则';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function describeParamDeclaration(hint: CommandParameterHint): string {
|
|
176
|
+
const type = hint.rest ? 'text' : 'string';
|
|
177
|
+
const flags: string[] = [];
|
|
178
|
+
if (hint.optional) flags.push('optional: true');
|
|
179
|
+
if (hint.rest) flags.push('rest: true');
|
|
180
|
+
const flagText = flags.map((flag) => `, ${flag}`).join('');
|
|
181
|
+
return `${hint.name}: { type: '${type}'${flagText} }`;
|
|
182
|
+
}
|
|
183
|
+
|
|
112
184
|
/** 把文件名形态与 `definition.params` 合并成完整参数定义。 */
|
|
113
185
|
function resolveParameter(
|
|
114
186
|
definition: CommandDefinition,
|
|
@@ -124,8 +196,16 @@ function resolveParameter(
|
|
|
124
196
|
`missing params.${hint.name} declaration in defineCommand({ params })`,
|
|
125
197
|
);
|
|
126
198
|
}
|
|
127
|
-
|
|
128
|
-
|
|
199
|
+
const declaredNames = Object.keys(definition.params ?? {});
|
|
200
|
+
if (declaredNames[0] !== hint.name) {
|
|
201
|
+
const reorderedNames = [hint.name, ...declaredNames.filter((name) => name !== hint.name)];
|
|
202
|
+
throw new CommandPathSyntaxError(
|
|
203
|
+
source,
|
|
204
|
+
`params.${hint.name} must be the first parameter declaration: the parameter directory `
|
|
205
|
+
+ `binds the first argument after the command name, so it has to come first in `
|
|
206
|
+
+ `defineCommand({ params }). Current order: { ${declaredNames.join(', ')} }; `
|
|
207
|
+
+ `reorder to { ${reorderedNames.join(', ')} }`,
|
|
208
|
+
);
|
|
129
209
|
}
|
|
130
210
|
if (schema.rest !== undefined && schema.rest !== hint.rest
|
|
131
211
|
|| schema.optional !== undefined && schema.optional !== hint.optional) {
|
|
@@ -166,6 +246,46 @@ function preferredCommandIndex(
|
|
|
166
246
|
- commandFilePriority(right.name, preferJavaScript))[0]?.name;
|
|
167
247
|
}
|
|
168
248
|
|
|
249
|
+
export interface BoundCommandSource<TDefinition> {
|
|
250
|
+
/** Plugin-local name the runtime registers, e.g. `term/$word`. */
|
|
251
|
+
readonly localName: string;
|
|
252
|
+
/** The definition with `$parameter` bound from the directory shape, as the runtime loads it. */
|
|
253
|
+
readonly definition: TDefinition;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Apply the runtime's convention-path rules to one command module: directory syntax
|
|
258
|
+
* (`[name]`, `[[name]]`, `[...name]`, `[[...name]]`), the `params` declaration the directory
|
|
259
|
+
* implies, and the route shape (one dynamic segment, last). `source` is the command's `index`
|
|
260
|
+
* file or its directory; everything after the last `commands` directory is the command path.
|
|
261
|
+
* Throws the same errors discovery, loading and the command index throw.
|
|
262
|
+
*/
|
|
263
|
+
export function bindCommandSource<TDefinition extends CommandDefinition>(
|
|
264
|
+
value: TDefinition,
|
|
265
|
+
source: string,
|
|
266
|
+
): BoundCommandSource<TDefinition>;
|
|
267
|
+
export function bindCommandSource(value: unknown, source: string): BoundCommandSource<CommandDefinition>;
|
|
268
|
+
export function bindCommandSource(value: unknown, source: string): BoundCommandSource<CommandDefinition> {
|
|
269
|
+
const directory = /^index\.(?:tsx?|[cm]?js)$/u.test(basename(source)) ? dirname(source) : source;
|
|
270
|
+
const parts = directory.split(/[\\/]/u).filter(Boolean);
|
|
271
|
+
const commandsAt = parts.lastIndexOf('commands');
|
|
272
|
+
if (commandsAt < 0 || commandsAt === parts.length - 1) {
|
|
273
|
+
throw new CommandPathSyntaxError(source, 'expected a path under commands/, e.g. commands/foo/index.ts');
|
|
274
|
+
}
|
|
275
|
+
const segments = parts.slice(commandsAt + 1).map((name) => {
|
|
276
|
+
const segment = parseCommandDirectory(name);
|
|
277
|
+
if (!segment) {
|
|
278
|
+
throw new CommandPathSyntaxError(source, `"${name}" is not a command directory name, so the runtime does not discover it`);
|
|
279
|
+
}
|
|
280
|
+
return segment;
|
|
281
|
+
});
|
|
282
|
+
const definition = parseCommandDefinition(value);
|
|
283
|
+
const bound = bindCommandParameter(definition, resolveParameter(definition, segments.at(-1), source));
|
|
284
|
+
const localName = segments.map((segment) => segment.localSegment).join('/');
|
|
285
|
+
assertParameterSegment(localName.split('/'), bound.$parameter, source);
|
|
286
|
+
return Object.freeze({ localName, definition: bound });
|
|
287
|
+
}
|
|
288
|
+
|
|
169
289
|
export class CommandPathSyntaxError extends TypeError {
|
|
170
290
|
constructor(
|
|
171
291
|
file: string,
|