@xmanrui/dsh-im 4.18.0 → 4.19.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.
Files changed (51) hide show
  1. package/README.en.md +8 -3
  2. package/README.md +8 -3
  3. package/lib/client.js +809 -515
  4. package/lib/index.js +269 -267
  5. package/package.json +5 -1
  6. package/plugin-src/client/bot-alias.js +92 -0
  7. package/plugin-src/client/channels/dingtalk/api.js +3 -0
  8. package/plugin-src/client/channels/dingtalk/index.js +7 -1
  9. package/plugin-src/client/channels/feishu/api.js +3 -0
  10. package/plugin-src/client/channels/feishu/index.js +7 -1
  11. package/plugin-src/client/channels/qq/api.js +3 -0
  12. package/plugin-src/client/channels/qq/index.js +9 -1
  13. package/plugin-src/client/channels/shared/token-api.js +3 -0
  14. package/plugin-src/client/channels/shared/token-channel.js +9 -2
  15. package/plugin-src/client/channels/wecom/api.js +3 -0
  16. package/plugin-src/client/channels/wecom/index.js +9 -1
  17. package/plugin-src/client/channels/wecom-app/api.js +3 -0
  18. package/plugin-src/client/channels/wecom-app/index.js +9 -1
  19. package/plugin-src/client/channels/weixin/api.js +3 -0
  20. package/plugin-src/client/channels/weixin/index.js +7 -1
  21. package/plugin-src/client/channels/whatsapp/api.js +3 -0
  22. package/plugin-src/client/channels/whatsapp/index.js +9 -1
  23. package/plugin-src/client/i18n.js +10 -0
  24. package/plugin-src/client/styles.js +26 -0
  25. package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
  26. package/plugin-src/host/channels/feishu/production.mjs +22 -0
  27. package/plugin-src/host/channels/feishu/rpc.mjs +14 -0
  28. package/plugin-src/host/channels/imessage/rpc.mjs +1 -0
  29. package/plugin-src/host/channels/qq/rpc.mjs +11 -0
  30. package/plugin-src/host/channels/shared/bot-alias-rpc.mjs +15 -0
  31. package/plugin-src/host/channels/shared/rpc.mjs +9 -0
  32. package/plugin-src/host/channels/slack/rpc.mjs +10 -0
  33. package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
  34. package/plugin-src/host/channels/wecom-app/rpc.mjs +10 -0
  35. package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
  36. package/plugin-src/host/channels/whatsapp/rpc.mjs +13 -0
  37. package/plugin-src/host/rpc-authority.mjs +3 -2
  38. package/plugin-src/host/session-sync-coordinator.mjs +19 -1
  39. package/scripts/verify-lan-management.mjs +176 -0
  40. package/src/channels/feishu/bridge.mjs +370 -7
  41. package/src/channels/feishu/feishu-cards.mjs +1 -1
  42. package/src/channels/feishu/feishu-runtime.mjs +6 -0
  43. package/src/channels/feishu/state-store.mjs +17 -0
  44. package/src/channels/shared/bot-alias.mjs +29 -0
  45. package/src/channels/shared/bot-workspace-store.mjs +71 -3
  46. package/src/channels/shared/i18n-en/shared-a.mjs +2 -2
  47. package/src/channels/shared/message-failure.mjs +2 -1
  48. package/src/channels/shared/model-command.mjs +3 -2
  49. package/src/channels/shared/session-sync-registry.mjs +34 -0
  50. package/src/channels/shared/text-harness-bridge.mjs +38 -0
  51. package/src/channels/telegram/telegram-runtime.mjs +77 -25
