@zhin.js/command 1.1.17 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/lib/command-index.d.ts +8 -0
- package/lib/command-index.js +55 -23
- package/lib/definition.d.ts +17 -3
- package/lib/definition.js +23 -38
- package/lib/execution-runtime.d.ts +3 -1
- package/lib/execution-runtime.js +3 -2
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/messages.d.ts +1 -1
- package/lib/messages.js +2 -1
- package/lib/permission-explanation.js +2 -3
- package/lib/permit.d.ts +8 -5
- package/lib/permit.js +8 -5
- package/package.json +6 -7
- package/src/arguments.ts +0 -308
- package/src/command-index.ts +0 -1063
- package/src/command-menu.ts +0 -116
- package/src/definition.ts +0 -816
- package/src/execution-policy.ts +0 -39
- package/src/execution-runtime.ts +0 -202
- package/src/index.ts +0 -31
- package/src/messages.ts +0 -63
- package/src/permission-explanation.ts +0 -61
- package/src/permit.ts +0 -28
- package/src/provider.ts +0 -320
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ import { defineCommand } from 'zhin.js/command';
|
|
|
16
16
|
export default defineCommand({
|
|
17
17
|
description: 'List GitHub issues',
|
|
18
18
|
alias: ['issues'], // 可多词;owner 不参与用户路由
|
|
19
|
-
permit: ['adapter(icqq)', '
|
|
19
|
+
permit: ['adapter(icqq)', 'owner'], // 数组 AND;失败返回 denied
|
|
20
20
|
// shortcut: { '列 issue': {} }, // 全局整句
|
|
21
21
|
execute: ({ args }) => `issues:${args.join(',')}`,
|
|
22
22
|
});
|
|
@@ -154,7 +154,7 @@ parent 和 thread 独立计算。没有完整可信身份或真实 IM operation
|
|
|
154
154
|
新策略的并发数用于新准入,原来已开始的冷却保留到原到期时间。移除策略即停止新请求的预算检查。
|
|
155
155
|
状态只保留在当前进程,进程重启后重置;容量满时拒绝新身份,不驱逐活跃或仍在冷却的记录。
|
|
156
156
|
|
|
157
|
-
|
|
157
|
+
进度默认关闭,只在业务等待阶段发送一次“正在处理中,请稍候。”(按当前操作的语言本地化),走当前平台统一发送链路。
|
|
158
158
|
开始自动最终回复前停止尚未开始的提示,并等待已开始的提示完成;已开始的 I/O 随原 lease 排空。
|
|
159
159
|
执行内显式 `$reply()` 不会停止进度计时。提示失败不重试,也不表示任务取消或远端已经展示。
|
|
160
160
|
`@zhin.js/command/internal/execution` 是 Core 装配使用的内部入口,不是插件创作 API。
|
|
@@ -173,7 +173,7 @@ pnpm --filter @zhin.js/command test
|
|
|
173
173
|
```
|
|
174
174
|
|
|
175
175
|
完整用户契约见[命令创作指南](../../../docs/authoring/commands.md),架构迁移背景见
|
|
176
|
-
[
|
|
176
|
+
[Legacy 概念迁移](../../../docs/contributing/legacy-concepts.md)。
|
|
177
177
|
|
|
178
178
|
|
|
179
179
|
命令可声明 `descriptionI18n: { en: 'Create a reminder' }` 翻译描述。若静态目录名是中文,可沿用 `alias` 提供英文入口,并用 `preferredAliasI18n` 显式选择英文展示:
|
package/lib/command-index.d.ts
CHANGED
|
@@ -46,6 +46,14 @@ export interface CommandDispatchResult {
|
|
|
46
46
|
readonly owner?: PluginId;
|
|
47
47
|
readonly value?: unknown;
|
|
48
48
|
readonly menu?: CommandMenuPage;
|
|
49
|
+
/**
|
|
50
|
+
* `denied` only: the first unmet permit and a one-sentence reason for the sender (e.g. 「机器人不是本群管理员」).
|
|
51
|
+
* Undefined when no Permission host / source answered.
|
|
52
|
+
*/
|
|
53
|
+
readonly denial?: Readonly<{
|
|
54
|
+
permit: string;
|
|
55
|
+
reason: string;
|
|
56
|
+
}>;
|
|
49
57
|
}
|
|
50
58
|
/** Semantic menu navigation; the IM composition layer supplies platform controls. */
|
|
51
59
|
export interface CommandMenuNavigation {
|
package/lib/command-index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { SegmentMatcher, TypeMatcherRegistry, } from 'segment-matcher';
|
|
2
2
|
import { htmlRendererToken, resolveLocale, } from '@zhin.js/plugin-runtime';
|
|
3
3
|
import { bindOperationInput } from '@zhin.js/feature-kit';
|
|
4
|
-
import { permissionHostToken, } from '@zhin.js/permission';
|
|
4
|
+
import { describeDeniedPermit, permissionHostToken, } from '@zhin.js/permission';
|
|
5
5
|
import { commandAdapterMatches, toCommandPermissionSubject } from './permission-explanation.js';
|
|
6
6
|
import { commandUsage, commandHelp, parseCommandArguments, requestsCommandHelp, commandValidationIssue } from './arguments.js';
|
|
7
7
|
import { commandDescription, commandMessages } from './messages.js';
|
|
@@ -175,7 +175,7 @@ export class CommandIndex {
|
|
|
175
175
|
this.#diagnoseParameter(name);
|
|
176
176
|
throw new Error(`Unknown Command: ${name}`);
|
|
177
177
|
}
|
|
178
|
-
if (
|
|
178
|
+
if ((await this.#permitDenial(match.command, undefined, operationSnapshot)) !== undefined)
|
|
179
179
|
throw new Error(`Permission denied for Command: ${match.command.name}`);
|
|
180
180
|
const definition = match.command.slot.definition;
|
|
181
181
|
const supplied = [...match.remaining, ...(args.length > 0 ? [{ type: 'text', data: { text: args.join(' ') } }] : [])];
|
|
@@ -201,7 +201,7 @@ export class CommandIndex {
|
|
|
201
201
|
const prefix = commandPrefix ?? '';
|
|
202
202
|
if (this.#menu) {
|
|
203
203
|
const keyword = this.#menu.keyword ?? commandMessages(locale, 'menuKeyword');
|
|
204
|
-
const menuResult = this.#dispatchMenu(input, { commandPrefix: prefix, snapshot: operationSnapshot, locale, keyword });
|
|
204
|
+
const menuResult = await this.#dispatchMenu(input, { commandPrefix: prefix, snapshot: operationSnapshot, locale, keyword, source });
|
|
205
205
|
if (menuResult !== undefined) {
|
|
206
206
|
return Object.freeze({
|
|
207
207
|
matched: true,
|
|
@@ -222,8 +222,9 @@ export class CommandIndex {
|
|
|
222
222
|
return Object.freeze({ matched: false, status: 'unmatched' });
|
|
223
223
|
if (!commandAdapterMatches(candidate, source))
|
|
224
224
|
return Object.freeze({ matched: false, status: 'unmatched' });
|
|
225
|
-
|
|
226
|
-
|
|
225
|
+
const candidateDenial = await this.#permitDenial(candidate, source, operationSnapshot);
|
|
226
|
+
if (candidateDenial !== undefined)
|
|
227
|
+
return this.#denied(candidate, candidateDenial, locale);
|
|
227
228
|
const help = commandHelp(commandDisplayName(candidate, locale), candidate.slot.definition, prefix, locale);
|
|
228
229
|
const segments = normalizeSegments(typeof input === 'string' ? [{ type: 'text', data: { text: input } }] : input);
|
|
229
230
|
if (requestsCommandHelp(segments))
|
|
@@ -233,8 +234,9 @@ export class CommandIndex {
|
|
|
233
234
|
}
|
|
234
235
|
if (!commandAdapterMatches(match.command, source))
|
|
235
236
|
return Object.freeze({ matched: false, status: 'unmatched' });
|
|
236
|
-
|
|
237
|
-
|
|
237
|
+
const denial = await this.#permitDenial(match.command, source, operationSnapshot);
|
|
238
|
+
if (denial !== undefined)
|
|
239
|
+
return this.#denied(match.command, denial, locale);
|
|
238
240
|
const definition = match.command.slot.definition;
|
|
239
241
|
const authorSource = bindOperationInput(source, match.command.slot.owner, operationSnapshot);
|
|
240
242
|
const help = commandHelp(commandDisplayName(match.command, locale), definition, prefix, locale);
|
|
@@ -265,34 +267,60 @@ export class CommandIndex {
|
|
|
265
267
|
catch { /* Cleanup never replaces the command's actual outcome. */ }
|
|
266
268
|
}
|
|
267
269
|
}
|
|
268
|
-
#denied(record) {
|
|
269
|
-
return Object.freeze({ matched: true, status: 'denied', command: record.name, owner: record.slot.owner
|
|
270
|
+
#denied(record, permit, locale) {
|
|
271
|
+
return Object.freeze({ matched: true, status: 'denied', command: record.name, owner: record.slot.owner,
|
|
272
|
+
...(permit ? { denial: Object.freeze({ permit, reason: describeDeniedPermit(permit, locale) }) } : {}) });
|
|
270
273
|
}
|
|
271
274
|
/** Diagnose only a reliable static route prefix; fully dynamic routes are ordinary chat candidates. */
|
|
272
275
|
#invalidRoute(input) {
|
|
273
276
|
const segments = normalizeSegments(typeof input === 'string' ? [{ type: 'text', data: { text: input } }] : input);
|
|
277
|
+
// segment-matcher clones its input before matching, so one copy serves every route.
|
|
278
|
+
const matcherSegments = asMatcherSegments(segments);
|
|
274
279
|
for (const route of this.#routes) {
|
|
275
280
|
if (!route.record.parameter)
|
|
276
281
|
continue;
|
|
277
|
-
const result = route.literalMatcher?.match(
|
|
282
|
+
const result = route.literalMatcher?.match(matcherSegments);
|
|
278
283
|
if (result && hasCommandBoundary(result.remaining))
|
|
279
284
|
return route.record;
|
|
280
285
|
}
|
|
281
286
|
return undefined;
|
|
282
287
|
}
|
|
283
|
-
|
|
288
|
+
/**
|
|
289
|
+
* ADR 0056 §5: the shared evaluator (PermissionHost) over the three dimensions — sender zhin role,
|
|
290
|
+
* sender platform standing and the Bot's own standing (resolved from the Adapter index cache).
|
|
291
|
+
* `undefined` = allowed; a permit string = the first unmet one; `null` = denied without a reason
|
|
292
|
+
* (no source or no Permission host).
|
|
293
|
+
*/
|
|
294
|
+
async #permitDenial(record, source, operationSnapshot, cachedOnly = false) {
|
|
284
295
|
const permits = record.permit;
|
|
285
296
|
if (!permits || permits.length === 0)
|
|
286
|
-
return
|
|
297
|
+
return undefined;
|
|
287
298
|
if (!source || typeof source !== 'object')
|
|
288
|
-
return
|
|
299
|
+
return null;
|
|
289
300
|
const host = this.#resolveHost(operationSnapshot);
|
|
290
301
|
if (!host)
|
|
291
|
-
return
|
|
302
|
+
return null;
|
|
292
303
|
const subject = toCommandPermissionSubject(source);
|
|
293
304
|
if (!subject.sender?.id && !subject.scene?.id && !subject.adapter && !subject.endpoint)
|
|
305
|
+
return null;
|
|
306
|
+
const options = { source, ...(cachedOnly ? { cachedOnly } : {}) };
|
|
307
|
+
if (await host.checkAll(permits, subject, options))
|
|
308
|
+
return undefined;
|
|
309
|
+
// Denied: name the first unmet permit for the reply (same evaluator; Bot standing is cached now).
|
|
310
|
+
try {
|
|
311
|
+
return (await host.firstDenied?.(permits, subject, options)) ?? null;
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/** Whether the menu should list `record` for `source` (adapter restriction + permits). */
|
|
318
|
+
async #listable(record, source, operationSnapshot) {
|
|
319
|
+
if (source === undefined)
|
|
320
|
+
return true;
|
|
321
|
+
if (!commandAdapterMatches(record, source))
|
|
294
322
|
return false;
|
|
295
|
-
return
|
|
323
|
+
return (await this.#permitDenial(record, source, operationSnapshot)) === undefined;
|
|
296
324
|
}
|
|
297
325
|
#resolveHost(operationSnapshot) {
|
|
298
326
|
try {
|
|
@@ -322,14 +350,16 @@ export class CommandIndex {
|
|
|
322
350
|
: input);
|
|
323
351
|
if (segments.length === 0)
|
|
324
352
|
return undefined;
|
|
353
|
+
// segment-matcher clones its input before matching, so one copy serves every route.
|
|
354
|
+
const matcherSegments = asMatcherSegments(segments);
|
|
325
355
|
for (const route of this.#routes) {
|
|
326
356
|
// Options may appear between the literal route and a directory-bound parameter.
|
|
327
|
-
const literalResult = !exact ? route.literalMatcher?.match(
|
|
357
|
+
const literalResult = !exact ? route.literalMatcher?.match(matcherSegments) : undefined;
|
|
328
358
|
const helpRequested = literalResult && hasCommandBoundary(literalResult.remaining)
|
|
329
359
|
&& requestsCommandHelp(normalizeSegments(literalResult.remaining));
|
|
330
360
|
const parseDeclaredArguments = !exact && route.record.parameter && route.literalMatcher !== undefined
|
|
331
361
|
&& (Object.hasOwn(route.record.slot.definition.params ?? {}, route.record.parameter.name) || helpRequested);
|
|
332
|
-
const result = parseDeclaredArguments ? literalResult : route.matcher.match(
|
|
362
|
+
const result = parseDeclaredArguments ? literalResult : route.matcher.match(matcherSegments);
|
|
333
363
|
if (!result || !hasCommandBoundary(result.remaining))
|
|
334
364
|
continue;
|
|
335
365
|
const parameter = route.record.parameter;
|
|
@@ -380,7 +410,7 @@ export class CommandIndex {
|
|
|
380
410
|
// ==========================================================================
|
|
381
411
|
// 内置菜单命令
|
|
382
412
|
// ==========================================================================
|
|
383
|
-
#dispatchMenu(input, context) {
|
|
413
|
+
async #dispatchMenu(input, context) {
|
|
384
414
|
const { commandPrefix, keyword, locale } = context;
|
|
385
415
|
const text = typeof input === 'string' ? input.trim() : exactMessageText(input);
|
|
386
416
|
if (text === undefined)
|
|
@@ -414,7 +444,7 @@ export class CommandIndex {
|
|
|
414
444
|
}
|
|
415
445
|
return undefined;
|
|
416
446
|
}
|
|
417
|
-
#buildMenu({ pluginKey, page = 1 }, { commandPrefix, keyword, snapshot, locale }) {
|
|
447
|
+
async #buildMenu({ pluginKey, page = 1 }, { commandPrefix, keyword, snapshot, locale, source }) {
|
|
418
448
|
const root = snapshot.root;
|
|
419
449
|
const targetId = pluginKey
|
|
420
450
|
? `${root}/${pluginKey.split('.').join('/')}`
|
|
@@ -422,15 +452,17 @@ export class CommandIndex {
|
|
|
422
452
|
const node = snapshot.tree.get(targetId);
|
|
423
453
|
if (!node)
|
|
424
454
|
return { value: commandMessages(locale, 'missingPlugin', { plugin: pluginKey ?? '' }) };
|
|
425
|
-
const
|
|
455
|
+
const listable = await Promise.all(this.#commands.map(async (cmd) => (await this.#listable(cmd, source, snapshot)) ? cmd : undefined));
|
|
456
|
+
const available = listable.filter((cmd) => cmd !== undefined);
|
|
457
|
+
const directCommands = available.filter((cmd) => cmd.slot.owner === targetId);
|
|
426
458
|
const children = node.children
|
|
427
459
|
.map((childId) => snapshot.tree.get(childId))
|
|
428
460
|
.filter((child) => !!child);
|
|
429
461
|
const effectiveCommands = targetId === root
|
|
430
462
|
&& directCommands.length === 0
|
|
431
|
-
&&
|
|
432
|
-
&&
|
|
433
|
-
?
|
|
463
|
+
&& available.length > 0
|
|
464
|
+
&& available.length <= MENU_PAGE_SIZE
|
|
465
|
+
? available
|
|
434
466
|
: directCommands;
|
|
435
467
|
const pages = Math.max(1, Math.ceil((effectiveCommands.length + children.length) / MENU_PAGE_SIZE));
|
|
436
468
|
const invocation = `${commandPrefix}${keyword}${pluginKey ? ` ${pluginKey}` : ''}`;
|
package/lib/definition.d.ts
CHANGED
|
@@ -112,7 +112,19 @@ export interface CommandScene {
|
|
|
112
112
|
export interface CommandSender {
|
|
113
113
|
readonly id: string;
|
|
114
114
|
readonly name?: string;
|
|
115
|
-
|
|
115
|
+
/** Highest zhin chat role effective in this conversation (ADR 0056), set by the Runtime. */
|
|
116
|
+
readonly chatRole?: 'owner' | 'admin' | 'trusted';
|
|
117
|
+
/** Per role, the topmost level it is held at on this conversation's chain, set by the Runtime. */
|
|
118
|
+
readonly chatRoleLevels?: Readonly<Partial<Record<'owner' | 'admin' | 'trusted', 'global' | 'adapter' | 'endpoint' | 'conversation'>>>;
|
|
119
|
+
/** Normalized platform standing; absent in private chats. */
|
|
120
|
+
readonly platform?: Readonly<{
|
|
121
|
+
standing: 'owner' | 'admin' | 'member' | 'guest';
|
|
122
|
+
roles?: readonly Readonly<{
|
|
123
|
+
id: string;
|
|
124
|
+
name?: string;
|
|
125
|
+
}>[];
|
|
126
|
+
permissions?: readonly string[];
|
|
127
|
+
}>;
|
|
116
128
|
}
|
|
117
129
|
/**
|
|
118
130
|
* 命令侧入站会话契约(结构对齐 `@zhin.js/im-contract` 的 ConversationRef;
|
|
@@ -147,7 +159,9 @@ export interface CommandMessage {
|
|
|
147
159
|
readonly sender?: {
|
|
148
160
|
readonly id: string;
|
|
149
161
|
readonly name?: string;
|
|
150
|
-
readonly
|
|
162
|
+
readonly chatRole?: CommandSender['chatRole'];
|
|
163
|
+
readonly chatRoleLevels?: CommandSender['chatRoleLevels'];
|
|
164
|
+
readonly platform?: CommandSender['platform'];
|
|
151
165
|
};
|
|
152
166
|
readonly id?: string;
|
|
153
167
|
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
@@ -265,7 +279,7 @@ export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput
|
|
|
265
279
|
readonly preferredAliasI18n?: Readonly<Partial<Record<Locale, string>>>;
|
|
266
280
|
/**
|
|
267
281
|
* 内置 permit DSL(AND)。单项内逗号为 OR。
|
|
268
|
-
* 例:`adapter(icqq)`、`
|
|
282
|
+
* 例:`adapter(icqq)`、`owner`、`trusted`、`platform:admin`、`group(123,456)`。
|
|
269
283
|
*/
|
|
270
284
|
readonly permit?: readonly string[];
|
|
271
285
|
/** Optional full-operation cooldown, concurrency and one-shot progress feedback. */
|
package/lib/definition.js
CHANGED
|
@@ -213,14 +213,12 @@ export function resolveCommandSession(input) {
|
|
|
213
213
|
if (operation) {
|
|
214
214
|
const endpoint = input && typeof input === 'object' ? input.endpointId : undefined;
|
|
215
215
|
const sender = operation.sender;
|
|
216
|
-
const roles = [...new Set([...sender?.authorizationRoles ?? [], ...sender?.platformRoles ?? []])];
|
|
217
216
|
return Object.freeze({
|
|
218
217
|
adapter: operation.scene.endpoint.adapter,
|
|
219
218
|
...(typeof endpoint === 'string' && endpoint ? { endpoint } : {}),
|
|
220
219
|
scene: Object.freeze({ id: operation.scene.id, type: operation.scene.kind,
|
|
221
220
|
...(operation.scene.name !== undefined ? { name: operation.scene.name } : {}) }),
|
|
222
|
-
...(sender ? { sender:
|
|
223
|
-
role: Object.freeze(roles.length ? roles : ['user']) }) } : {}),
|
|
221
|
+
...(sender ? { sender: freezeSender(sender) } : {}),
|
|
224
222
|
});
|
|
225
223
|
}
|
|
226
224
|
if (!isCommandMessageLike(input))
|
|
@@ -281,38 +279,8 @@ export function resolveCommandSender(input, metadata) {
|
|
|
281
279
|
if (!id)
|
|
282
280
|
return undefined;
|
|
283
281
|
const name = input.sender?.name || firstString(metadata?.nickname, metadata?.senderName, metadata?.name);
|
|
284
|
-
|
|
285
|
-
return
|
|
286
|
-
id,
|
|
287
|
-
...(name !== undefined ? { name } : {}),
|
|
288
|
-
role,
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
function resolveRoles(input, metadata) {
|
|
292
|
-
if (input.sender?.roles?.length) {
|
|
293
|
-
return Object.freeze([...input.sender.roles]);
|
|
294
|
-
}
|
|
295
|
-
const roles = [];
|
|
296
|
-
const push = (value) => {
|
|
297
|
-
if (typeof value !== 'string')
|
|
298
|
-
return;
|
|
299
|
-
const trimmed = value.trim();
|
|
300
|
-
if (trimmed && !roles.includes(trimmed))
|
|
301
|
-
roles.push(trimmed);
|
|
302
|
-
};
|
|
303
|
-
if (Array.isArray(metadata?.roles)) {
|
|
304
|
-
for (const item of metadata.roles)
|
|
305
|
-
push(item);
|
|
306
|
-
}
|
|
307
|
-
push(metadata?.senderRole);
|
|
308
|
-
push(metadata?.role);
|
|
309
|
-
if (metadata?.isMaster === true)
|
|
310
|
-
push('master');
|
|
311
|
-
if (metadata?.isTrusted === true)
|
|
312
|
-
push('trusted');
|
|
313
|
-
if (roles.length === 0)
|
|
314
|
-
roles.push('user');
|
|
315
|
-
return Object.freeze(roles);
|
|
282
|
+
// Roles come only from the Runtime-set sender fields, never from metadata (ADR 0056).
|
|
283
|
+
return freezeSender({ ...(input.sender ?? {}), id, ...(name !== undefined ? { name } : {}) });
|
|
316
284
|
}
|
|
317
285
|
function isCommandScene(value) {
|
|
318
286
|
if (!value || typeof value !== 'object')
|
|
@@ -329,14 +297,31 @@ function isCommandSender(value) {
|
|
|
329
297
|
const sender = value;
|
|
330
298
|
return typeof sender.id === 'string'
|
|
331
299
|
&& sender.id.length > 0
|
|
332
|
-
&&
|
|
333
|
-
|
|
300
|
+
&& (sender.chatRole === undefined || isChatRoleName(sender.chatRole));
|
|
301
|
+
}
|
|
302
|
+
function isChatRoleName(value) {
|
|
303
|
+
return value === 'owner' || value === 'admin' || value === 'trusted';
|
|
304
|
+
}
|
|
305
|
+
const STANDINGS = new Set(['owner', 'admin', 'member', 'guest']);
|
|
306
|
+
const LEVELS = new Set(['global', 'adapter', 'endpoint', 'conversation']);
|
|
307
|
+
function chatRoleLevelsOf(value) {
|
|
308
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
309
|
+
return undefined;
|
|
310
|
+
const levels = Object.fromEntries(['owner', 'admin', 'trusted']
|
|
311
|
+
.filter(role => LEVELS.has(String(value[role])))
|
|
312
|
+
.map(role => [role, value[role]]));
|
|
313
|
+
return Object.keys(levels).length ? Object.freeze(levels) : undefined;
|
|
334
314
|
}
|
|
335
315
|
function freezeSender(sender) {
|
|
316
|
+
const platform = sender.platform && typeof sender.platform === 'object'
|
|
317
|
+
&& STANDINGS.has(String(sender.platform.standing))
|
|
318
|
+
? sender.platform : undefined;
|
|
336
319
|
return Object.freeze({
|
|
337
320
|
id: sender.id,
|
|
338
321
|
...(sender.name !== undefined ? { name: sender.name } : {}),
|
|
339
|
-
|
|
322
|
+
...(isChatRoleName(sender.chatRole) ? { chatRole: sender.chatRole } : {}),
|
|
323
|
+
...(isChatRoleName(sender.chatRole) && chatRoleLevelsOf(sender.chatRoleLevels) ? { chatRoleLevels: chatRoleLevelsOf(sender.chatRoleLevels) } : {}),
|
|
324
|
+
...(platform ? { platform } : {}),
|
|
340
325
|
});
|
|
341
326
|
}
|
|
342
327
|
function firstString(...values) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Internal composition contract. Not part of the command authoring facade. */
|
|
2
|
-
import { type PluginId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
|
|
2
|
+
import { type Locale, type PluginId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
|
|
3
3
|
import type { CommandExecutionLimit, CommandExecutionPolicy } from './execution-policy.js';
|
|
4
4
|
interface ConversationIdentity {
|
|
5
5
|
readonly endpoint: {
|
|
@@ -55,6 +55,8 @@ export declare class CommandExecutionBudget {
|
|
|
55
55
|
readonly conversation: ConversationIdentity;
|
|
56
56
|
readonly senderId?: string;
|
|
57
57
|
readonly retirementSignal: AbortSignal;
|
|
58
|
+
/** The operation's language for progress feedback; defaults to the catalog default. */
|
|
59
|
+
readonly locale?: Locale;
|
|
58
60
|
readonly reply: (owner: PluginId, text: string) => Promise<unknown>;
|
|
59
61
|
}): CommandOperationController;
|
|
60
62
|
}
|
package/lib/execution-runtime.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Internal composition contract. Not part of the command authoring facade. */
|
|
2
2
|
import { createToken } from '@zhin.js/plugin-runtime';
|
|
3
|
+
import { commandMessages } from './messages.js';
|
|
3
4
|
/** @internal Admission-only Root resource; no bind or finish authority is published. */
|
|
4
5
|
export const commandExecutionAdmissionToken = createToken('zhin.command.execution-admission');
|
|
5
6
|
/** @internal Root-instance-owned state shared by projections and draining retired generations. */
|
|
@@ -26,7 +27,7 @@ export class CommandExecutionBudget {
|
|
|
26
27
|
const operation = {
|
|
27
28
|
snapshot: input.snapshot, key: valid ? Object.freeze(values) : undefined,
|
|
28
29
|
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
|
+
locale: input.locale, reply: input.reply, releases: [], timers: new Set(), feedback: [], accepted: new Set(), sends: new Set(),
|
|
30
31
|
};
|
|
31
32
|
const stop = () => {
|
|
32
33
|
operation.progressStopped = true;
|
|
@@ -136,7 +137,7 @@ export class CommandExecutionBudget {
|
|
|
136
137
|
// Observe failure once. Never retry uncertain delivery and never expose the exception.
|
|
137
138
|
let send;
|
|
138
139
|
try {
|
|
139
|
-
send = Promise.resolve(operation.reply(owner, '
|
|
140
|
+
send = Promise.resolve(operation.reply(owner, commandMessages(operation.locale, 'progress'))).then(() => { }, () => { });
|
|
140
141
|
}
|
|
141
142
|
catch {
|
|
142
143
|
send = Promise.resolve();
|
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 { CONVERSATION_MANAGER_PERMIT, canManageConversation, assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit,
|
|
10
|
+
export { CONVERSATION_MANAGER_PERMIT, canManageConversation, assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, removedPermitReplacement, parsePermitName, type ParsedPermit, type PermitKind, } from './permit.js';
|
|
11
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, CommandValidationIssueCode } 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 { CONVERSATION_MANAGER_PERMIT, canManageConversation, assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit,
|
|
10
|
+
export { CONVERSATION_MANAGER_PERMIT, canManageConversation, assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, removedPermitReplacement, parsePermitName, } from './permit.js';
|
|
11
11
|
export { bindCommandSource, CommandPathSyntaxError, commandFeatureId, default as commandFeature, } from './provider.js';
|
|
12
12
|
export { default } from './provider.js';
|
package/lib/messages.d.ts
CHANGED
|
@@ -5,4 +5,4 @@ export declare function commandDescription(value: Readonly<{
|
|
|
5
5
|
descriptionI18n?: Readonly<Partial<Record<Locale, string>>>;
|
|
6
6
|
}>, locale: Locale): string | undefined;
|
|
7
7
|
/** Static dictionaries; the caller supplies the operation's language. */
|
|
8
|
-
export declare const commandMessages: (locale: Locale | undefined, key: "choices" | "defaultValue" | "menuKeyword" | "invalidPage" | "missingPlugin" | "pageOutOfRange" | "menuTitle" | "menuSummary" | "childPlugins" | "emptyMenu" | "pluginHint" | "previousPage" | "nextPage" | "guideTitle" | "guideSubtitle" | "commandCount" | "pluginCount" | "commandIndex" | "pluginIndex" | "guideEmpty" | "helpHint" | "genericHelpHint" | "expectedType" | "expectedArray" | "minimum" | "maximum" | "invalidFormat" | "invalidValue" | "unknownOption" | "duplicateOption" | "defaultUndefined" | "parameterUndefined" | "missingOption" | "missingRequiredParam" | "unexpectedArguments" | "invalidParameter" | "dynamicValue" | "noAdapter" | "missingEndpoint" | "adapterComparison" | "conditionMismatch" | "missingPermissionHost" | "missingEvidence" | "noPermit" | "uncheckedConditions" | "conditionsMatch" | "unconstrained" | "simulatedIdentity" | "unevaluatedCode" | "defaultedRole", values?: import("@zhin.js/plugin-runtime").MessageValues) => string;
|
|
8
|
+
export declare const commandMessages: (locale: Locale | undefined, key: "choices" | "defaultValue" | "menuKeyword" | "progress" | "invalidPage" | "missingPlugin" | "pageOutOfRange" | "menuTitle" | "menuSummary" | "childPlugins" | "emptyMenu" | "pluginHint" | "previousPage" | "nextPage" | "guideTitle" | "guideSubtitle" | "commandCount" | "pluginCount" | "commandIndex" | "pluginIndex" | "guideEmpty" | "helpHint" | "genericHelpHint" | "expectedType" | "expectedArray" | "minimum" | "maximum" | "invalidFormat" | "invalidValue" | "unknownOption" | "duplicateOption" | "defaultUndefined" | "parameterUndefined" | "missingOption" | "missingRequiredParam" | "unexpectedArguments" | "invalidParameter" | "dynamicValue" | "noAdapter" | "missingEndpoint" | "adapterComparison" | "conditionMismatch" | "missingPermissionHost" | "missingEvidence" | "noPermit" | "uncheckedConditions" | "conditionsMatch" | "unconstrained" | "simulatedIdentity" | "unevaluatedCode" | "defaultedRole", values?: import("@zhin.js/plugin-runtime").MessageValues) => string;
|
package/lib/messages.js
CHANGED
|
@@ -6,6 +6,7 @@ export function commandDescription(value, locale) {
|
|
|
6
6
|
/** Static dictionaries; the caller supplies the operation's language. */
|
|
7
7
|
export const commandMessages = createMessages({
|
|
8
8
|
menuKeyword: { 'zh-CN': '菜单', en: 'menu' },
|
|
9
|
+
progress: { 'zh-CN': '正在处理中,请稍候。', en: 'Working on it, please wait.' },
|
|
9
10
|
invalidPage: { 'zh-CN': '页码必须是正整数。用法:{command} [插件名] --page <页码>', en: 'The page must be a positive integer. Usage: {command} [plugin] --page <page>' },
|
|
10
11
|
missingPlugin: { 'zh-CN': '未找到插件: {plugin}', en: 'Plugin not found: {plugin}' },
|
|
11
12
|
pageOutOfRange: { 'zh-CN': '页码超出范围:共 {pages} 页。请使用「{command} --page <页码>」。', en: 'Page out of range: {pages} pages available. Use {command} --page <page>.' },
|
|
@@ -54,5 +55,5 @@ export const commandMessages = createMessages({
|
|
|
54
55
|
unconstrained: { 'zh-CN': '已解释的 builtin 规则不检查具体 Endpoint、parent 或 thread;保留完整目标不等于这些维度受规则限制', en: 'The explained builtin rules do not constrain a specific Endpoint, parent or thread; retaining the full target does not add such restrictions' },
|
|
55
56
|
simulatedIdentity: { 'zh-CN': '身份与角色仅用于模拟,未向平台核实', en: 'Identity and roles are hypothetical and have not been verified with the platform' },
|
|
56
57
|
unevaluatedCode: { 'zh-CN': '未执行命令、动态默认值、平台或自定义 checker', en: 'Commands, dynamic defaults, platform checks and custom checkers have not been executed' },
|
|
57
|
-
defaultedRole: { 'zh-CN': '
|
|
58
|
+
defaultedRole: { 'zh-CN': '输入未附聊天角色时按普通用户(无 owner / admin / trusted)解释;这不是已核验的角色', en: 'Input without a chat role is treated as an ordinary user (no owner / admin / trusted); this is not a verified role' },
|
|
58
59
|
});
|
|
@@ -20,14 +20,13 @@ export function explainCommandPermissions(command, options) {
|
|
|
20
20
|
const inputSender = options.source && typeof options.source === 'object'
|
|
21
21
|
? options.source.sender : undefined;
|
|
22
22
|
const structuredRoles = inputSender && typeof inputSender === 'object'
|
|
23
|
-
&&
|
|
23
|
+
&& 'chatRole' in inputSender;
|
|
24
24
|
// A sender-only assumption has no synthetic conversation. Apply the real canonical
|
|
25
25
|
// sender defaults without inventing an Endpoint or treating unknown roles as known.
|
|
26
26
|
const senderOnly = options.source && typeof options.source === 'object' && !('conversation' in options.source) && !structuredRoles
|
|
27
27
|
? resolveCommandSender(options.source, undefined) : undefined;
|
|
28
28
|
const subject = senderOnly ? { ...projected, sender: senderOnly } : projected;
|
|
29
|
-
const defaultedRoles = options.rolesKnown &&
|
|
30
|
-
&& subject.sender?.role.length === 1 && subject.sender.role[0] === 'user';
|
|
29
|
+
const defaultedRoles = options.rolesKnown === true && !!subject.sender && subject.sender.chatRole === undefined;
|
|
31
30
|
const actualAdapter = operationClientAdapter(options.source);
|
|
32
31
|
const adapter = !command.adapter
|
|
33
32
|
? { outcome: 'not_evaluated', reason: commandMessages(locale, 'noAdapter') }
|
package/lib/permit.d.ts
CHANGED
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
* 保留 CommandSession 兼容签名供 command-index 过渡使用。
|
|
4
4
|
*/
|
|
5
5
|
import { type PermissionSubject } from '@zhin.js/permission';
|
|
6
|
-
/** 会话管理者:群主 / 群管理员 / 机器人主人(适配器投影的规范角色名)。 */
|
|
7
|
-
export declare const CONVERSATION_MANAGER_PERMIT = "role(owner,admin,master)";
|
|
8
6
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* 会话管理者:本会话层级链上的 zhin 聊天角色 `admin`(含 `owner`)。平台群主 / 管理员不自动具备
|
|
8
|
+
* (ADR 0056 §4,2026-11-12 修订):订阅等 zhin 功能需要被授予的角色。
|
|
9
|
+
*/
|
|
10
|
+
export declare const CONVERSATION_MANAGER_PERMIT = "admin";
|
|
11
|
+
/**
|
|
12
|
+
* 「私聊,或本会话 admin」。`permit` 列表只支持 AND,
|
|
13
|
+
* 订阅类命令需要这个 OR 规则时用它,并以命令会话的 `scene` / `sender.chatRole` 为输入。
|
|
11
14
|
*/
|
|
12
15
|
export declare function canManageConversation(subject: PermissionSubject): boolean;
|
|
13
|
-
export { type PermitKind, type ParsedPermit, parsePermitName, isBuiltinPermit,
|
|
16
|
+
export { type PermitKind, type ParsedPermit, parsePermitName, isBuiltinPermit, removedPermitReplacement, assertPermitSyntax as assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, } from '@zhin.js/permission';
|
package/lib/permit.js
CHANGED
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
* 保留 CommandSession 兼容签名供 command-index 过渡使用。
|
|
4
4
|
*/
|
|
5
5
|
import { checkBuiltinPermit } from '@zhin.js/permission';
|
|
6
|
-
/** 会话管理者:群主 / 群管理员 / 机器人主人(适配器投影的规范角色名)。 */
|
|
7
|
-
export const CONVERSATION_MANAGER_PERMIT = 'role(owner,admin,master)';
|
|
8
6
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* 会话管理者:本会话层级链上的 zhin 聊天角色 `admin`(含 `owner`)。平台群主 / 管理员不自动具备
|
|
8
|
+
* (ADR 0056 §4,2026-11-12 修订):订阅等 zhin 功能需要被授予的角色。
|
|
9
|
+
*/
|
|
10
|
+
export const CONVERSATION_MANAGER_PERMIT = 'admin';
|
|
11
|
+
/**
|
|
12
|
+
* 「私聊,或本会话 admin」。`permit` 列表只支持 AND,
|
|
13
|
+
* 订阅类命令需要这个 OR 规则时用它,并以命令会话的 `scene` / `sender.chatRole` 为输入。
|
|
11
14
|
*/
|
|
12
15
|
export function canManageConversation(subject) {
|
|
13
16
|
return checkBuiltinPermit('private()', subject)
|
|
14
17
|
|| checkBuiltinPermit(CONVERSATION_MANAGER_PERMIT, subject);
|
|
15
18
|
}
|
|
16
|
-
export { parsePermitName, isBuiltinPermit,
|
|
19
|
+
export { parsePermitName, isBuiltinPermit, removedPermitReplacement, assertPermitSyntax as assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, } from '@zhin.js/permission';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/command",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Convention-based Command Feature for Zhin Plugin Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -16,15 +16,14 @@
|
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
|
-
"lib"
|
|
20
|
-
"src"
|
|
19
|
+
"lib"
|
|
21
20
|
],
|
|
22
21
|
"dependencies": {
|
|
23
22
|
"segment-matcher": "^1.0.5",
|
|
24
|
-
"@zhin.js/feature-kit": "1.1.
|
|
25
|
-
"@zhin.js/
|
|
26
|
-
"@zhin.js/permission": "1.
|
|
27
|
-
"@zhin.js/
|
|
23
|
+
"@zhin.js/feature-kit": "1.1.16",
|
|
24
|
+
"@zhin.js/plugin-runtime": "1.2.0",
|
|
25
|
+
"@zhin.js/permission": "1.2.0",
|
|
26
|
+
"@zhin.js/im-contract": "1.1.14"
|
|
28
27
|
},
|
|
29
28
|
"devDependencies": {
|
|
30
29
|
"@types/node": "^26.1.2",
|