@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 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)', 'role(master)'], // 数组 AND;失败返回 denied
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
- [Plugin Runtime 原位迁移](../../../docs/architecture/target-implementation/in-place-migration.md)。
176
+ [Legacy 概念迁移](../../../docs/contributing/legacy-concepts.md)。
177
177
 
178
178
 
179
179
  命令可声明 `descriptionI18n: { en: 'Create a reminder' }` 翻译描述。若静态目录名是中文,可沿用 `alias` 提供英文入口,并用 `preferredAliasI18n` 显式选择英文展示:
@@ -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 {
@@ -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 (!(await this.#permitAllows(match.command, undefined, operationSnapshot)))
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
- if (!(await this.#permitAllows(candidate, source, operationSnapshot)))
226
- return this.#denied(candidate);
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
- if (!(await this.#permitAllows(match.command, source, operationSnapshot)))
237
- return this.#denied(match.command);
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(asMatcherSegments(segments));
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
- async #permitAllows(record, source, operationSnapshot) {
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 true;
297
+ return undefined;
287
298
  if (!source || typeof source !== 'object')
288
- return false;
299
+ return null;
289
300
  const host = this.#resolveHost(operationSnapshot);
290
301
  if (!host)
291
- return false;
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 host.checkAll(permits, subject);
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(asMatcherSegments(segments)) : undefined;
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(asMatcherSegments(segments));
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 directCommands = this.#commands.filter((cmd) => cmd.slot.owner === targetId);
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
- && this.#commands.length > 0
432
- && this.#commands.length <= MENU_PAGE_SIZE
433
- ? this.#commands
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}` : ''}`;
@@ -112,7 +112,19 @@ export interface CommandScene {
112
112
  export interface CommandSender {
113
113
  readonly id: string;
114
114
  readonly name?: string;
115
- readonly role: readonly string[];
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 roles?: readonly string[];
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)`、`role(master)`、`group(123,456)`。
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: Object.freeze({ id: sender.id, ...(sender.name !== undefined ? { name: sender.name } : {}),
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
- const role = resolveRoles(input, metadata);
285
- return Object.freeze({
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
- && Array.isArray(sender.role)
333
- && sender.role.every((item) => typeof item === 'string');
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
- role: Object.freeze([...sender.role]),
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
  }
@@ -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, '正在处理中,请稍候。')).then(() => { }, () => { });
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, isPlatformPermit, parsePermitName, type ParsedPermit, type PermitKind, } from './permit.js';
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, isPlatformPermit, parsePermitName, } from './permit.js';
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': 'canonical 输入未附角色时按命令运行时默认 user 解释;这不是已核验的平台角色', en: 'Canonical input without roles uses the command runtime’s default user role; this is not a verified platform role' },
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
- && Array.isArray(inputSender.role);
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 && Array.isArray(inputSender?.roles) && inputSender.roles.length === 0
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
- * 「私聊,或 owner / admin / master」。`permit` 列表只支持 AND,
10
- * 订阅类命令需要这个 OR 规则时用它,并以命令会话的 `scene` / `sender.role` 为输入。
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, isPlatformPermit, assertPermitSyntax as assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, } from '@zhin.js/permission';
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
- * 「私聊,或 owner / admin / master」。`permit` 列表只支持 AND,
10
- * 订阅类命令需要这个 OR 规则时用它,并以命令会话的 `scene` / `sender.role` 为输入。
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, isPlatformPermit, assertPermitSyntax as assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, } from '@zhin.js/permission';
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.1.17",
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.15",
25
- "@zhin.js/im-contract": "1.1.13",
26
- "@zhin.js/permission": "1.1.15",
27
- "@zhin.js/plugin-runtime": "1.1.23"
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",