@@ -1,3 +1,4 @@
1
+ import { validateBotAlias, withBotAlias } from './bot-alias.mjs';
1
2
  import {
2
3
  mkdir,
3
4
  readFile,
@@ -264,6 +265,16 @@ function normalizeDocument(value) {
264
265
  if (value.version === 1 && value.deliveryTargets !== undefined) return null;
265
266
  const deliveryTargets = normalizeDeliveryTargets(value.deliveryTargets, { version: value.version });
266
267
  if (!deliveryTargets) return null;
268
+ const aliases = Object.create(null);
269
+ if (value.aliases && typeof value.aliases === 'object' && !Array.isArray(value.aliases)) {
270
+ for (const [botId, alias] of Object.entries(value.aliases)) {
271
+ try {
272
+ botIdOf(botId);
273
+ const normalized = validateBotAlias(alias);
274
+ if (normalized) aliases[botId] = normalized;
275
+ } catch { /* A damaged display name must not disable the bot. */ }
276
+ }
277
+ }
267
278
  const accessPolicies = normalizeAccessPolicies(value.accessPolicies, workspaces);
268
279
  const version = Math.max(
269
280
  value.version,
@@ -279,6 +290,7 @@ function normalizeDocument(value) {
279
290
  contextEnhancement,
280
291
  deliveryTargets,
281
292
  accessPolicies,
293
+ aliases,
282
294
  };
283
295
  }
284
296
 
@@ -290,8 +302,10 @@ function storedDocument({
290
302
  contextEnhancement,
291
303
  deliveryTargets,
292
304
  accessPolicies,
305
+ aliases,
293
306
  }) {
294
307
  const document = { version, workspaces };
308
+ if (Object.keys(aliases).length > 0) document.aliases = aliases;
295
309
  if (Object.keys(agentPresets).length > 0) document.agentPresets = agentPresets;
296
310
  if (Object.keys(models).length > 0) document.models = models;
297
311
  if (Object.keys(contextEnhancement).length > 0) {
@@ -346,6 +360,7 @@ export class BotWorkspaceStore {
346
360
  #workspaces = {};
347
361
  #agentPresets = {};
348
362
  #models = {};
363
+ #aliases = Object.create(null);
349
364
  #contextEnhancement = {};
350
365
  #deliveryTargets = Object.create(null);
351
366
  #accessPolicies = Object.create(null);
@@ -373,6 +388,7 @@ export class BotWorkspaceStore {
373
388
  this.#workspaces = normalized.workspaces;
374
389
  this.#agentPresets = normalized.agentPresets;
375
390
  this.#models = normalized.models;
391
+ this.#aliases = normalized.aliases;
376
392
  this.#contextEnhancement = normalized.contextEnhancement;
377
393
  this.#deliveryTargets = normalized.deliveryTargets;
378
394
  this.#accessPolicies = normalized.accessPolicies;
@@ -382,6 +398,7 @@ export class BotWorkspaceStore {
382
398
  this.#workspaces = {};
383
399
  this.#agentPresets = {};
384
400
  this.#models = {};
401
+ this.#aliases = Object.create(null);
385
402
  this.#contextEnhancement = {};
386
403
  this.#deliveryTargets = Object.create(null);
387
404
  this.#accessPolicies = Object.create(null);
@@ -427,6 +444,11 @@ export class BotWorkspaceStore {
427
444
  return selection ? { ...selection } : null;
428
445
  }
429
446
 
447
+ aliasFor(botId) {
448
+ const id = botIdOf(botId);
449
+ return this.has(id) && Object.hasOwn(this.#aliases, id) ? this.#aliases[id] : '';
450
+ }
451
+
430
452
  contextEnhancementFor(botId) {
431
453
  const id = botIdOf(botId);
432
454
  return this.has(id) && Object.hasOwn(this.#contextEnhancement, id)
@@ -745,6 +767,26 @@ export class BotWorkspaceStore {
745
767
  });
746
768
  }
747
769
 
770
+ async setAlias(botId, value, { incarnation } = {}) {
771
+ const id = botIdOf(botId);
772
+ const expectedIncarnation = incarnation === undefined ? this.incarnationFor(id) : incarnation;
773
+ const alias = validateBotAlias(value);
774
+ return this.#enqueue(id, async () => {
775
+ if (!this.has(id) || expectedIncarnation !== this.incarnationFor(id)) {
776
+ const error = new Error('找不到要修改的机器人。');
777
+ error.code = 'workspace-bot-not-found';
778
+ throw error;
779
+ }
780
+ const next = { ...this.#aliases };
781
+ if (alias) next[id] = alias;
782
+ else delete next[id];
783
+ await this.#persist(this.#contextEnhancement, this.#deliveryTargets,
784
+ this.#version, this.#accessPolicies, next);
785
+ this.#aliases = next;
786
+ return alias;
787
+ });
788
+ }
789
+
748
790
  async setContextEnhancement(botId, value, { incarnation } = {}) {
749
791
  const id = botIdOf(botId);
750
792
  const expectedIncarnation = incarnation === undefined ? this.incarnationFor(id) : incarnation;
@@ -945,6 +987,7 @@ export class BotWorkspaceStore {
945
987
  ...Object.keys(this.#workspaces),
946
988
  ...Object.keys(this.#agentPresets),
947
989
  ...Object.keys(this.#models),
990
+ ...Object.keys(this.#aliases),
948
991
  ...Object.keys(this.#contextEnhancement),
949
992
  ...Object.keys(this.#deliveryTargets),
950
993
  ...Object.keys(this.#accessPolicies),
@@ -962,6 +1005,7 @@ export class BotWorkspaceStore {
962
1005
  bots: status.bots.map((bot) => bot?.botId
963
1006
  ? {
964
1007
  ...bot,
1008
+ ...(this.aliasFor(bot.botId) ? { bot: withBotAlias(bot.bot, this.aliasFor(bot.botId)) } : {}),
965
1009
  workspace: this.workspaceFor(bot.botId),
966
1010
  agentPreset: this.agentPresetFor(bot.botId),
967
1011
  model: this.modelFor(bot.botId),
@@ -997,14 +1041,16 @@ export class BotWorkspaceStore {
997
1041
  const hadWorkspace = Object.hasOwn(this.#workspaces, id);
998
1042
  const hadPreset = Object.hasOwn(this.#agentPresets, id);
999
1043
  const hadModel = Object.hasOwn(this.#models, id);
1044
+ const hadAlias = Object.hasOwn(this.#aliases, id);
1000
1045
  const hadContextEnhancement = Object.hasOwn(this.#contextEnhancement, id);
1001
1046
  const hadDeliveryTargets = Object.hasOwn(this.#deliveryTargets, id);
1002
1047
  const hadAccessPolicy = Object.hasOwn(this.#accessPolicies, id);
1003
- const needsCleanup = hadWorkspace || hadPreset || hadModel || hadContextEnhancement
1048
+ const needsCleanup = hadWorkspace || hadPreset || hadModel || hadAlias || hadContextEnhancement
1004
1049
  || hadDeliveryTargets || hadAccessPolicy || this.#dirtyRemovals.has(id);
1005
1050
  delete this.#workspaces[id];
1006
1051
  delete this.#agentPresets[id];
1007
1052
  delete this.#models[id];
1053
+ delete this.#aliases[id];
1008
1054
  delete this.#contextEnhancement[id];
1009
1055
  delete this.#deliveryTargets[id];
1010
1056
  delete this.#accessPolicies[id];
@@ -1042,6 +1088,7 @@ export class BotWorkspaceStore {
1042
1088
  deliveryTargets = this.#deliveryTargets,
1043
1089
  version = this.#version,
1044
1090
  accessPolicies = this.#accessPolicies,
1091
+ aliases = this.#aliases,
1045
1092
  ) {
1046
1093
  await writeStoredDocument(this.#path, storedDocument({
1047
1094
  version,
@@ -1051,6 +1098,7 @@ export class BotWorkspaceStore {
1051
1098
  contextEnhancement,
1052
1099
  deliveryTargets,
1053
1100
  accessPolicies,
1101
+ aliases,
1054
1102
  }));
1055
1103
  this.#dirtyRemovals.clear();
1056
1104
  }
@@ -1059,6 +1107,7 @@ export class BotWorkspaceStore {
1059
1107
  if (Object.keys(this.#workspaces).length > 0
1060
1108
  || Object.keys(this.#agentPresets).length > 0
1061
1109
  || Object.keys(this.#models).length > 0
1110
+ || Object.keys(this.#aliases).length > 0
1062
1111
  || Object.keys(this.#contextEnhancement).length > 0
1063
1112
  || Object.keys(this.#deliveryTargets).length > 0
1064
1113
  || Object.keys(this.#accessPolicies).length > 0) {
@@ -1350,6 +1399,7 @@ export function createBotWorkspaceScope(
1350
1399
  }
1351
1400
  if (property === 'createSession') {
1352
1401
  return async (options = {}) => {
1402
+ const { inheritBotModel = true, ...createOptions } = options;
1353
1403
  await workspaces.whenBotIdle(botId);
1354
1404
  if (!isCurrentScope()) {
1355
1405
  const error = new Error('找不到要修改的机器人。');
@@ -1358,9 +1408,9 @@ export function createBotWorkspaceScope(
1358
1408
  }
1359
1409
  const generation = workspaces.generationFor(botId);
1360
1410
  const agentPreset = workspaces.agentPresetFor(botId);
1361
- const model = workspaces.modelFor(botId);
1411
+ const model = inheritBotModel === false ? null : workspaces.modelFor(botId);
1362
1412
  const sessionId = await target.createSession({
1363
- ...options,
1413
+ ...createOptions,
1364
1414
  workspace: workspaces.workspaceFor(botId),
1365
1415
  ...(agentPreset == null ? {} : { agentPreset }),
1366
1416
  });
@@ -1645,6 +1695,23 @@ export function createWorkspaceAwareController(controller, {
1645
1695
  );
1646
1696
  });
1647
1697
  };
1698
+ const updateAlias = (botId, value, projectStatus) => {
1699
+ const incarnation = workspaces.incarnationFor(botId);
1700
+ const alias = validateBotAlias(value);
1701
+ return withBotTransition(botId, async () => {
1702
+ const snapshot = await decorate(await controller.status());
1703
+ if (!snapshot?.bots?.some((bot) => bot?.botId === botId)) {
1704
+ const error = new Error('找不到要修改的机器人。');
1705
+ error.code = 'workspace-bot-not-found';
1706
+ throw error;
1707
+ }
1708
+ const updated = { ...snapshot, bots: snapshot.bots.map((bot) => bot.botId === botId
1709
+ ? { ...bot, bot: withBotAlias(bot.bot, alias) } : bot) };
1710
+ const result = projectStatus ? await projectStatus(updated) : updated;
1711
+ await workspaces.setAlias(botId, alias, { incarnation });
1712
+ return result;
1713
+ });
1714
+ };
1648
1715
  const updateContextEnhancement = (botId, value, projectStatus) => {
1649
1716
  const incarnation = workspaces.incarnationFor(botId);
1650
1717
  const config = validateContextEnhancementConfig(value);
@@ -1743,6 +1810,7 @@ export function createWorkspaceAwareController(controller, {
1743
1810
  if (property === 'updateWorkspace') return updateWorkspace;
1744
1811
  if (property === 'updateAgentPreset') return updateAgentPreset;
1745
1812
  if (property === 'updateModel') return updateModel;
1813
+ if (property === 'updateAlias') return updateAlias;
1746
1814
  if (property === 'updateContextEnhancement') return updateContextEnhancement;
1747
1815
  if (property === 'updateAccessPolicy') return updateAccessPolicy;
1748
1816
  const value = Reflect.get(target, property, target);
@@ -57,8 +57,8 @@ export default {
57
57
  'This conversation exceeds the model context limit. Send /compact or /new, then try again.',
58
58
  '当前模型不存在或暂不可用。请发送 /models 查看并使用 /model 切换模型。':
59
59
  'The current model does not exist or is unavailable. Send /models and use /model to switch models.',
60
- '当前模型不存在或不支持所选配置。请发送 /models,并使用 /model 重新选择。':
61
- 'The current model does not exist or does not support the selected settings. Send /models and use /model to choose again.',
60
+ '当前模型不存在或不支持所选配置。请发送 /models,再用 /model <序号> 为当前聊天重新选择。若要修改后续新会话的默认模型,请到 DSH 设置 → IM机器人 → 对应机器人卡片修改。':
61
+ 'The current model does not exist or does not support the selected settings. Send /models, then use /model <index> to choose a model for the current conversation. To change the default model for new conversations, open DSH Settings → IM Bots → the corresponding bot card.',
62
62
  '当前模型不支持这类内容或所选配置。请调整内容、模型或推理等级后重试。':
63
63
  'The current model does not support this content or the selected settings. Adjust the content, model, or reasoning effort and try again.',
64
64
  '当前模型不支持所选配置。请切换模型或推理等级后重试。':
@@ -47,7 +47,7 @@ const FAILURE_MESSAGES = Object.freeze({
47
47
  MODEL_CONTEXT_LIMIT:
48
48
  '当前会话内容超过模型上下文上限。请发送 /compact 或 /new 后重试。',
49
49
  MODEL_UNAVAILABLE:
50
- '当前模型不存在或不支持所选配置。请发送 /models,并使用 /model 重新选择。',
50
+ '当前模型不存在或不支持所选配置。请发送 /models,再用 /model <序号> 为当前聊天重新选择。若要修改后续新会话的默认模型,请到 DSH 设置 → IM机器人 → 对应机器人卡片修改。',
51
51
  MODEL_CONFIG:
52
52
  '当前模型不支持这类内容或所选配置。请调整内容、模型或推理等级后重试。',
53
53
  MODEL_TIMEOUT:
@@ -125,6 +125,7 @@ function failureCode(error) {
125
125
  }
126
126
  if (code === 'harness-turn-failed') return 'INTERNAL_UNKNOWN';
127
127
  if (['harness-http-failed', 'harness-rpc-rejected'].includes(code)) return 'HARNESS_SERVICE';
128
+ if (code === 'model-unavailable' || code === 'session/model-unavailable') return 'MODEL_UNAVAILABLE';
128
129
  if (code === 'model-empty-response') return 'MODEL_EMPTY_REPLY';
129
130
  if (code === 'model-max-tokens') return 'MODEL_OUTPUT_LIMIT';
130
131
  if (code === 'turn-blocked') return 'TURN_BLOCKED';
@@ -393,7 +393,7 @@ function modelErrorMessage(error, action) {
393
393
  if (code === 'session-not-found') {
394
394
  return t('当前聊天绑定的会话已不存在,请重试。');
395
395
  }
396
- if (code === 'model-unavailable') {
396
+ if (code === 'model-unavailable' || code === 'session/model-unavailable') {
397
397
  if (action === 'reasoning-select') {
398
398
  return t('无法切换推理等级。当前模型或推理等级不可用。');
399
399
  }
@@ -704,7 +704,8 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
704
704
  || typeof state?.setSession !== 'function') {
705
705
  throw new TypeError('Harness cannot create a conversation session');
706
706
  }
707
- const sessionId = await harness.createSession(requestOptions);
707
+ // An explicit choice must remain usable even when the saved bot model expires.
708
+ const sessionId = await harness.createSession({ ...requestOptions, inheritBotModel: false });
708
709
  if (typeof sessionId !== 'string' || !sessionId) {
709
710
  throw new TypeError('Harness returned an invalid session id');
710
711
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Session-sync ownership registry: records which delivery targets of a
3
+ * session are being mirrored as process cards by their owning bridge.
4
+ *
5
+ * The plain-text session-sync coordinator and the per-bridge card mirror
6
+ * observe the same global session events. When a bridge mirrors a turn to a
7
+ * specific target, the coordinator must suppress the plain-text delivery for
8
+ * THAT target only — other targets (same or other channels) still receive
9
+ * their normal text. Both sides live in the same Host process, so a
10
+ * module-level registry is the whole contract: the mirror claims a target
11
+ * when it opens the mirror card and releases it when the turn ends.
12
+ */
13
+ const claimed = new Map();
14
+
15
+ function keyOf(sessionId, targetId) {
16
+ return `${sessionId}\0${targetId ?? ''}`;
17
+ }
18
+
19
+ export function claimSessionSyncMirror(sessionId, targetId = '', turn = null) {
20
+ if (typeof sessionId !== 'string' || !sessionId) return;
21
+ claimed.set(keyOf(sessionId, targetId), { turn, claimedAt: Date.now() });
22
+ }
23
+
24
+ export function releaseSessionSyncMirror(sessionId, targetId = '') {
25
+ claimed.delete(keyOf(sessionId, targetId));
26
+ }
27
+
28
+ /** True when a live mirror claim covers this session and target. */
29
+ export function isSessionSyncMirrored(sessionId, targetId = '', turn = null) {
30
+ const claim = claimed.get(keyOf(sessionId, targetId));
31
+ if (!claim) return false;
32
+ if (turn !== null && claim.turn !== null && claim.turn !== turn) return false;
33
+ return true;
34
+ }
@@ -144,6 +144,7 @@ export class TextHarnessBridge {
144
144
  #logger;
145
145
  #replyTimeoutMs;
146
146
  #signal;
147
+ #keepaliveIntervalMs;
147
148
  #queues = new Map();
148
149
  #pendingInteractions = new Map();
149
150
  #interactionKeys = new Map();
@@ -165,6 +166,7 @@ export class TextHarnessBridge {
165
166
  logger = console,
166
167
  replyTimeoutMs = 600_000,
167
168
  signal,
169
+ keepaliveIntervalMs = 4_000,
168
170
  }) {
169
171
  if (!descriptor?.key || !descriptor?.label) throw new TypeError('A channel descriptor is required');
170
172
  if (!bot || typeof bot.sendText !== 'function') throw new TypeError('A bot client is required');
@@ -179,6 +181,7 @@ export class TextHarnessBridge {
179
181
  this.#logger = logger;
180
182
  this.#replyTimeoutMs = replyTimeoutMs;
181
183
  this.#signal = signal;
184
+ this.#keepaliveIntervalMs = keepaliveIntervalMs;
182
185
  this.#deferred = createDeferredDeliveryCoordinator({ harness, state, signal, logger,
183
186
  deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
184
187
  });
@@ -586,6 +589,15 @@ export class TextHarnessBridge {
586
589
  const batchSubmission = message.batchSubmission;
587
590
  let stream = null;
588
591
  let semanticStream = false;
592
+ // A keepalive heartbeat keeps short-lived carriers (e.g. Telegram's
593
+ // private-chat Rich Draft) visible during long silent stretches such as a
594
+ // running tool call. Declared outside the try so every exit path (including
595
+ // pre-prompt failures like image parsing) clears the timer.
596
+ let keepaliveTimer = null;
597
+ const stopKeepalive = () => {
598
+ if (keepaliveTimer !== null) clearInterval(keepaliveTimer);
599
+ keepaliveTimer = null;
600
+ };
589
601
  try {
590
602
  this.#signal?.throwIfAborted();
591
603
  if (message.kind === 'group' && message.addressed !== true) {
@@ -685,6 +697,29 @@ export class TextHarnessBridge {
685
697
  }));
686
698
  contextEnhanced = content !== originalContent;
687
699
  }
700
+ // Start the keepalive only after the inbound payload is ready, so a
701
+ // pre-prompt failure (image parsing, context building) cannot leave the
702
+ // timer running; the outermost finally below clears it on every path.
703
+ if (stream && stream.keepalive === true && typeof stream.refresh === 'function') {
704
+ let refreshing = false;
705
+ keepaliveTimer = setInterval(async () => {
706
+ // Skip ticks while the previous heartbeat is pending so redundant
707
+ // refreshes cannot queue ahead of the final answer on a slow network.
708
+ if (refreshing) return;
709
+ refreshing = true;
710
+ try {
711
+ await Promise.allSettled([
712
+ this.#bot.sendTyping?.(target),
713
+ stream.refresh(),
714
+ ]);
715
+ } catch {
716
+ // Keepalive is best-effort, including synchronous adapter failures.
717
+ } finally {
718
+ refreshing = false;
719
+ }
720
+ }, this.#keepaliveIntervalMs);
721
+ keepaliveTimer.unref?.();
722
+ }
688
723
  const { answer, artifacts = [] } = await askInWorkspaceSession({
689
724
  deferredDelivery: () => ({ coordinator: this.#deferred, target: this.#descriptor.key === 'whatsapp' ? { jid: target.jid, selfChat: target.selfChat } : target }),
690
725
  harness: this.#harness,
@@ -719,6 +754,7 @@ export class TextHarnessBridge {
719
754
  files: message.files,
720
755
  },
721
756
  });
757
+ stopKeepalive();
722
758
  if (batchSubmission) {
723
759
  this.#batches.complete(conversationKey, batchSubmission.token);
724
760
  }
@@ -805,6 +841,7 @@ export class TextHarnessBridge {
805
841
  }
806
842
  return delivery.receipt;
807
843
  } catch (error) {
844
+ stopKeepalive();
808
845
  const turnStopped = error?.code === 'turn-stopped';
809
846
  if (batchSubmission && turnStopped) {
810
847
  this.#batches.complete(conversationKey, batchSubmission.token);
@@ -875,6 +912,7 @@ export class TextHarnessBridge {
875
912
  }
876
913
  return error.deliveryReceipt;
877
914
  } finally {
915
+ stopKeepalive();
878
916
  await Promise.allSettled([
879
917
  this.#cancelPendingInteraction(conversationKey),
880
918
  this.#approvals.closeRoute(conversationKey),
@@ -325,48 +325,99 @@ class TelegramDeliveryStream {
325
325
  #providerMessageIds;
326
326
  #closed = false;
327
327
  #lastUpdate = null;
328
+ #lastBlock = null;
329
+ #keepalive = false;
330
+ #chain = Promise.resolve();
328
331
 
329
- constructor({ update, finish, fail, providerMessageIds = [], presentation, logger }) {
332
+ constructor({
333
+ update,
334
+ finish,
335
+ fail,
336
+ providerMessageIds = [],
337
+ presentation,
338
+ logger,
339
+ keepalive = false,
340
+ }) {
330
341
  this.#update = update;
331
342
  this.#finish = finish;
332
343
  this.#fail = fail;
333
344
  this.#providerMessageIds = providerMessageIds;
334
345
  this.presentation = presentation;
335
346
  this.#logger = logger;
347
+ // Only short-lived carriers (e.g. the private-chat Rich Draft) need a
348
+ // keepalive refresh; editing a real placeholder message with identical
349
+ // content would be rejected by the platform.
350
+ this.#keepalive = keepalive === true;
336
351
  }
337
352
 
338
353
  get providerMessageIds() {
339
354
  return [...this.#providerMessageIds];
340
355
  }
341
356
 
342
- async update(value) {
343
- if (this.#closed) return undefined;
344
- const block = createTextDeliveryBlock(value);
345
- const key = `${block.format}:${block.text}`;
346
- if (key === this.#lastUpdate) return undefined;
347
- this.#lastUpdate = key;
348
- try {
349
- return await this.#update(block);
350
- } catch (error) {
351
- this.#logger.warn?.('[dsh-im:telegram] rich stream update failed:', error);
352
- return undefined;
353
- }
357
+ /** Whether this carrier is short-lived and wants a keepalive heartbeat. */
358
+ get keepalive() {
359
+ return this.#keepalive;
354
360
  }
355
361
 
356
- async finish(value) {
357
- if (this.#closed) throw new Error('Message stream is already closed');
358
- this.#closed = true;
359
- const result = await this.#finish(createTextDeliveryBlock(value));
360
- this.#providerMessageIds.push(...(result?.providerMessageIds ?? []));
361
- return result;
362
+ /** Serialize every write so an in-flight keepalive refresh can never land
363
+ * after the final frame: finish()/fail() queue behind refresh()/update(). */
364
+ #enqueue(task) {
365
+ const run = this.#chain.then(task);
366
+ this.#chain = run.catch(() => undefined);
367
+ return run;
362
368
  }
363
369
 
364
- async fail(text) {
365
- if (this.#closed) return undefined;
366
- this.#closed = true;
367
- const result = await this.#fail(createTextDeliveryBlock(text, 'plain'));
368
- this.#providerMessageIds.push(...(result?.providerMessageIds ?? []));
369
- return result;
370
+ update(value) {
371
+ return this.#enqueue(async () => {
372
+ if (this.#closed) return undefined;
373
+ const block = createTextDeliveryBlock(value);
374
+ this.#lastBlock = block;
375
+ const key = `${block.format}:${block.text}`;
376
+ if (key === this.#lastUpdate) return undefined;
377
+ this.#lastUpdate = key;
378
+ try {
379
+ return await this.#update(block);
380
+ } catch (error) {
381
+ this.#logger.warn?.('[dsh-im:telegram] rich stream update failed:', error);
382
+ return undefined;
383
+ }
384
+ });
385
+ }
386
+
387
+ /** Re-send the most recent frame even when unchanged, to keep a short-lived
388
+ * carrier (the private-chat Rich Draft) visible during long silent
389
+ * stretches such as a running tool call. Serialized like update() so it
390
+ * never overtakes a later finish(). No-op for carriers without keepalive. */
391
+ refresh() {
392
+ return this.#enqueue(async () => {
393
+ if (this.#closed || !this.#keepalive || !this.#lastBlock) return undefined;
394
+ try {
395
+ return await this.#update(this.#lastBlock);
396
+ } catch (error) {
397
+ this.#logger.warn?.('[dsh-im:telegram] rich stream refresh failed:', error);
398
+ return undefined;
399
+ }
400
+ });
401
+ }
402
+
403
+ finish(value) {
404
+ return this.#enqueue(async () => {
405
+ if (this.#closed) throw new Error('Message stream is already closed');
406
+ this.#closed = true;
407
+ const result = await this.#finish(createTextDeliveryBlock(value));
408
+ this.#providerMessageIds.push(...(result?.providerMessageIds ?? []));
409
+ return result;
410
+ });
411
+ }
412
+
413
+ fail(text) {
414
+ return this.#enqueue(async () => {
415
+ if (this.#closed) return undefined;
416
+ this.#closed = true;
417
+ const result = await this.#fail(createTextDeliveryBlock(text, 'plain'));
418
+ this.#providerMessageIds.push(...(result?.providerMessageIds ?? []));
419
+ return result;
420
+ });
370
421
  }
371
422
 
372
423
  cancel() {
@@ -663,6 +714,7 @@ export class TelegramBotClient {
663
714
  finish: (block) => this.#sendRich(target, block),
664
715
  fail: (block) => this.#sendPlain(target, block.text),
665
716
  presentation: 'telegram-rich-draft',
717
+ keepalive: true,
666
718
  logger: this.#logger,
667
719
  });
668
720
  await stream.update(createTextDeliveryBlock('正在处理…', 'plain'));