@xmanrui/dsh-im 4.33.0 → 4.34.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.33.0",
3
+ "version": "4.34.1",
4
4
  "icon": "./assets/logo-plugin-message-link-gradient.webp",
5
5
  "description": "把微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord、Matrix接入DSH。 Connect Telegram, WhatsApp, Slack, Discord, Matrix, WeChat, Feishu, WeCom, DingTalk, and QQ to DSH.",
6
6
  "keywords": [
@@ -8,6 +8,7 @@ import {
8
8
  import { h, localizeText } from './i18n.js';
9
9
 
10
10
  export const ACCESS_POLICY_ENDPOINT = 'bot.access-policy.set';
11
+ const CONFIRMED_OWNER_CHANNELS = new Set(['weixin', 'feishu', 'qq', 'whatsapp', 'discord']);
11
12
 
12
13
  export const ACCESS_CHANNEL_DEFINITIONS = Object.freeze({
13
14
  weixin: Object.freeze({
@@ -17,9 +18,9 @@ export const ACCESS_CHANNEL_DEFINITIONS = Object.freeze({
17
18
  }),
18
19
  feishu: Object.freeze({
19
20
  directUserLabel: '飞书 Open ID',
20
- directPlaceholder: 'ou_xxx',
21
+ directPlaceholder: '填写使用者的 Open ID(ou_xxx),不是机器人 ID',
21
22
  groupUserLabel: '群成员 Open ID',
22
- groupPlaceholder: 'ou_xxx',
23
+ groupPlaceholder: '填写使用者的 Open ID(ou_xxx),不是机器人 ID',
23
24
  }),
24
25
  dingtalk: Object.freeze({
25
26
  directUserLabel: '钉钉用户 ID',
@@ -125,6 +126,8 @@ function ScenePolicyEditor({
125
126
  placeholder,
126
127
  disabled = false,
127
128
  unsupported = false,
129
+ ownerHelp,
130
+ identityHelp,
128
131
  onChange,
129
132
  }) {
130
133
  const ownerHelpId = React.useId();
@@ -165,7 +168,7 @@ function ScenePolicyEditor({
165
168
  id: ownerHelpId,
166
169
  className: 'dim-channelTooltip dim-accessHelpTooltip',
167
170
  role: 'tooltip',
168
- }, '原所有者或扫码接入者始终可以访问并执行命令;以下设置仅约束其他用户。')))),
171
+ }, ownerHelp, identityHelp ? h('br') : null, identityHelp)))),
169
172
  unsupported
170
173
  ? h('div', { className: 'dim-accessUnsupported', role: 'note' },
171
174
  h('strong', null, '当前渠道不支持群聊'),
@@ -271,6 +274,12 @@ function ScenePolicyEditor({
271
274
 
272
275
  export function AccessPolicySettingsPage({ channel, account, rpcCall, onSaved }) {
273
276
  const definition = ACCESS_CHANNEL_DEFINITIONS[channel];
277
+ const ownerHelp = CONFIRMED_OWNER_CHANNELS.has(channel)
278
+ ? '已由接入流程确认的所有者可以访问并执行命令,不受本页限制。未识别到所有者身份时,按本页访问设置执行。'
279
+ : '原所有者或扫码接入者始终可以访问并执行命令;以下设置仅约束其他用户。';
280
+ const identityHelp = channel === 'feishu'
281
+ ? '白名单请填写使用者的 Open ID,不是机器人的 Open ID。仅保存通配符的接入配置不包含所有者身份。'
282
+ : null;
274
283
  const initialPolicy = normalizeAccessPolicy(account?.accessPolicy);
275
284
  const initialKey = JSON.stringify(initialPolicy);
276
285
  const [draft, setDraft] = React.useState(() => clonePolicy(
@@ -333,6 +342,8 @@ export function AccessPolicySettingsPage({ channel, account, rpcCall, onSaved })
333
342
  policy: draft.direct,
334
343
  userLabel: definition.directUserLabel,
335
344
  placeholder: definition.directPlaceholder,
345
+ ownerHelp,
346
+ identityHelp,
336
347
  disabled: saving,
337
348
  onChange: (direct) => { setDraft((current) => ({ ...current, direct })); setFeedback(null); },
338
349
  }),
@@ -342,6 +353,10 @@ export function AccessPolicySettingsPage({ channel, account, rpcCall, onSaved })
342
353
  policy: draft.group,
343
354
  userLabel: definition.groupUserLabel ?? definition.directUserLabel,
344
355
  placeholder: definition.groupPlaceholder ?? definition.directPlaceholder,
356
+ ownerHelp,
357
+ identityHelp: channel === 'qq'
358
+ ? '扫码获得的是私聊身份,群聊只有匹配已确认的所有者身份时才豁免;其他成员按群聊白名单设置执行。'
359
+ : identityHelp,
345
360
  disabled: saving || definition.groupSupported === false,
346
361
  unsupported: definition.groupSupported === false,
347
362
  onChange: (group) => { setDraft((current) => ({ ...current, group })); setFeedback(null); },
@@ -68,6 +68,11 @@ const EN = Object.freeze({
68
68
  '尚未添加用户': 'No users added',
69
69
  '当前渠道不支持群聊': 'Group chat is not supported by this channel',
70
70
  '原所有者或扫码接入者始终可以访问并执行命令;以下设置仅约束其他用户。': 'The original owner or QR-code operator can always access the bot and run commands; the settings below apply only to other users.',
71
+ '已由接入流程确认的所有者可以访问并执行命令,不受本页限制。未识别到所有者身份时,按本页访问设置执行。': 'Owners confirmed during connection setup can access the bot and run commands regardless of these settings. When no owner has been identified, these access settings apply.',
72
+ '白名单请填写使用者的 Open ID,不是机器人的 Open ID。仅保存通配符的接入配置不包含所有者身份。': 'Enter the user’s Open ID in the allowlist, not the bot’s Open ID. A connection configured with only a wildcard has no identified owner.',
73
+ '扫码获得的是私聊身份,群聊只有匹配已确认的所有者身份时才豁免;其他成员按群聊白名单设置执行。': 'QR setup identifies the owner for direct messages. In groups, only a matching confirmed owner identity is exempt; other members follow the group allowlist settings.',
74
+ '填写使用者的 Open ID(ou_xxx),不是机器人 ID': 'Enter the user’s Open ID (ou_xxx), not the bot ID',
75
+ '这里需要填写用户的 Open ID,不能填写机器人自己的 Open ID。': 'Enter a user Open ID here, not the bot’s own Open ID.',
71
76
  '此区域无需配置,保存私聊设置时会保留现有群聊策略。': 'No setup is needed here. Saving direct-message settings keeps the existing group policy.',
72
77
  '访问设置已保存。': 'Access settings saved.',
73
78
  '访问设置暂不可用。': 'Access settings are currently unavailable.',
@@ -1,4 +1,5 @@
1
1
  import { getImageInputSettingsStore } from '../../../../src/channels/shared/image-input-settings-store.mjs';
2
+ import { t } from '../../../../src/channels/shared/i18n.mjs';
2
3
  import { homedir } from 'node:os';
3
4
  import { join, resolve } from 'node:path';
4
5
  import { unlink } from 'node:fs/promises';
@@ -291,6 +292,16 @@ export async function createProductionController(ctx, config = {}, internals = {
291
292
  stateFor: stateForBotId,
292
293
  agentPresetCatalog,
293
294
  modelCatalog,
295
+ validateAccessPolicyForBot(botId, policy) {
296
+ const botOpenId = configStore.getBot(botId)?.botOpenId;
297
+ if (!botOpenId) return;
298
+ if ([policy.direct, policy.group].some((scope) => scope.mode === 'allowlist'
299
+ && scope.allowlist.users.some((user) => user.id === botOpenId))) {
300
+ const error = new TypeError(t('这里需要填写用户的 Open ID,不能填写机器人自己的 Open ID。'));
301
+ error.code = 'access-policy-invalid';
302
+ throw error;
303
+ }
304
+ },
294
305
  });
295
306
 
296
307
  const supervisor = createSupervisor({
@@ -109,6 +109,7 @@ export function privilegedSenderIdsFor(channel, config = {}) {
109
109
  }
110
110
  if (key === 'qq') return cleanIds(config.ownerUserOpenid).filter((id) => id !== '*');
111
111
  if (key === 'whatsapp') return cleanIds(config.accountJid);
112
+ if (key === 'discord') return cleanIds(config.ownerUserId);
112
113
  return [];
113
114
  }
114
115
 
@@ -26,6 +26,7 @@ export function publicWorkspaceError(error) {
26
26
  'model-selection-unavailable',
27
27
  'model-reasoning-unavailable',
28
28
  'context-enhancement-invalid',
29
+ 'access-policy-invalid',
29
30
  ].includes(error?.code)) return null;
30
31
  return { code: error.code, message: error.message };
31
32
  }
@@ -240,6 +240,9 @@ class ModernHarnessApi {
240
240
  models: (request, signal) => rpcResult(request, () => this.#sessionModels(
241
241
  request.payload.sessionId, signal,
242
242
  )),
243
+ permissions: (request, signal) => rpcResult(request, () => this.#sessionPermissions(
244
+ request.payload.sessionId, signal,
245
+ )),
243
246
  selectModel: (request, signal) => rpcResult(request, () => this.#invoke(
244
247
  'session', 'selectModel', { request: request.payload }, signal,
245
248
  )),
@@ -414,6 +417,25 @@ class ModernHarnessApi {
414
417
  return this.#invoke('session', 'modelCatalog', {}, signal);
415
418
  }
416
419
 
420
+ async #sessionPermissions(sessionId, signal) {
421
+ const [catalog, projection] = await Promise.all([
422
+ this.#invoke('permissionPresets', 'catalog', {}, signal),
423
+ this.#invoke('session', 'projections', { request: { sessionId } }, signal),
424
+ ]);
425
+ if (projection === null) {
426
+ const error = new Error('Session not found');
427
+ error.failure = { code: 'session-not-found', message: error.message, details: {} };
428
+ throw error;
429
+ }
430
+ const currentValue = projection?.values?.permissions?.currentValue;
431
+ if (typeof currentValue !== 'string' || !Array.isArray(catalog?.options)) {
432
+ const error = new Error('Host permission presets are unavailable');
433
+ error.failure = { code: 'permissions-unavailable', message: error.message, details: {} };
434
+ throw error;
435
+ }
436
+ return { currentValue, options: catalog.options };
437
+ }
438
+
417
439
  async #sessionModels(sessionId, signal) {
418
440
  const [catalog, list] = await Promise.all([
419
441
  this.#modelCatalog(signal),
@@ -20,6 +20,26 @@ export function maskDiscordBotId(platformId) {
20
20
 
21
21
  export class DiscordConfigStore extends TokenBotConfigStore {
22
22
  constructor(path) {
23
- super(path, { channel: 'Discord', ...IDENTITY_OPTIONS });
23
+ super(path, {
24
+ channel: 'Discord',
25
+ ...IDENTITY_OPTIONS,
26
+ normalizeBotExtension(value) {
27
+ if (value.ownerUserId == null) return { ownerUserId: null };
28
+ const ownerUserId = typeof value.ownerUserId === 'string' ? value.ownerUserId.trim() : '';
29
+ if (!/^\d{5,30}$/.test(ownerUserId) || ownerUserId === value.platformId?.trim()) return null;
30
+ return { ownerUserId };
31
+ },
32
+ });
33
+ }
34
+
35
+ save(value) {
36
+ // Shared credential binding rebuilds the base config. Preserve only this
37
+ // bot's confirmed identity when omitted; explicit null still clears it.
38
+ return super.save({
39
+ ...value,
40
+ ownerUserId: value?.ownerUserId === undefined
41
+ ? this.get(value?.botId)?.ownerUserId ?? null
42
+ : value.ownerUserId,
43
+ });
24
44
  }
25
45
  }
@@ -123,6 +123,10 @@ export class DiscordApi {
123
123
  return this.#request('users/@me', { ...options, method: 'GET' });
124
124
  }
125
125
 
126
+ getCurrentApplication(options = {}) {
127
+ return this.#request('applications/@me', { ...options, method: 'GET' });
128
+ }
129
+
126
130
  getGatewayBot(options = {}) {
127
131
  return this.#request('gateway/bot', { ...options, method: 'GET' });
128
132
  }
@@ -345,3 +349,31 @@ export async function inspectDiscordToken(token, options = {}) {
345
349
  username: cleanString(bot.username),
346
350
  };
347
351
  }
352
+
353
+ /** Owner lookup is optional metadata, bounded separately from Gateway startup. */
354
+ export async function inspectDiscordOwner(token, {
355
+ platformId,
356
+ timeoutMs = 5_000,
357
+ signal,
358
+ ...options
359
+ } = {}) {
360
+ const api = new DiscordApi({ token, ...options });
361
+ const lookupSignal = requestSignal(signal, positiveTimeout(timeoutMs, 'owner lookup timeout'));
362
+ const request = { signal: lookupSignal, retry: false };
363
+ const application = await api.getCurrentApplication(request);
364
+ const bot = application?.bot ?? await api.getCurrentUser(request);
365
+ if ((!application?.bot && bot?.bot !== true) || bot?.bot === false
366
+ || snowflake(bot?.id, 'bot id') !== platformId) {
367
+ throw new Error('Discord application does not belong to the configured bot');
368
+ }
369
+ // A team application belongs to its team owner, not every developer or the
370
+ // synthetic application owner that Discord can return for team applications.
371
+ const ownerUserId = snowflake(application?.team != null
372
+ ? application.team.owner_user_id
373
+ : application?.owner?.id, 'owner id');
374
+ if (ownerUserId === platformId || (application?.owner?.bot === true && application?.team == null)) {
375
+ throw new Error('Discord application owner is not a human identity');
376
+ }
377
+ lookupSignal.throwIfAborted();
378
+ return ownerUserId;
379
+ }
@@ -1,16 +1,37 @@
1
1
  import { TokenBotController } from '../shared/token-bot-controller.mjs';
2
2
  import { deriveDiscordBotIdentity, maskDiscordBotId } from './config-store.mjs';
3
- import { inspectDiscordToken } from './discord-api.mjs';
3
+ import { inspectDiscordOwner, inspectDiscordToken } from './discord-api.mjs';
4
4
  import { DISCORD_DESCRIPTOR } from './discord-bridge.mjs';
5
5
 
6
6
  export class DiscordController extends TokenBotController {
7
7
  constructor(options) {
8
+ if (typeof options.createRuntime !== 'function') throw new TypeError('Discord runtime factory must be a function');
9
+ const inspectOwner = options.inspectOwner ?? inspectDiscordOwner;
10
+ if (typeof inspectOwner !== 'function') throw new TypeError('Discord owner inspector must be a function');
8
11
  super({
9
12
  ...options,
10
13
  descriptor: DISCORD_DESCRIPTOR,
11
14
  inspectToken: options.inspectToken ?? inspectDiscordToken,
12
15
  deriveIdentity: deriveDiscordBotIdentity,
13
16
  maskPlatformId: maskDiscordBotId,
17
+ createRuntime: async (request) => {
18
+ // Run inside the parent's existing per-bot transition. The production
19
+ // callback must receive the committed owner before building its policy.
20
+ let config = options.configStore.get(request.botId) ?? request.config;
21
+ try {
22
+ const ownerUserId = await inspectOwner(request.token, { platformId: config.platformId });
23
+ if (ownerUserId != null && ownerUserId !== config.ownerUserId) {
24
+ config = await options.configStore.save({ ...config, ownerUserId });
25
+ }
26
+ } catch {
27
+ // Neither provider errors nor optional metadata writes should break
28
+ // an otherwise usable bot. Do not log provider responses or user IDs.
29
+ (options.logger ?? console).warn?.('[dsh-im:discord] owner lookup unavailable; using saved identity', {
30
+ botId: request.botId,
31
+ });
32
+ }
33
+ return options.createRuntime({ ...request, config });
34
+ },
14
35
  });
15
36
  }
16
37
  }
@@ -1,3 +1,4 @@
1
+ import { isPermissionCommand, runPermissionCommand } from '../shared/permission-command.mjs';
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import QRCode from 'qrcode';
3
4
  import {
@@ -298,6 +299,7 @@ function printableText(value) {
298
299
  }
299
300
 
300
301
  function isFeishuLocalCommand(text, { hasImages = false, hasFiles = false } = {}) {
302
+ if (isPermissionCommand(text)) return true;
301
303
  if (hasImages || hasFiles || typeof text !== 'string') return false;
302
304
  const command = text.trim();
303
305
  return MENU_COMMAND.test(command)
@@ -1234,6 +1236,7 @@ export class FeishuHarnessBridge {
1234
1236
  return processing;
1235
1237
  }
1236
1238
  const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
1239
+ : isPermissionCommand(commandText) ? runPermissionCommand
1237
1240
  : hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
1238
1241
  ? runControlCommand
1239
1242
  : (isModelCommand(commandText)
@@ -1408,11 +1411,20 @@ export class FeishuHarnessBridge {
1408
1411
  return current;
1409
1412
  }
1410
1413
 
1414
+ #logAccessDenied(conversationType, reason) {
1415
+ this.#logger.info?.('[dsh-feishu] inbound access denied', {
1416
+ botId: this.#accessPolicy?.botId,
1417
+ conversationType,
1418
+ reason,
1419
+ });
1420
+ }
1421
+
1411
1422
  #finishAccessDecision(event, messageId, access) {
1412
1423
  let current;
1413
1424
  current = Promise.resolve().then(async () => {
1414
1425
  if (this.#state.hasSeen(messageId)) return;
1415
1426
  await this.#state.markSeen(messageId);
1427
+ this.#logAccessDenied(event.message.chat_type === 'p2p' ? 'direct' : 'group', access.reason);
1416
1428
  if (access.reason === 'command-not-allowed') {
1417
1429
  this.#status.lastMessageAt = new Date().toISOString();
1418
1430
  this.#status.messagesReceived += 1;
@@ -1656,6 +1668,7 @@ export class FeishuHarnessBridge {
1656
1668
  || (!hasImages && !hasFiles && NUMBER_REPLY.test(text) && this.#menus.has(key)),
1657
1669
  });
1658
1670
  if (!transcribedAccess.allowed) {
1671
+ this.#logAccessDenied(event.message.chat_type === 'p2p' ? 'direct' : 'group', transcribedAccess.reason);
1659
1672
  if (transcribedAccess.reason === 'command-not-allowed') {
1660
1673
  await this.#send(
1661
1674
  event.message.chat_id,
@@ -1690,6 +1703,15 @@ export class FeishuHarnessBridge {
1690
1703
  await this.#handleRepairCommand(event, commandText);
1691
1704
  return;
1692
1705
  }
1706
+ if (isPermissionCommand(text)) {
1707
+ const result = await runPermissionCommand(text, this.#harness, this.#state, key, {
1708
+ signal: this.#signal, hasImages, hasFiles,
1709
+ });
1710
+ for (const reply of result.messages) {
1711
+ await this.#send(event.message.chat_id, reply, { replyTo: event.message.message_id });
1712
+ }
1713
+ return;
1714
+ }
1693
1715
  if (commandText === '/help') {
1694
1716
  await this.#send(event.message.chat_id, menuHelpText(), { replyTo: event.message.message_id });
1695
1717
  return;
@@ -2298,11 +2320,11 @@ export class FeishuHarnessBridge {
2298
2320
  isCommand: !isInteractionResponse,
2299
2321
  });
2300
2322
  if (!access.allowed) {
2323
+ this.#logAccessDenied(conversationType, access.reason);
2301
2324
  if (access.reason === 'command-not-allowed') {
2302
2325
  return this.#send(route.chatId, t(COMMAND_PERMISSION_DENIED_MESSAGE))
2303
2326
  .catch(() => undefined);
2304
2327
  }
2305
- this.#logger.warn?.('[dsh-feishu] ignoring card action blocked by access policy');
2306
2328
  return Promise.resolve();
2307
2329
  }
2308
2330
  // A used card is recent even if it was first created long ago.
@@ -535,6 +535,9 @@ export function menuHelpText() {
535
535
  '/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
536
536
  '纯数字 ID:/preset id:<ID>',
537
537
  '/preset --default 跟随 Host 默认',
538
+ '/permissionlist 按序号列出权限档位',
539
+ '/permission [序号或完整ID] 查看或切换当前会话权限',
540
+ '纯数字 ID:/permission id:<ID>',
538
541
  '/models 列出模型',
539
542
  '/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级',
540
543
  '/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级',
@@ -619,6 +622,8 @@ export function helpCard(extraTextLines = []) {
619
622
  { tag: 'hr' },
620
623
  { tag: 'div', text: markdown([
621
624
  t(HELP_TEXT_COMMANDS),
625
+ t('`/permissionlist` — 按序号列出权限档位'),
626
+ t('`/permission [序号/ID]` — 查看或切换当前会话权限'),
622
627
  t('/ws、/wsl、/workspaces 工作区命令别名'),
623
628
  t('`/version` — 查看插件版本'),
624
629
  t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
@@ -49,6 +49,8 @@ export const SLASH_COMMAND_MANIFEST = Object.freeze([
49
49
  { command: 'reasoning', icon: 'ai-deepthink_outlined', default: '查看或切换推理等级', en_us: 'Show or switch the reasoning effort' },
50
50
  { command: 'presetlist', icon: 'skill_outlined', default: '列出可用 Agent 预设', en_us: 'List available Agent Presets' },
51
51
  { command: 'preset', icon: 'skill_outlined', default: '查看或切换 Agent 预设', en_us: 'Show or switch the Agent Preset' },
52
+ { command: 'permissionlist', icon: 'skill_outlined', default: '按序号列出权限档位', en_us: 'List numbered permission presets' },
53
+ { command: 'permission', icon: 'skill_outlined', default: '查看或切换当前会话权限', en_us: 'Show or switch current session permissions' },
52
54
  { command: 'stop', icon: 'clear_outlined', default: '停止当前任务', en_us: 'Stop the current task' },
53
55
  { command: 'steer', icon: 'promptword_outlined', default: '给当前任务补充指令', en_us: 'Send additional instructions to the current task' },
54
56
  { command: 'batch', icon: 'chat-ai_outlined', default: '开始批量输入(仅私聊)', en_us: 'Start batch input (private chats only)' },
@@ -1,3 +1,4 @@
1
+ import { isPermissionCommand, runPermissionCommand } from '../shared/permission-command.mjs';
1
2
  import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
2
3
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
3
4
  import { runCompactCommand } from '../shared/compact-command.mjs';
@@ -115,6 +116,9 @@ function helpText() {
115
116
  t('/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset'),
116
117
  t('纯数字 ID:/preset id:<ID>'),
117
118
  t('/preset --default 跟随 Host 默认'),
119
+ t('/permissionlist 按序号列出权限档位'),
120
+ t('/permission [序号或完整ID] 查看或切换当前会话权限'),
121
+ t('纯数字 ID:/permission id:<ID>'),
118
122
  t('/stop 停止当前任务'),
119
123
  t('/steer 补充指令 纠偏当前任务'),
120
124
  t('/batch 开始批量输入(仅私聊,最多 10 条文字)'),
@@ -523,7 +527,7 @@ export class QqHarnessBridge {
523
527
  text: commandText,
524
528
  hasImages: hasQqImageAttachments(message),
525
529
  hasFiles: hasQqFileAttachments(message),
526
- ...(explicitMenu || numericMenu ? { isCommand: true } : {}),
530
+ ...(explicitMenu || numericMenu || isPermissionCommand(commandText) ? { isCommand: true } : {}),
527
531
  })
528
532
  : senderAllowed(message, this.#ownerUserOpenid)
529
533
  ? { allowed: true, reason: 'legacy-owner' }
@@ -594,6 +598,7 @@ export class QqHarnessBridge {
594
598
  const commandRunner = explicitMenu || numericMenu
595
599
  ? () => this.#runMenuCommand(message, menuInput, key)
596
600
  : isHistoryCommand(commandText) ? runHistoryCommand
601
+ : isPermissionCommand(commandText) ? runPermissionCommand
597
602
  : hasQqFileAttachments(message) ? null : isControlCommand(commandText)
598
603
  ? runControlCommand
599
604
  : (isModelCommand(commandText)
@@ -2002,6 +2002,11 @@ export function createBotWorkspaceScope(
2002
2002
  models(...args) {
2003
2003
  return invokeCurrentSession('getSessionModels', args, 'model listing');
2004
2004
  },
2005
+ ...(typeof target.getSessionPermissions === 'function' ? {
2006
+ permissions(...args) {
2007
+ return invokeCurrentSession('getSessionPermissions', args, 'permission listing');
2008
+ },
2009
+ } : {}),
2005
2010
  readHistory(...args) {
2006
2011
  return invokeCurrentSession('readSessionHistory', args, 'history read');
2007
2012
  },
@@ -2131,6 +2136,7 @@ export function createWorkspaceAwareController(controller, {
2131
2136
  stateFor,
2132
2137
  agentPresetCatalog,
2133
2138
  modelCatalog,
2139
+ validateAccessPolicyForBot,
2134
2140
  } = {}) {
2135
2141
  if (!controller || !workspaces || typeof stateFor !== 'function') {
2136
2142
  throw new TypeError('controller, workspaces, and stateFor are required');
@@ -2284,6 +2290,9 @@ export function createWorkspaceAwareController(controller, {
2284
2290
  error.code = 'workspace-bot-not-found';
2285
2291
  throw error;
2286
2292
  }
2293
+ // Channel-specific identity checks must finish before projecting or
2294
+ // committing the new policy. Existing channels need no extra validation.
2295
+ await validateAccessPolicyForBot?.(botId, policy);
2287
2296
  const [catalog, models] = await Promise.all([
2288
2297
  resolveAgentPresetCatalog(agentPresetCatalog),
2289
2298
  resolveModelCatalog(modelCatalog),
@@ -0,0 +1,33 @@
1
+ export const COMMAND_LIST_TTL_MS = 15 * 60_000;
2
+ export const COMMAND_LIST_MAX_ENTRIES = 256;
3
+
4
+ /** Short-lived lists scoped to a bot's state and conversation, never persisted. */
5
+ export function createCommandListSnapshots() {
6
+ const states = new WeakMap();
7
+ return {
8
+ save(state, key, value) {
9
+ if ((!state || typeof state !== 'object') && typeof state !== 'function') return;
10
+ let entries = states.get(state);
11
+ if (!entries) states.set(state, entries = new Map());
12
+ const now = Date.now();
13
+ for (const [entryKey, entry] of entries) {
14
+ if (entry.expiresAt <= now) entries.delete(entryKey);
15
+ }
16
+ entries.delete(key);
17
+ entries.set(key, { value, expiresAt: now + COMMAND_LIST_TTL_MS });
18
+ while (entries.size > COMMAND_LIST_MAX_ENTRIES) entries.delete(entries.keys().next().value);
19
+ },
20
+ load(state, key) {
21
+ const entries = states.get(state);
22
+ const entry = entries?.get(key);
23
+ if (!entry) return null;
24
+ if (entry.expiresAt <= Date.now()) {
25
+ entries.delete(key);
26
+ return null;
27
+ }
28
+ entries.delete(key);
29
+ entries.set(key, entry);
30
+ return entry.value;
31
+ },
32
+ };
33
+ }
@@ -1139,6 +1139,12 @@ export class HarnessClient {
1139
1139
  return validateModelCatalog(value, 'session.models', { session: true });
1140
1140
  }
1141
1141
 
1142
+ async getSessionPermissions(sessionId, options = {}) {
1143
+ if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
1144
+ await this.ensureRunning(options);
1145
+ return this.rpc('session.permissions', { sessionId }, 30_000, options);
1146
+ }
1147
+
1142
1148
  async selectSessionModel(sessionId, selection, options = {}) {
1143
1149
  if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
1144
1150
  if (!validModelSelection(selection)) {
@@ -1,5 +1,7 @@
1
1
  // English translations (feishu area). Keys are exact Chinese literals passed to t().
2
2
  export default {
3
+ '这里需要填写用户的 Open ID,不能填写机器人自己的 Open ID。':
4
+ 'Enter a user Open ID here, not the bot’s own Open ID.',
3
5
  '已结束本轮提问,将在当前会话中继续处理图片。':
4
6
  'The questions have been closed. Your image will be processed in this conversation.',
5
7
  '图片已排队,但暂时无法结束当前提问。请先回答问题或发送 /stop,结束后会继续处理图片。':
@@ -0,0 +1,30 @@
1
+ export default {
2
+ '用法:/permission 查看当前权限;/permissionlist 列出档位;/permission <序号或完整ID> 切换;纯数字 ID 使用 /permission id:<ID>。': 'Usage: /permission to show current permissions; /permissionlist to list presets; /permission <number or full ID> to switch; use /permission id:<ID> for numeric IDs.',
3
+ '仅影响当前会话;沙箱与审批策略会同时按 Host 的档位定义更新。': 'Applies only to the current session; sandbox and approval policies change together according to the Host preset.',
4
+ '请先执行 /permissionlist,再按列表序号选择权限档位。': 'Run /permissionlist first, then select a permission preset by its number.',
5
+ '完全文件访问,不再请求审批。': 'Full file access without approval prompts.',
6
+ '可写工作区和允许的临时目录;扩大访问范围需要审批。': 'Write inside the workspace and permitted temporary directories; wider retries require approval.',
7
+ '当前聊天绑定的会话已不存在,请先绑定或建立会话。': 'The bound session no longer exists. Bind or start a session first.',
8
+ '当前 Host 暂不支持从机器人查看或切换权限档位。': 'This Host does not support viewing or switching permission presets from the bot.',
9
+ '未能确认权限切换结果,请执行 /permission 核对当前档位。': 'Could not confirm the permission change. Run /permission to check the current preset.',
10
+ '暂时无法获取权限档位,请稍后重试。': 'Could not read permission presets. Please try again later.',
11
+ '权限命令仅支持纯文字,请移除图片或文件后重试。': 'Permission commands require plain text. Remove images or files and try again.',
12
+ '当前聊天尚未绑定会话,请先发送普通消息建立会话,或使用 /session 绑定已有会话。': 'This chat has no bound session. Send a regular message to start one, or use /session to bind an existing session.',
13
+ '当前会话已变化,请重新执行 /permissionlist。': 'The current session changed. Run /permissionlist again.',
14
+ '当前会话权限:{id}': 'Current session permissions: {id}',
15
+ ' ✓ 当前': ' ✓ Current',
16
+ '当前没有可用权限档位。': 'No permission presets are currently available.',
17
+ '切换:/permission <序号或完整ID>': 'Switch: /permission <number or full ID>',
18
+ '查看可用档位与序号:/permissionlist': 'Show available presets and numbers: /permissionlist',
19
+ '权限档位序号无效,请重新执行 /permissionlist。': 'Invalid permission preset number. Run /permissionlist again.',
20
+ '当前 Host 未注册 /permission 命令,请确认权限预设组件已启用。': 'This Host has not registered /permission. Check that permission presets are enabled.',
21
+ '该权限档位不存在或已不可用,请重新执行 /permissionlist。': 'That permission preset no longer exists or is unavailable. Run /permissionlist again.',
22
+ '权限切换失败。': 'Permission change failed.',
23
+ '当前会话权限已切换为:{id}': 'Current session permissions changed to: {id}',
24
+ '恢复工作区权限:/permission workspace-write(需 Host 支持该档位)': 'Restore workspace permissions: /permission workspace-write (if supported by the Host)',
25
+ '/permissionlist 按序号列出权限档位': '/permissionlist List numbered permission presets',
26
+ '/permission [序号或完整ID] 查看或切换当前会话权限': '/permission [number or full ID] Show or switch current session permissions',
27
+ '纯数字 ID:/permission id:<ID>': 'Numeric IDs: /permission id:<ID>',
28
+ '`/permissionlist` — 按序号列出权限档位': '`/permissionlist` — List numbered permission presets',
29
+ '`/permission [序号/ID]` — 查看或切换当前会话权限': '`/permission [number/ID]` — Show or switch current session permissions',
30
+ };
@@ -1,4 +1,5 @@
1
1
  import imageInput from './i18n-en/image-input.mjs';
2
+ import permission from './i18n-en/permission.mjs';
2
3
  import diagnostics from './i18n-en/diagnostics.mjs';
3
4
  // English translations for host-side user-facing text.
4
5
  // Keys are the exact Chinese literals passed to t() in src/channels/**.
@@ -26,6 +27,7 @@ export const EN = Object.freeze(Object.assign(
26
27
  {},
27
28
  diagnostics,
28
29
  imageInput,
30
+ permission,
29
31
  sharedA,
30
32
  sharedB,
31
33
  sharedC,