@xmanrui/dsh-im 4.33.0 → 4.34.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.33.0",
3
+ "version": "4.34.0",
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": [
@@ -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),
@@ -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)
@@ -1690,6 +1693,15 @@ export class FeishuHarnessBridge {
1690
1693
  await this.#handleRepairCommand(event, commandText);
1691
1694
  return;
1692
1695
  }
1696
+ if (isPermissionCommand(text)) {
1697
+ const result = await runPermissionCommand(text, this.#harness, this.#state, key, {
1698
+ signal: this.#signal, hasImages, hasFiles,
1699
+ });
1700
+ for (const reply of result.messages) {
1701
+ await this.#send(event.message.chat_id, reply, { replyTo: event.message.message_id });
1702
+ }
1703
+ return;
1704
+ }
1693
1705
  if (commandText === '/help') {
1694
1706
  await this.#send(event.message.chat_id, menuHelpText(), { replyTo: event.message.message_id });
1695
1707
  return;
@@ -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
  },
@@ -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)) {
@@ -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,
@@ -0,0 +1,149 @@
1
+ import { createCommandListSnapshots } from './command-list-snapshots.mjs';
2
+ import { t } from './i18n.mjs';
3
+ import { withSessionBindingLock } from './session-binding-lock.mjs';
4
+ import { splitWorkspaceCommandMessage } from './workspace-command.mjs';
5
+ import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
6
+
7
+ const COMMAND = /^\/(?:permission|permissionlist)(?=$|\s)/iu;
8
+ const SNAPSHOTS = createCommandListSnapshots();
9
+ const USAGE = '用法:/permission 查看当前权限;/permissionlist 列出档位;/permission <序号或完整ID> 切换;纯数字 ID 使用 /permission id:<ID>。';
10
+ const SCOPE = '仅影响当前会话;沙箱与审批策略会同时按 Host 的档位定义更新。';
11
+ const REFRESH = '请先执行 /permissionlist,再按列表序号选择权限档位。';
12
+ const validId = (id) => typeof id === 'string' && /^[^\s\p{Cc}\p{Cf}]+$/u.test(id);
13
+ const display = (value) => typeof value === 'string'
14
+ ? value.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').trim() : '';
15
+
16
+ function result(message) {
17
+ return { handled: true, message, messages: splitWorkspaceCommandMessage(message) };
18
+ }
19
+
20
+ function permissions(value) {
21
+ if (!value || !validId(value.currentValue) || !Array.isArray(value.options)
22
+ || value.options.some((item) => !item || !validId(item.value))) {
23
+ throw new TypeError('Harness returned invalid permissions');
24
+ }
25
+ const seen = new Set();
26
+ return {
27
+ currentValue: value.currentValue,
28
+ options: value.options.filter((item) => {
29
+ if (item.value === 'custom' || seen.has(item.value)) return false;
30
+ seen.add(item.value);
31
+ return true;
32
+ }),
33
+ };
34
+ }
35
+
36
+ function description(item) {
37
+ // Translate known Host copy, not an assumed ID -> policy mapping: presets are configurable.
38
+ const text = display(item.description);
39
+ if (text === 'Full file access without approval prompts.') {
40
+ return t('完全文件访问,不再请求审批。');
41
+ }
42
+ if (text === 'Write inside the workspace and permitted temporary directories; wider retries require approval.') {
43
+ return t('可写工作区和允许的临时目录;扩大访问范围需要审批。');
44
+ }
45
+ return text;
46
+ }
47
+
48
+ function errorMessage(error, writing) {
49
+ const code = error?.code ?? error?.failure?.code;
50
+ if (code === WORKSPACE_SESSION_STALE || code === 'workspace-bot-not-found') {
51
+ return t('工作区或机器人状态已发生变化,请重试。');
52
+ }
53
+ if (['session-not-found', 'session/not-found', 'agent-not-found'].includes(code)) {
54
+ return t('当前聊天绑定的会话已不存在,请先绑定或建立会话。');
55
+ }
56
+ if (['commands-unavailable', 'permissions-unavailable', 'harness-api-not-found',
57
+ 'endpoint-not-found', 'gateway/endpoint-not-found'].includes(code)) {
58
+ return t('当前 Host 暂不支持从机器人查看或切换权限档位。');
59
+ }
60
+ return writing
61
+ ? t('未能确认权限切换结果,请执行 /permission 核对当前档位。')
62
+ : t('暂时无法获取权限档位,请稍后重试。');
63
+ }
64
+
65
+ export function isPermissionCommand(text) {
66
+ return typeof text === 'string' && COMMAND.test(text.trim());
67
+ }
68
+
69
+ export async function runPermissionCommand(text, harness, state, key, options = {}) {
70
+ if (!isPermissionCommand(text)) return null;
71
+ if (options.hasImages || options.hasFiles) {
72
+ return result(t('权限命令仅支持纯文字,请移除图片或文件后重试。'));
73
+ }
74
+ const match = /^\/(permission|permissionlist)(?:[ \t]+([^\s]+))?[ \t]*$/iu.exec(text.trim());
75
+ if (!match || (match[1].toLowerCase() === 'permissionlist' && match[2])) return result(t(USAGE));
76
+ const list = match[1].toLowerCase() === 'permissionlist';
77
+ const requested = match[2];
78
+ if (requested && (!validId(requested) || requested.startsWith('--'))) return result(t(USAGE));
79
+ const signal = options.signal
80
+ ? AbortSignal.any([options.signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000);
81
+ let writing = false;
82
+ try {
83
+ return await withSessionBindingLock(state, key, async () => {
84
+ signal.throwIfAborted();
85
+ const sessionId = state.sessionFor?.(key);
86
+ if (!sessionId) return result(t('当前聊天尚未绑定会话,请先发送普通消息建立会话,或使用 /session 绑定已有会话。'));
87
+ const session = typeof harness.workspaceSession === 'function'
88
+ ? harness.workspaceSession(sessionId, key)
89
+ : {
90
+ permissions: harness.getSessionPermissions && ((opts) => harness.getSessionPermissions(sessionId, opts)),
91
+ executeCommand: harness.executeCommand && ((line, opts) => harness.executeCommand(sessionId, line, opts)),
92
+ };
93
+ if (!requested) {
94
+ if (typeof session?.permissions !== 'function') return result(t('当前 Host 暂不支持从机器人查看或切换权限档位。'));
95
+ const current = permissions(await session.permissions({ signal }));
96
+ if (state.sessionFor(key) !== sessionId) return result(t('当前会话已变化,请重新执行 /permissionlist。'));
97
+ const lines = [t('当前会话权限:{id}', { id: current.currentValue }), ''];
98
+ if (list) {
99
+ SNAPSHOTS.save(state, key, { sessionId, ids: current.options.map((item) => item.value) });
100
+ for (const [index, item] of current.options.entries()) {
101
+ const label = display(item.name);
102
+ const detail = description(item);
103
+ lines.push(`${index + 1}. ${item.value}${label && label !== item.value ? `(${label})` : ''}${item.value === current.currentValue ? t(' ✓ 当前') : ''}${detail ? ` — ${detail}` : ''}`);
104
+ }
105
+ if (!current.options.length) lines.push(t('当前没有可用权限档位。'));
106
+ lines.push('', t('切换:/permission <序号或完整ID>'));
107
+ } else {
108
+ lines.push(t('查看可用档位与序号:/permissionlist'));
109
+ }
110
+ lines.push(t(SCOPE));
111
+ return result(lines.join('\n'));
112
+ }
113
+
114
+ let selected = requested;
115
+ const numericId = /^id:(\d+)$/iu.exec(requested);
116
+ if (numericId) selected = numericId[1];
117
+ else if (/^\d+$/u.test(requested)) {
118
+ const snapshot = SNAPSHOTS.load(state, key);
119
+ if (!snapshot || snapshot.sessionId !== sessionId) return result(t(REFRESH));
120
+ const index = Number(requested);
121
+ if (!Number.isSafeInteger(index) || index < 1 || !snapshot.ids[index - 1]) {
122
+ return result(t('权限档位序号无效,请重新执行 /permissionlist。'));
123
+ }
124
+ selected = snapshot.ids[index - 1];
125
+ }
126
+ if (selected === 'custom' || /^id:/iu.test(selected)) return result(t(USAGE));
127
+ if (typeof session?.executeCommand !== 'function') return result(t('当前 Host 暂不支持从机器人查看或切换权限档位。'));
128
+ signal.throwIfAborted();
129
+ writing = true;
130
+ const execution = await session.executeCommand(`/permission ${selected}`, { signal });
131
+ if (execution === undefined) return result(t('当前 Host 未注册 /permission 命令,请确认权限预设组件已启用。'));
132
+ const outcome = execution?.result;
133
+ if (!outcome || !['success', 'error'].includes(outcome.kind)
134
+ || (outcome.text !== undefined && typeof outcome.text !== 'string')) throw new TypeError('Invalid permission command result');
135
+ if (outcome.kind === 'error') {
136
+ if (outcome.text?.startsWith('unknown preset ')) {
137
+ return result(t('该权限档位不存在或已不可用,请重新执行 /permissionlist。'));
138
+ }
139
+ return result([t('权限切换失败。'), display(outcome.text)].filter(Boolean).join('\n'));
140
+ }
141
+ return result([
142
+ t('当前会话权限已切换为:{id}', { id: selected }), '', t(SCOPE),
143
+ ...(selected === 'danger-full-access' ? [t('恢复工作区权限:/permission workspace-write(需 Host 支持该档位)')] : []),
144
+ ].join('\n'));
145
+ });
146
+ } catch (error) {
147
+ return result(errorMessage(error, writing));
148
+ }
149
+ }
@@ -1,3 +1,4 @@
1
+ import { createCommandListSnapshots, COMMAND_LIST_TTL_MS, COMMAND_LIST_MAX_ENTRIES } from './command-list-snapshots.mjs';
1
2
  import {
2
3
  normalizeAgentPresetCatalog,
3
4
  normalizeAgentPresetId,
@@ -19,9 +20,9 @@ const PRESET_USAGE = [
19
20
  '/preset --default 跟随 Host 默认',
20
21
  ].join('\n');
21
22
  const UNSAFE_DISPLAY_TEXT_GLOBAL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu;
22
- const LIST_SNAPSHOTS = new WeakMap();
23
- export const PRESET_LIST_SNAPSHOT_TTL_MS = 15 * 60_000;
24
- export const PRESET_LIST_SNAPSHOT_MAX_ENTRIES = 256;
23
+ const LIST_SNAPSHOTS = createCommandListSnapshots();
24
+ export const PRESET_LIST_SNAPSHOT_TTL_MS = COMMAND_LIST_TTL_MS;
25
+ export const PRESET_LIST_SNAPSHOT_MAX_ENTRIES = COMMAND_LIST_MAX_ENTRIES;
25
26
 
26
27
  function commandResult(message) {
27
28
  return {
@@ -140,59 +141,13 @@ function formatUpdated(settings) {
140
141
  ].join('\n');
141
142
  }
142
143
 
143
- function stateSnapshots(state, { create = false } = {}) {
144
- if ((typeof state !== 'object' || state === null) && typeof state !== 'function') return null;
145
- let snapshots = LIST_SNAPSHOTS.get(state);
146
- if (!snapshots && create) {
147
- snapshots = new Map();
148
- LIST_SNAPSHOTS.set(state, snapshots);
149
- }
150
- return snapshots ?? null;
151
- }
152
-
153
- function pruneExpiredSnapshots(snapshots, now) {
154
- for (const [snapshotKey, snapshot] of snapshots) {
155
- if (snapshot.expiresAt <= now) snapshots.delete(snapshotKey);
156
- }
157
- }
158
-
159
- function saveSnapshot(state, key, items) {
160
- const snapshots = stateSnapshots(state, { create: true });
161
- if (!snapshots) return;
162
- const now = Date.now();
163
- pruneExpiredSnapshots(snapshots, now);
164
- snapshots.delete(key);
165
- snapshots.set(key, {
166
- expiresAt: now + PRESET_LIST_SNAPSHOT_TTL_MS,
167
- ids: items.map((item) => item.id),
168
- });
169
- while (snapshots.size > PRESET_LIST_SNAPSHOT_MAX_ENTRIES) {
170
- const oldest = snapshots.keys().next();
171
- if (oldest.done) break;
172
- snapshots.delete(oldest.value);
173
- }
174
- }
175
-
176
- function loadSnapshot(state, key) {
177
- const snapshots = stateSnapshots(state);
178
- const snapshot = snapshots?.get(key);
179
- if (!snapshots || !snapshot) return null;
180
- if (snapshot.expiresAt <= Date.now()) {
181
- snapshots.delete(key);
182
- return null;
183
- }
184
- snapshots.delete(key);
185
- snapshots.set(key, snapshot);
186
- return snapshot.ids;
187
- }
188
-
189
144
  function presetFromSnapshot(state, key, requested) {
190
145
  if (!/^\d+$/u.test(requested)) return { numeric: false, id: null };
191
146
  const index = Number(requested);
192
147
  if (!Number.isSafeInteger(index) || index < 1) {
193
148
  return { numeric: true, error: t('Agent Preset 序号无效,请先执行 /presetlist。') };
194
149
  }
195
- const snapshot = loadSnapshot(state, key);
150
+ const snapshot = LIST_SNAPSHOTS.load(state, key);
196
151
  if (!snapshot) {
197
152
  return { numeric: true, error: t('请先执行 /presetlist,再按列表序号选择 Agent Preset。') };
198
153
  }
@@ -260,7 +215,7 @@ export async function runPresetCommand(text, harness, state, key, options = {})
260
215
  if (!/^\/(?:presetlist|presets)[ \t]*$/iu.test(command)) return commandResult(t(PRESET_LIST_USAGE));
261
216
  try {
262
217
  const current = await settings(harness, requestOptions);
263
- saveSnapshot(state, key, current.agentPresetCatalog.items);
218
+ LIST_SNAPSHOTS.save(state, key, current.agentPresetCatalog.items.map((item) => item.id));
264
219
  return commandResult(formatList(current));
265
220
  } catch (error) {
266
221
  return commandResult(presetErrorMessage(error, 'list'));
@@ -1,5 +1,4 @@
1
1
  import { extractConnectionEvidence, createConnectionDiagnostics, atConnectionStage } from '../shared/connection-error.mjs';
2
- import { randomInt } from 'node:crypto';
3
2
 
4
3
  import { createEditableMessageStream } from '../shared/editable-message-stream.mjs';
5
4
  import { createTextDeliveryBlock } from '../shared/semantic/delivery.mjs';
@@ -357,6 +356,11 @@ function telegramMessageId(value) {
357
356
  return Number.isSafeInteger(value?.message_id) ? String(value.message_id) : null;
358
357
  }
359
358
 
359
+ function telegramMessageUnchanged(error) {
360
+ return error?.providerCode === 400
361
+ && /^(?:Bad Request: )?message is not modified(?::|$)/iu.test(error?.message ?? '');
362
+ }
363
+
360
364
  function telegramFailure(error) {
361
365
  const providerCode = Number(error?.providerCode);
362
366
  const status = Number(error?.status);
@@ -687,6 +691,9 @@ export class TelegramBotClient {
687
691
  messageId: placeholderMessageId,
688
692
  text: chunks[0],
689
693
  signal: this.#signal,
694
+ }).catch((error) => {
695
+ // The final answer may already be visible from the last progress edit.
696
+ if (!telegramMessageUnchanged(error)) throw error;
690
697
  });
691
698
  firstUnsent = 1;
692
699
  } catch (error) {
@@ -814,6 +821,8 @@ export class TelegramBotClient {
814
821
  messageId,
815
822
  richMessage: { markdown: chunks[0].markdown },
816
823
  signal: this.#signal,
824
+ }).catch((error) => {
825
+ if (!telegramMessageUnchanged(error)) throw error;
817
826
  });
818
827
  } catch (error) {
819
828
  const failure = telegramFailure(error);
@@ -869,31 +878,8 @@ export class TelegramBotClient {
869
878
  }
870
879
 
871
880
  async openDeliveryStream(target) {
872
- if (target.chatType === 'private') {
873
- const draftId = randomInt(1, 2_147_483_647);
874
- const updateDraft = async (block) => {
875
- const richMessage = { markdown: toTelegramRichMarkdown(block.text) };
876
- await this.#api.sendRichMessageDraft({
877
- chatId: target.chatId,
878
- draftId,
879
- richMessage,
880
- messageThreadId: target.messageThreadId,
881
- signal: this.#signal,
882
- });
883
- return deliveryResult('telegram-rich-draft', []);
884
- };
885
- const stream = new TelegramDeliveryStream({
886
- update: updateDraft,
887
- finish: (block) => this.#sendRich(target, block),
888
- fail: (block) => this.#sendPlain(target, block.text),
889
- presentation: 'telegram-rich-draft',
890
- keepalive: true,
891
- logger: this.#logger,
892
- });
893
- await stream.update(createTextDeliveryBlock(t('正在处理…'), 'plain'));
894
- return stream;
895
- }
896
-
881
+ // Real messages keep the Android composer available for /stop and /steer;
882
+ // private-chat Rich Drafts can block sending for the whole turn (#276).
897
883
  const placeholder = await this.#api.sendMessage({
898
884
  chatId: target.chatId,
899
885
  text: t('正在处理…'),