@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/lib/definition.js CHANGED
@@ -1,6 +1,7 @@
1
- import { createCapabilityContext, readOperationClient, } from '@zhin.js/feature-kit';
1
+ import { createCapabilityContext, readOperationClient, readOperationInputSignal, } from '@zhin.js/feature-kit';
2
2
  import { assertPermitSyntax } from '@zhin.js/permission';
3
3
  import { validateArgumentSchemas } from './arguments.js';
4
+ import { normalizeExecutionPolicy } from './execution-policy.js';
4
5
  const commandBrand = 'zhin.command/1';
5
6
  /** @internal Runtime validator lookup. */
6
7
  export const commandParameterTypes = new Set([
@@ -39,8 +40,27 @@ export function defineCommand(definition) {
39
40
  validateCommandAlias(definition.alias);
40
41
  validateCommandPermit(definition.permit);
41
42
  validateCommandShortcutShape(definition.shortcut);
42
- return Object.freeze({ $feature: commandBrand, ...definition });
43
+ const execution = normalizeExecutionPolicy(definition.execution);
44
+ return Object.freeze({ $feature: commandBrand, ...definition, ...(execution ? { execution } : {}) });
43
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
+ };
44
64
  function validateAdapterName(adapter) {
45
65
  if (adapter !== undefined && (typeof adapter !== 'string' || adapter.trim() === '')) {
46
66
  throw new TypeError('Command adapter must be a non-empty string');
@@ -106,6 +126,7 @@ export function parseCommandDefinition(value) {
106
126
  throw new TypeError('Command module must default-export defineCommand(...)');
107
127
  }
108
128
  validateAdapterName(definition.adapter);
129
+ normalizeExecutionPolicy(definition.execution);
109
130
  return definition;
110
131
  }
111
132
  /**
@@ -123,7 +144,7 @@ export function resolveDynamicParams(params, source) {
123
144
  }
124
145
  /** @internal Command dispatcher assembly helper. */
125
146
  export function createCommandContext(snapshot, ownerId, args, params = Object.freeze({}), input = undefined, segments = Object.freeze([]), interaction, adapter, options = Object.freeze({})) {
126
- const context = createCapabilityContext(snapshot, ownerId);
147
+ const context = createCapabilityContext(snapshot, ownerId, undefined, readOperationInputSignal(input));
127
148
  const session = resolveCommandSession(input);
128
149
  const parameterValues = { __proto__: null, ...params };
129
150
  const optionValues = { __proto__: null, ...options };
@@ -158,7 +179,7 @@ export function resolveCommandSession(input) {
158
179
  const endpoint = input.endpointId
159
180
  || (typeof metadata?.endpoint === 'string' && metadata.endpoint ? metadata.endpoint : undefined);
160
181
  const scene = resolveScene(input, metadata);
161
- const sender = resolveSender(input, metadata);
182
+ const sender = resolveCommandSender(input, metadata);
162
183
  return Object.freeze({
163
184
  ...(adapter ? { adapter } : {}),
164
185
  ...(endpoint !== undefined ? { endpoint } : {}),
@@ -194,7 +215,11 @@ function resolveScene(input, metadata) {
194
215
  ...(name !== undefined ? { name } : {}),
195
216
  });
196
217
  }
197
- function resolveSender(input, metadata) {
218
+ /**
219
+ * Pure canonical sender projection shared with read-only command condition simulation.
220
+ * @internal Command dispatcher projection helper.
221
+ */
222
+ export function resolveCommandSender(input, metadata) {
198
223
  const structured = input.from;
199
224
  if (isCommandSender(structured)) {
200
225
  return freezeSender(structured);
@@ -0,0 +1,18 @@
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
+ export interface CommandExecutionLimit {
9
+ readonly reason: 'cooldown' | 'busy' | 'capacity' | 'operation_unavailable' | 'identity_unavailable' | 'service_unavailable';
10
+ /** Exact remaining cooldown; a busy operation has no predictable completion time. */
11
+ readonly retryAfterMs?: number;
12
+ }
13
+ export declare class CommandExecutionLimitedError extends Error {
14
+ readonly limit: CommandExecutionLimit;
15
+ constructor(limit: CommandExecutionLimit);
16
+ }
17
+ /** @internal Validate at declaration and projection, without coercion or silent clamping. */
18
+ export declare function normalizeExecutionPolicy(value: unknown): Readonly<CommandExecutionPolicy> | undefined;
@@ -0,0 +1,27 @@
1
+ export class CommandExecutionLimitedError extends Error {
2
+ limit;
3
+ constructor(limit) {
4
+ super(`Command execution limited: ${limit.reason}`);
5
+ this.name = 'CommandExecutionLimitedError';
6
+ this.limit = limit;
7
+ }
8
+ }
9
+ /** @internal Validate at declaration and projection, without coercion or silent clamping. */
10
+ export function normalizeExecutionPolicy(value) {
11
+ if (value === undefined)
12
+ return undefined;
13
+ if (!value || typeof value !== 'object' || Array.isArray(value))
14
+ throw new TypeError('Command execution must be an object');
15
+ const limits = { cooldownMs: 86_400_000, maxConcurrent: 1000, progressAfterMs: 3_600_000 };
16
+ const result = {};
17
+ for (const [key, item] of Object.entries(value)) {
18
+ if (!Object.hasOwn(limits, key) || !Number.isSafeInteger(item) || item <= 0
19
+ || item > limits[key]) {
20
+ throw new TypeError(`Command execution.${key} must be a positive bounded integer`);
21
+ }
22
+ result[key] = item;
23
+ }
24
+ if (Object.keys(result).length === 0)
25
+ throw new TypeError('Command execution requires at least one policy');
26
+ return Object.freeze(result);
27
+ }
@@ -0,0 +1,65 @@
1
+ /** Internal composition contract. Not part of the command authoring facade. */
2
+ import { type PluginId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
3
+ import type { CommandExecutionLimit, CommandExecutionPolicy } from './execution-policy.js';
4
+ interface ConversationIdentity {
5
+ readonly endpoint: {
6
+ readonly adapter: string;
7
+ readonly id: string;
8
+ };
9
+ readonly kind: string;
10
+ readonly id: string;
11
+ readonly parent?: {
12
+ readonly kind: string;
13
+ readonly id: string;
14
+ };
15
+ readonly threadId?: string;
16
+ }
17
+ interface AdmissionReader {
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;
25
+ }
26
+ type DirectAdmission = {
27
+ readonly limit: CommandExecutionLimit;
28
+ readonly release?: undefined;
29
+ } | {
30
+ readonly limit?: undefined;
31
+ readonly release: () => void;
32
+ };
33
+ /** @internal Admission-only Root resource; no bind or finish authority is published. */
34
+ export declare const commandExecutionAdmissionToken: import("@zhin.js/plugin-runtime").Token<AdmissionReader>;
35
+ /** @internal Held only by Core's actual leased operation. */
36
+ export interface CommandOperationController {
37
+ bind(view: object, owner: PluginId): void;
38
+ /** Core registers actual started delivery promises, never a caller-supplied completion callback. */
39
+ trackSend(send: Promise<unknown>): void;
40
+ /** Stop pending feedback and drain already-started feedback before the final reply. */
41
+ stopProgress(): Promise<void>;
42
+ /** Called only by the real outer operation's finally, after all final sends settle. */
43
+ finish(): Promise<void>;
44
+ }
45
+ /** @internal Root-instance-owned state shared by projections and draining retired generations. */
46
+ export declare class CommandExecutionBudget {
47
+ #private;
48
+ readonly admission: AdmissionReader;
49
+ constructor(options?: {
50
+ readonly clock?: () => number;
51
+ readonly capacity?: number;
52
+ });
53
+ openOperation(input: {
54
+ readonly snapshot: RuntimeSnapshot;
55
+ readonly conversation: ConversationIdentity;
56
+ readonly senderId?: string;
57
+ readonly retirementSignal: AbortSignal;
58
+ readonly reply: (owner: PluginId, text: string) => Promise<unknown>;
59
+ }): CommandOperationController;
60
+ }
61
+ /** @internal Projection reads the Root resource; ordinary authoring values cannot bind an operation. */
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;
65
+ export {};
@@ -0,0 +1,167 @@
1
+ /** Internal composition contract. Not part of the command authoring facade. */
2
+ import { createToken } from '@zhin.js/plugin-runtime';
3
+ /** @internal Admission-only Root resource; no bind or finish authority is published. */
4
+ export const commandExecutionAdmissionToken = createToken('zhin.command.execution-admission');
5
+ /** @internal Root-instance-owned state shared by projections and draining retired generations. */
6
+ export class CommandExecutionBudget {
7
+ #entries = new Map();
8
+ #views = new WeakMap();
9
+ #clock;
10
+ #capacity;
11
+ admission;
12
+ constructor(options = {}) {
13
+ this.#clock = options.clock ?? (() => performance.now());
14
+ this.#capacity = options.capacity ?? 10_000;
15
+ if (!Number.isSafeInteger(this.#capacity) || this.#capacity < 1)
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),
18
+ admitDirect: (capability, policy) => this.#admitDirect(capability, policy) });
19
+ }
20
+ openOperation(input) {
21
+ const c = input.conversation;
22
+ const values = [c.endpoint.adapter, c.endpoint.id, c.kind, c.id, c.parent?.kind ?? '', c.parent?.id ?? '', c.threadId ?? '', input.senderId ?? ''];
23
+ const valid = values.slice(0, 4).every(nonempty) && ['private', 'group', 'channel'].includes(c.kind) && nonempty(input.senderId)
24
+ && (c.parent === undefined || (nonempty(c.parent.kind) && nonempty(c.parent.id)))
25
+ && (c.threadId === undefined || nonempty(c.threadId)) && values.every(value => value.length <= 1024);
26
+ const operation = {
27
+ snapshot: input.snapshot, key: valid ? Object.freeze(values) : undefined,
28
+ active: true, progressStopped: false, admitted: false, signal: input.retirementSignal,
29
+ reply: input.reply, releases: [], timers: new Set(), feedback: [], accepted: new Set(), sends: new Set(),
30
+ };
31
+ const stop = () => {
32
+ operation.progressStopped = true;
33
+ for (const timer of operation.timers)
34
+ clearTimeout(timer);
35
+ operation.timers.clear();
36
+ };
37
+ input.retirementSignal.addEventListener('abort', stop, { once: true });
38
+ return Object.freeze({
39
+ bind: (view, owner) => { if (operation.active)
40
+ this.#views.set(view, { operation, owner }); },
41
+ trackSend: (send) => {
42
+ if (!operation.active)
43
+ return;
44
+ const settled = send.then(() => { operation.sends.delete(settled); }, () => { operation.sends.delete(settled); });
45
+ operation.sends.add(settled);
46
+ },
47
+ stopProgress: async () => { stop(); await Promise.all(operation.feedback); },
48
+ finish: async () => {
49
+ stop();
50
+ await Promise.all(operation.feedback);
51
+ // New sends may start while an earlier send is draining. Do not release from an initial snapshot alone.
52
+ if (operation.admitted)
53
+ while (operation.sends.size > 0)
54
+ await Promise.all(operation.sends);
55
+ if (!operation.active)
56
+ return;
57
+ operation.active = false;
58
+ operation.signal.removeEventListener('abort', stop);
59
+ for (const release of operation.releases)
60
+ release();
61
+ operation.releases.length = 0;
62
+ operation.feedback.length = 0;
63
+ operation.accepted.clear();
64
+ operation.sends.clear();
65
+ operation.reply = undefined;
66
+ operation.snapshot = undefined;
67
+ operation.key = undefined;
68
+ },
69
+ });
70
+ }
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) {
76
+ const now = this.#clock();
77
+ let entry = this.#entries.get(key);
78
+ if (entry && policy.maxConcurrent !== undefined && entry.active >= policy.maxConcurrent)
79
+ return { limit: limited('busy') };
80
+ if (entry && entry.cooldownUntil > now)
81
+ return { limit: Object.freeze({ reason: 'cooldown', retryAfterMs: Math.ceil(entry.cooldownUntil - now) }) };
82
+ if (!entry) {
83
+ if (this.#entries.size >= this.#capacity) {
84
+ for (const [oldKey, old] of this.#entries)
85
+ if (old.active === 0 && old.cooldownUntil <= now)
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
+ }
96
+ if (this.#entries.size >= this.#capacity)
97
+ return { limit: limited('capacity') };
98
+ }
99
+ entry = { active: 0, cooldownUntil: 0 };
100
+ this.#entries.set(key, entry);
101
+ }
102
+ entry.active++;
103
+ entry.cooldownUntil = now + (policy.cooldownMs ?? 0);
104
+ const acceptedEntry = entry;
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);
131
+ if (policy.progressAfterMs !== undefined && !operation.progressStopped && !operation.accepted.has(capability)) {
132
+ const timer = setTimeout(() => {
133
+ operation.timers.delete(timer);
134
+ if (!operation.active || operation.progressStopped || operation.signal.aborted || !operation.reply)
135
+ return;
136
+ // Observe failure once. Never retry uncertain delivery and never expose the exception.
137
+ let send;
138
+ try {
139
+ send = Promise.resolve(operation.reply(owner, '正在处理中,请稍候。')).then(() => { }, () => { });
140
+ }
141
+ catch {
142
+ send = Promise.resolve();
143
+ }
144
+ operation.feedback.push(send);
145
+ }, policy.progressAfterMs);
146
+ operation.timers.add(timer);
147
+ }
148
+ operation.accepted.add(capability);
149
+ return undefined;
150
+ }
151
+ }
152
+ function nonempty(value) { return typeof value === 'string' && value.length > 0; }
153
+ function limited(reason) { return Object.freeze({ reason }); }
154
+ /** @internal Projection reads the Root resource; ordinary authoring values cannot bind an operation. */
155
+ export function admitCommandExecution(source, capability, owner, policy, snapshot) {
156
+ if (!policy)
157
+ return undefined;
158
+ const reader = snapshot.resources.get(snapshot.root)?.get(commandExecutionAdmissionToken.id);
159
+ return reader ? reader.admit(source, capability, owner, policy, snapshot) : limited('service_unavailable');
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
@@ -6,7 +6,8 @@
6
6
  export * from './command-index.js';
7
7
  /** @public Command authoring contract and `defineCommand`. */
8
8
  export * from './definition.js';
9
- export { assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, isPlatformPermit, parsePermitName, type ParsedPermit, type PermitKind, } from './permit.js';
10
- export { CommandPathSyntaxError, commandFeatureId, default as commandFeature, } from './provider.js';
9
+ export * from './permission-explanation.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';
11
12
  export { default } from './provider.js';
12
13
  export type { CommandValidationIssue } from './arguments.js';
package/lib/index.js CHANGED
@@ -6,6 +6,7 @@
6
6
  export * from './command-index.js';
7
7
  /** @public Command authoring contract and `defineCommand`. */
8
8
  export * from './definition.js';
9
- export { assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, isPlatformPermit, parsePermitName, } from './permit.js';
10
- export { CommandPathSyntaxError, commandFeatureId, default as commandFeature, } from './provider.js';
9
+ export * from './permission-explanation.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';
11
12
  export { default } from './provider.js';
@@ -0,0 +1,22 @@
1
+ import { type PermissionSubject, type PermissionConditionExplanation } from '@zhin.js/permission';
2
+ import type { CommandDescriptor } from './command-index.js';
3
+ /** The exact subject projection used by the real command permit gate. No factories or checkers run. */
4
+ export declare function toCommandPermissionSubject(source: unknown): PermissionSubject;
5
+ /** Exact authoring adapter restriction, distinct from builtin adapter(...) matching. */
6
+ export declare function commandAdapterMatches(command: Readonly<{
7
+ adapter?: string;
8
+ }>, source: unknown): boolean;
9
+ export type CommandPermissionHostStatus = 'present' | 'missing' | 'unverified';
10
+ /** Read-only hypothetical conditions. A match is not permission to execute the command. */
11
+ export declare function explainCommandPermissions(command: Pick<CommandDescriptor, 'adapter' | 'permit'>, options: Readonly<{
12
+ source?: unknown;
13
+ rolesKnown?: boolean;
14
+ permissionHost: CommandPermissionHostStatus;
15
+ }>): Readonly<{
16
+ permissionHost: CommandPermissionHostStatus;
17
+ adapter: Readonly<PermissionConditionExplanation>;
18
+ rules: readonly import("@zhin.js/permission").PermitRuleExplanation[];
19
+ summary: Readonly<PermissionConditionExplanation>;
20
+ unconstrained: readonly string[];
21
+ assumptions: readonly string[];
22
+ }>;
@@ -0,0 +1,54 @@
1
+ import { operationClientAdapter } from '@zhin.js/feature-kit';
2
+ import { explainPermitRules, toPermissionSubject } from '@zhin.js/permission';
3
+ import { resolveCommandSender, resolveCommandSession } from './definition.js';
4
+ /** The exact subject projection used by the real command permit gate. No factories or checkers run. */
5
+ export function toCommandPermissionSubject(source) {
6
+ const projected = toPermissionSubject(source);
7
+ const session = resolveCommandSession(source);
8
+ return { ...projected, ...session, ...(session.sender ? { sender: { ...projected.sender, ...session.sender } } : {}) };
9
+ }
10
+ /** Exact authoring adapter restriction, distinct from builtin adapter(...) matching. */
11
+ export function commandAdapterMatches(command, source) {
12
+ return !command.adapter || operationClientAdapter(source) === command.adapter;
13
+ }
14
+ /** Read-only hypothetical conditions. A match is not permission to execute the command. */
15
+ export function explainCommandPermissions(command, options) {
16
+ const projected = toCommandPermissionSubject(options.source);
17
+ const inputSender = options.source && typeof options.source === 'object'
18
+ ? options.source.sender : undefined;
19
+ const structuredRoles = inputSender && typeof inputSender === 'object'
20
+ && Array.isArray(inputSender.role);
21
+ // A sender-only assumption has no synthetic conversation. Apply the real canonical
22
+ // sender defaults without inventing an Endpoint or treating unknown roles as known.
23
+ const senderOnly = options.source && typeof options.source === 'object' && !('conversation' in options.source) && !structuredRoles
24
+ ? resolveCommandSender(options.source, undefined) : undefined;
25
+ const subject = senderOnly ? { ...projected, sender: senderOnly } : projected;
26
+ const defaultedRoles = options.rolesKnown && Array.isArray(inputSender?.roles) && inputSender.roles.length === 0
27
+ && subject.sender?.role.length === 1 && subject.sender.role[0] === 'user';
28
+ const actualAdapter = operationClientAdapter(options.source);
29
+ const adapter = !command.adapter
30
+ ? { outcome: 'not_evaluated', reason: '命令未声明 adapter 约束' }
31
+ : !actualAdapter ? { outcome: 'unknown', reason: '未选择当前 Endpoint,无法判断命令的精确 adapter 约束' }
32
+ : { outcome: commandAdapterMatches(command, options.source) ? 'match' : 'mismatch', reason: '命令 adapter 与当前 Endpoint 声明的原生适配器精确比较;未探测在线状态' };
33
+ const rules = explainPermitRules(command.permit ?? [], subject, { rolesKnown: options.rolesKnown });
34
+ const conditions = [...(command.adapter ? [adapter] : []), ...rules];
35
+ let summary;
36
+ if (conditions.some(item => item.outcome === 'mismatch'))
37
+ summary = { outcome: 'mismatch', reason: '至少一个声明条件与假设输入不符;这不是实际准入检查' };
38
+ else if ((rules.length > 0 && options.permissionHost !== 'present') || conditions.some(item => item.outcome === 'unknown')) {
39
+ summary = { outcome: 'unknown', reason: rules.length > 0 && options.permissionHost !== 'present'
40
+ ? 'PermissionHost 缺失或无法确认;不能推断命令可执行' : '存在缺少依据的条件,不能推断命令可执行' };
41
+ }
42
+ else if (rules.length === 0)
43
+ summary = { outcome: 'not_evaluated', reason: '命令未声明 permit 限制;这不表示已获得执行权限' };
44
+ else if (conditions.some(item => item.outcome === 'not_evaluated'))
45
+ summary = { outcome: 'not_evaluated', reason: '存在未执行的平台或自定义条件,不能推断命令可执行' };
46
+ else
47
+ summary = { outcome: 'match', reason: '已解释的 builtin 条件与假设输入匹配;不代表真实授权或命令可执行' };
48
+ return Object.freeze({ permissionHost: options.permissionHost, adapter: Object.freeze(adapter), rules, summary: Object.freeze(summary),
49
+ unconstrained: Object.freeze(['已解释的 builtin 规则不检查具体 Endpoint、parent 或 thread;保留完整目标不等于这些维度受规则限制']),
50
+ assumptions: Object.freeze(['身份与角色仅用于模拟,未向平台核实', '未执行命令、动态默认值、平台或自定义 checker',
51
+ ...(defaultedRoles
52
+ ? ['canonical 输入未附角色时按命令运行时默认 user 解释;这不是已核验的平台角色'] : [])]),
53
+ });
54
+ }
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
- if (Object.keys(definition.params ?? {})[0] !== hint.name) {
90
- throw new CommandPathSyntaxError(source, `params.${hint.name} must be the first parameter declaration`);
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}`);