@xmanrui/dsh-im 4.22.0 → 4.24.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 (148) hide show
  1. package/README.en.md +5 -4
  2. package/README.md +5 -4
  3. package/lib/client.js +2584 -1153
  4. package/lib/index.js +290 -298
  5. package/package.json +14 -2
  6. package/plugin-src/client/channel-card-meta.js +2 -0
  7. package/plugin-src/client/channel-logos.js +11 -0
  8. package/plugin-src/client/channels/dingtalk/api.js +5 -1
  9. package/plugin-src/client/channels/dingtalk/index.js +15 -13
  10. package/plugin-src/client/channels/email/api.js +52 -0
  11. package/plugin-src/client/channels/email/index.js +728 -0
  12. package/plugin-src/client/channels/email/styles.js +51 -0
  13. package/plugin-src/client/channels/feishu/api.js +4 -2
  14. package/plugin-src/client/channels/feishu/index.js +23 -7
  15. package/plugin-src/client/channels/imessage/index.js +2 -1
  16. package/plugin-src/client/channels/office/api.js +2 -0
  17. package/plugin-src/client/channels/office/index.js +6 -5
  18. package/plugin-src/client/channels/qq/api.js +7 -0
  19. package/plugin-src/client/channels/qq/index.js +16 -3
  20. package/plugin-src/client/channels/shared/token-api.js +8 -1
  21. package/plugin-src/client/channels/shared/token-channel.js +28 -3
  22. package/plugin-src/client/channels/slack/index.js +2 -1
  23. package/plugin-src/client/channels/telegram/index.js +3 -0
  24. package/plugin-src/client/channels/telegram/styles.js +12 -0
  25. package/plugin-src/client/channels/telegram/thinking-traces.js +25 -0
  26. package/plugin-src/client/channels/wecom/api.js +7 -1
  27. package/plugin-src/client/channels/wecom/index.js +16 -3
  28. package/plugin-src/client/channels/wecom-app/api.js +6 -1
  29. package/plugin-src/client/channels/wecom-app/index.js +16 -3
  30. package/plugin-src/client/channels/weixin/api.js +2 -1
  31. package/plugin-src/client/channels/weixin/connection-error.js +1 -71
  32. package/plugin-src/client/channels/whatsapp/api.js +5 -0
  33. package/plugin-src/client/channels/whatsapp/index.js +16 -3
  34. package/plugin-src/client/connection-error.js +87 -0
  35. package/plugin-src/client/global-settings.js +81 -1
  36. package/plugin-src/client/i18n.js +69 -1
  37. package/plugin-src/client/index.js +40 -4
  38. package/plugin-src/client/last-message-error.js +2 -1
  39. package/plugin-src/client/styles.js +8 -2
  40. package/plugin-src/host/build.mjs +4 -0
  41. package/plugin-src/host/channels/dingtalk/production.mjs +2 -0
  42. package/plugin-src/host/channels/dingtalk/rpc.mjs +7 -4
  43. package/plugin-src/host/channels/email/availability.mjs +60 -0
  44. package/plugin-src/host/channels/email/index.mjs +36 -0
  45. package/plugin-src/host/channels/email/production.mjs +58 -0
  46. package/plugin-src/host/channels/email/rpc.mjs +163 -0
  47. package/plugin-src/host/channels/feishu/production.mjs +3 -0
  48. package/plugin-src/host/channels/feishu/rpc.mjs +7 -4
  49. package/plugin-src/host/channels/imessage/rpc.mjs +13 -6
  50. package/plugin-src/host/channels/office/rpc.mjs +3 -1
  51. package/plugin-src/host/channels/qq/production.mjs +2 -0
  52. package/plugin-src/host/channels/qq/rpc.mjs +5 -3
  53. package/plugin-src/host/channels/shared/production.mjs +52 -5
  54. package/plugin-src/host/channels/shared/rpc.mjs +14 -3
  55. package/plugin-src/host/channels/shared/startup-error.mjs +4 -3
  56. package/plugin-src/host/channels/shared/startup.mjs +11 -6
  57. package/plugin-src/host/channels/shared/thinking-traces-rpc.mjs +11 -0
  58. package/plugin-src/host/channels/slack/production.mjs +2 -0
  59. package/plugin-src/host/channels/slack/rpc.mjs +5 -3
  60. package/plugin-src/host/channels/wecom/production.mjs +2 -0
  61. package/plugin-src/host/channels/wecom/rpc.mjs +5 -3
  62. package/plugin-src/host/channels/wecom-app/production.mjs +2 -0
  63. package/plugin-src/host/channels/wecom-app/rpc.mjs +5 -3
  64. package/plugin-src/host/channels/weixin/production.mjs +2 -0
  65. package/plugin-src/host/channels/whatsapp/production.mjs +2 -0
  66. package/plugin-src/host/channels/whatsapp/rpc.mjs +5 -3
  67. package/plugin-src/host/delivery-adapter.mjs +11 -0
  68. package/plugin-src/host/image-input-rpc.mjs +24 -0
  69. package/plugin-src/host/inbound-ttl-rpc.mjs +6 -1
  70. package/plugin-src/host/index.mjs +3 -0
  71. package/plugin-src/host/modern-harness-api.mjs +7 -2
  72. package/plugin-src/management-rpc.mjs +12 -2
  73. package/scripts/verify-package.mjs +13 -5
  74. package/src/channels/dingtalk/connection-error.mjs +5 -12
  75. package/src/channels/dingtalk/device-auth.mjs +4 -1
  76. package/src/channels/dingtalk/dingtalk-bridge.mjs +6 -4
  77. package/src/channels/dingtalk/dingtalk-controller.mjs +60 -45
  78. package/src/channels/dingtalk/dingtalk-runtime.mjs +20 -4
  79. package/src/channels/discord/discord-api.mjs +3 -3
  80. package/src/channels/discord/discord-runtime.mjs +18 -12
  81. package/src/channels/email/config-store.mjs +194 -0
  82. package/src/channels/email/email-api.mjs +20 -0
  83. package/src/channels/email/email-bridge.mjs +17 -0
  84. package/src/channels/email/email-controller.mjs +824 -0
  85. package/src/channels/email/email-runtime.mjs +699 -0
  86. package/src/channels/email/harness-client.mjs +7 -0
  87. package/src/channels/email/mail-format.mjs +91 -0
  88. package/src/channels/email/state-store.mjs +176 -0
  89. package/src/channels/email/transport.mjs +64 -0
  90. package/src/channels/email/transports/agent-mail.mjs +604 -0
  91. package/src/channels/email/transports/agently-cli.mjs +288 -0
  92. package/src/channels/email/transports/imap-smtp.mjs +165 -0
  93. package/src/channels/feishu/bridge.mjs +39 -11
  94. package/src/channels/feishu/feishu-channel.mjs +35 -0
  95. package/src/channels/feishu/feishu-runtime.mjs +15 -8
  96. package/src/channels/feishu/group-message-permission-manager.mjs +2 -1
  97. package/src/channels/feishu/live-cot.mjs +260 -0
  98. package/src/channels/feishu/message-utils.mjs +3 -2
  99. package/src/channels/feishu/multi-bot-controller.mjs +56 -47
  100. package/src/channels/feishu/registration-manager.mjs +8 -4
  101. package/src/channels/feishu/repair-manager.mjs +2 -1
  102. package/src/channels/feishu/slash-command-registry.mjs +17 -0
  103. package/src/channels/feishu/step-push-mode.mjs +10 -4
  104. package/src/channels/imessage/runtime.mjs +9 -5
  105. package/src/channels/office/office-controller.mjs +23 -12
  106. package/src/channels/office/office-runtime.mjs +6 -3
  107. package/src/channels/office/office-transport.mjs +1 -0
  108. package/src/channels/qq/qq-bridge.mjs +7 -5
  109. package/src/channels/qq/qq-controller.mjs +56 -42
  110. package/src/channels/qq/qq-runtime.mjs +16 -11
  111. package/src/channels/shared/bot-workspace-store.mjs +29 -10
  112. package/src/channels/shared/connection-error.mjs +191 -0
  113. package/src/channels/shared/connection-test.mjs +3 -1
  114. package/src/channels/shared/conversation-state-store.mjs +46 -1
  115. package/src/channels/shared/diagnostic-details.mjs +95 -0
  116. package/src/channels/shared/harness-client.mjs +287 -57
  117. package/src/channels/shared/i18n-en/diagnostics.mjs +55 -0
  118. package/src/channels/shared/i18n-en/email.mjs +25 -0
  119. package/src/channels/shared/i18n-en/image-input.mjs +17 -0
  120. package/src/channels/shared/i18n-en/shared-a.mjs +1 -0
  121. package/src/channels/shared/i18n-en/shared-c.mjs +1 -1
  122. package/src/channels/shared/i18n-en.mjs +6 -0
  123. package/src/channels/shared/image-input-policy.mjs +35 -0
  124. package/src/channels/shared/image-input-settings-store.mjs +60 -0
  125. package/src/channels/shared/image-input.mjs +124 -0
  126. package/src/channels/shared/image-prompt.mjs +15 -7
  127. package/src/channels/shared/message-failure.mjs +12 -0
  128. package/src/channels/shared/semantic/reply-reference.mjs +3 -3
  129. package/src/channels/shared/text-harness-bridge.mjs +150 -25
  130. package/src/channels/shared/token-bot-controller.mjs +52 -47
  131. package/src/channels/slack/slack-api.mjs +3 -3
  132. package/src/channels/slack/slack-controller.mjs +59 -51
  133. package/src/channels/slack/slack-runtime.mjs +18 -12
  134. package/src/channels/telegram/config-store.mjs +4 -1
  135. package/src/channels/telegram/telegram-api.mjs +2 -2
  136. package/src/channels/telegram/telegram-controller.mjs +13 -1
  137. package/src/channels/telegram/telegram-runtime.mjs +215 -14
  138. package/src/channels/wecom/wecom-bridge.mjs +15 -6
  139. package/src/channels/wecom/wecom-controller.mjs +51 -37
  140. package/src/channels/wecom/wecom-runtime.mjs +10 -6
  141. package/src/channels/wecom-app/wecom-app-bridge.mjs +7 -5
  142. package/src/channels/wecom-app/wecom-app-controller.mjs +50 -37
  143. package/src/channels/wecom-app/wecom-app-runtime.mjs +2 -1
  144. package/src/channels/weixin/connection-error.mjs +26 -92
  145. package/src/channels/weixin/diagnostic-details.mjs +1 -63
  146. package/src/channels/weixin/weixin-bridge.mjs +7 -5
  147. package/src/channels/whatsapp/whatsapp-controller.mjs +34 -17
  148. package/src/channels/whatsapp/whatsapp-runtime.mjs +35 -8
@@ -64,6 +64,7 @@ import {
64
64
  providerMessageIdsFor,
65
65
  } from './semantic/delivery.mjs';
66
66
  import {
67
+ messageFailureDiagnostic,
67
68
  channelDeliveryFailure,
68
69
  clearLastMessageFailure,
69
70
  messageFailureText,
@@ -74,6 +75,15 @@ import { beginStatusReaction } from './status-reaction.mjs';
74
75
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
75
76
  const FILE_ONLY_COMPLETION_TEXT = '任务已完成。';
76
77
 
78
+ /**
79
+ * How many times one inbound message may be re-delivered after a failed turn.
80
+ *
81
+ * A failure releases the message's `markSeen` so the next poll retries it, which
82
+ * would otherwise loop forever on a mail that always fails. After this many
83
+ * attempts the mark stays and the message is treated as handled.
84
+ */
85
+ const MAX_DELIVERY_ATTEMPTS = 3;
86
+
77
87
  function cleanText(value) {
78
88
  return typeof value === 'string' ? value.trim() : '';
79
89
  }
@@ -83,7 +93,7 @@ function canClaimInteractionReply(message, pending, senderId) {
83
93
  && (message.kind !== 'group' || message.addressed === true)
84
94
  && !hasInboundImages(message)
85
95
  && !hasInboundFiles(message)
86
- && Boolean(cleanText(message.content));
96
+ && Boolean(controlTextOf(message));
87
97
  }
88
98
 
89
99
  function artifactFailureText(fileName, error, descriptor) {
@@ -138,6 +148,18 @@ export function createTextBridgeStatus() {
138
148
  };
139
149
  }
140
150
 
151
+ /**
152
+ * The text a channel's control commands and approval decisions are parsed from.
153
+ *
154
+ * A channel may decorate `content` for the model — the email channel prepends
155
+ * the mail headers so a subject line reaches the model — while `controlText`
156
+ * keeps the undecorated body. Without this a decorated message made `/help`
157
+ * and "批准" unrecognisable.
158
+ */
159
+ function controlTextOf(message) {
160
+ return cleanText(message?.controlText ?? message?.content);
161
+ }
162
+
141
163
  export class TextHarnessBridge {
142
164
  #descriptor;
143
165
  #bot;
@@ -146,6 +168,7 @@ export class TextHarnessBridge {
146
168
  #deferred;
147
169
  #contextEnhancement;
148
170
  #accessPolicy;
171
+ #thinkingTraces;
149
172
  #status;
150
173
  #logger;
151
174
  #replyTimeoutMs;
@@ -161,6 +184,10 @@ export class TextHarnessBridge {
161
184
  #approvals;
162
185
  #batches = new BatchInputManager();
163
186
  #interactionCard;
187
+ // Delivery attempts per message id. A failed turn releases its `markSeen` so
188
+ // the next poll can retry, and this counter is what keeps that from becoming
189
+ // an infinite loop on a mail that always fails.
190
+ #deliveryAttempts = new Map();
164
191
 
165
192
  constructor({
166
193
  descriptor,
@@ -169,6 +196,7 @@ export class TextHarnessBridge {
169
196
  state,
170
197
  contextEnhancement,
171
198
  accessPolicy,
199
+ thinkingTraces = false,
172
200
  status = createTextBridgeStatus(),
173
201
  logger = console,
174
202
  replyTimeoutMs = 600_000,
@@ -185,6 +213,7 @@ export class TextHarnessBridge {
185
213
  this.#state = state;
186
214
  this.#contextEnhancement = contextEnhancement;
187
215
  this.#accessPolicy = accessPolicy;
216
+ this.#thinkingTraces = thinkingTraces;
188
217
  this.#status = status;
189
218
  this.#logger = logger;
190
219
  this.#replyTimeoutMs = replyTimeoutMs;
@@ -229,7 +258,11 @@ export class TextHarnessBridge {
229
258
  const decision = accessDecision ?? evaluateInboundAccess(this.#accessPolicy, {
230
259
  conversationType: kind,
231
260
  senderIds: [senderId, cleanText(normalized.senderAlternateId)].filter(Boolean),
232
- text: normalized.content,
261
+ // The same control text the command runner below parses. A channel may
262
+ // decorate `content` for the model (email prepends the mail headers), and
263
+ // a decorated body never looks like a command — so reading `content` here
264
+ // recognized no command at all and skipped the command-permission gate.
265
+ text: controlTextOf(normalized),
233
266
  hasImages,
234
267
  hasFiles,
235
268
  });
@@ -282,7 +315,7 @@ export class TextHarnessBridge {
282
315
 
283
316
  const key = `${normalized.kind}:${normalized.conversationId}`;
284
317
  const pending = this.#pendingInteractions.get(key);
285
- const text = cleanText(normalized.content);
318
+ const text = controlTextOf(normalized);
286
319
  const batchCommand = isBatchInputCommand(text);
287
320
  if (batchCommand && normalized.kind === 'group' && normalized.addressed === true) {
288
321
  return this.#finishLocalMessage(
@@ -362,7 +395,12 @@ export class TextHarnessBridge {
362
395
  key,
363
396
  actor: senderId,
364
397
  messageId,
365
- text: hasInboundImages(normalized) || hasInboundFiles(normalized) ? '' : normalized.content,
398
+ // Approval decisions are parsed from the control text, exactly like the
399
+ // commands above: a decorated `content` hid "批准" behind the mail headers,
400
+ // so a plain-body approval never claimed its pending request.
401
+ text: hasInboundImages(normalized) || hasInboundFiles(normalized)
402
+ ? ''
403
+ : controlTextOf(normalized),
366
404
  addressed: normalized.kind !== 'group' || normalized.addressed === true,
367
405
  hasPendingQuestion: Boolean(pending),
368
406
  questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
@@ -441,7 +479,7 @@ export class TextHarnessBridge {
441
479
  const failure = setLastMessageFailure(this.#status, error);
442
480
  this.#logger.error?.(
443
481
  `[dsh-im:${this.#descriptor.key}] failed to process a batch input message [${failure.referenceId}]:`,
444
- error,
482
+ messageFailureDiagnostic(error, failure),
445
483
  );
446
484
  }).finally(() => {
447
485
  this.#acceptedMessageIds.delete(messageId);
@@ -500,7 +538,7 @@ export class TextHarnessBridge {
500
538
  const target = message.replyTarget;
501
539
  try {
502
540
  const result = await runner(
503
- cleanText(message.content),
541
+ controlTextOf(message),
504
542
  this.#harness,
505
543
  this.#state,
506
544
  key,
@@ -550,7 +588,7 @@ export class TextHarnessBridge {
550
588
  const failure = setLastMessageFailure(this.#status, error);
551
589
  this.#logger.error?.(
552
590
  `[dsh-im:${this.#descriptor.key}] failed to process a command [${failure.referenceId}]:`,
553
- error,
591
+ messageFailureDiagnostic(error, failure),
554
592
  );
555
593
  await this.#bot.sendText(target, messageFailureText(failure)).catch(() => undefined);
556
594
  }
@@ -615,10 +653,14 @@ export class TextHarnessBridge {
615
653
  }
616
654
 
617
655
  const target = message.replyTarget;
618
- const text = cleanText(message.content);
656
+ const text = controlTextOf(message);
619
657
  const batchSubmission = message.batchSubmission;
620
658
  let stream = null;
621
659
  let semanticStream = false;
660
+ // Thinking streams accept the same delivery-block contract as semantic
661
+ // streams: finish() must receive { text, format } so a markdown answer
662
+ // renders rich instead of falling back to plain text.
663
+ let thinkingStream = false;
622
664
  // A keepalive heartbeat keeps short-lived carriers (e.g. Telegram's
623
665
  // private-chat Rich Draft) visible during long silent stretches such as a
624
666
  // running tool call. Declared outside the try so every exit path (including
@@ -689,29 +731,51 @@ export class TextHarnessBridge {
689
731
  this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] typing indicator failed:`, error);
690
732
  });
691
733
  let streamFinished = false;
692
- if (typeof this.#bot.openDeliveryStream === 'function') {
734
+ if (this.#thinkingTraces && typeof this.#bot.openThinkingStream === 'function') {
693
735
  try {
694
- stream = await this.#bot.openDeliveryStream(target);
695
- semanticStream = true;
736
+ stream = await this.#bot.openThinkingStream(target);
737
+ thinkingStream = true;
696
738
  } catch (error) {
739
+ stream = null;
697
740
  this.#logger.warn?.(
698
- `[dsh-im:${this.#descriptor.key}] unable to start a semantic reply stream; using final delivery:`,
741
+ `[dsh-im:${this.#descriptor.key}] unable to start a thinking stream; using final delivery:`,
699
742
  error,
700
743
  );
701
744
  }
702
- } else if (typeof this.#bot.openStream === 'function') {
703
- try {
704
- stream = await this.#bot.openStream(target);
705
- } catch (error) {
706
- this.#logger.warn?.(
707
- `[dsh-im:${this.#descriptor.key}] unable to start a streamed reply; using text:`,
708
- error,
709
- );
745
+ }
746
+ if (!stream) {
747
+ if (typeof this.#bot.openDeliveryStream === 'function') {
748
+ try {
749
+ stream = await this.#bot.openDeliveryStream(target);
750
+ semanticStream = true;
751
+ } catch (error) {
752
+ this.#logger.warn?.(
753
+ `[dsh-im:${this.#descriptor.key}] unable to start a semantic reply stream; using final delivery:`,
754
+ error,
755
+ );
756
+ }
757
+ } else if (typeof this.#bot.openStream === 'function') {
758
+ try {
759
+ stream = await this.#bot.openStream(target);
760
+ } catch (error) {
761
+ this.#logger.warn?.(
762
+ `[dsh-im:${this.#descriptor.key}] unable to start a streamed reply; using text:`,
763
+ error,
764
+ );
765
+ }
710
766
  }
711
767
  }
768
+ const thinkingMode = Boolean(stream
769
+ && this.#thinkingTraces
770
+ && typeof stream.sendToolTrace === 'function');
771
+ // What the model sees. A message may carry two texts: `controlText`, the
772
+ // plain body the parsers read, and `content`, the same body decorated for
773
+ // the model — the email channel prefixes the mail headers there, so a
774
+ // subject-only instruction is not silently dropped. Falling back to the
775
+ // parsed text kept commands working but lost that decoration entirely.
712
776
  let content = hasImages || hasReply
713
- ? await promptContentForInboundMessage(message, { signal: this.#signal })
714
- : undefined;
777
+ ? await promptContentForInboundMessage(message, { signal: this.#signal, deferImages: true })
778
+ : cleanText(message.content) || undefined;
715
779
  const snapshot = this.#acceptedMessageIds.get(messageId);
716
780
  let contextEnhanced = false;
717
781
  if (snapshot) {
@@ -766,7 +830,24 @@ export class TextHarnessBridge {
766
830
  timeoutMs: this.#replyTimeoutMs,
767
831
  signal: this.#signal,
768
832
  control: { owner: this, key: conversationKey },
833
+ // Thinking mode needs every update (reasoning + tool calls) in
834
+ // order; latest mode filters as before. Only thinking mode subscribes
835
+ // to reasoning updates, so default-mode consumers on other channels
836
+ // never see them (their progress handlers would render update.text).
837
+ progressMode: thinkingMode ? 'all' : undefined,
838
+ reasoning: thinkingMode,
769
839
  onUpdate: stream ? async (update) => {
840
+ if (thinkingMode) {
841
+ // Only the 💭 and 🔧 lines are surfaced; status/text updates
842
+ // and the assistant-message canonical text are not.
843
+ if (update.type === 'reasoning') {
844
+ await stream.sendThinking(update.text);
845
+ } else if (update.type === 'tool') {
846
+ await stream.sendToolTrace(update.name, update.arguments);
847
+ }
848
+ return;
849
+ }
850
+ if (update.type === 'reasoning') return;
770
851
  const progress = update.type === 'text' ? update.text
771
852
  : update.type === 'tool' ? t('正在使用{name}…', { name: update.name }) : update.text;
772
853
  if (progress) {
@@ -790,6 +871,7 @@ export class TextHarnessBridge {
790
871
  },
791
872
  onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
792
873
  files: message.files,
874
+ images: message.images,
793
875
  },
794
876
  });
795
877
  stopKeepalive();
@@ -805,7 +887,7 @@ export class TextHarnessBridge {
805
887
  let textReceipt = null;
806
888
  if (stream) {
807
889
  try {
808
- const result = await stream.finish(semanticStream
890
+ const result = await stream.finish(semanticStream || thinkingStream
809
891
  ? createTextDeliveryBlock(visibleAnswer, answerFormat)
810
892
  : visibleAnswer);
811
893
  streamFinished = true;
@@ -877,6 +959,8 @@ export class TextHarnessBridge {
877
959
  clearLastMessageFailure(this.#status);
878
960
  }
879
961
  }
962
+ // The turn produced an answer, so its retry budget is spent and reset.
963
+ this.#deliveryAttempts.delete(messageId);
880
964
  return delivery.receipt;
881
965
  } catch (error) {
882
966
  stopKeepalive();
@@ -934,7 +1018,7 @@ export class TextHarnessBridge {
934
1018
  : messageFailureText(failure);
935
1019
  this.#logger.error?.(
936
1020
  `[dsh-im:${this.#descriptor.key}] failed to process a message [${failure.referenceId}]:`,
937
- error,
1021
+ messageFailureDiagnostic(error, failure),
938
1022
  );
939
1023
  if (await presentStreamFailure(failureText)) {
940
1024
  return error.deliveryReceipt;
@@ -948,6 +1032,10 @@ export class TextHarnessBridge {
948
1032
  sendError,
949
1033
  );
950
1034
  }
1035
+ // The turn produced no answer, so this id is "attempted", not "handled".
1036
+ // Releasing the mark lets the next poll retry it; `alreadyRecorded` means
1037
+ // an outer caller owns the mark, so it is left alone there.
1038
+ if (!alreadyRecorded) await this.#releaseFailedDelivery(messageId);
951
1039
  return error.deliveryReceipt;
952
1040
  } finally {
953
1041
  stopKeepalive();
@@ -958,6 +1046,43 @@ export class TextHarnessBridge {
958
1046
  }
959
1047
  }
960
1048
 
1049
+ /**
1050
+ * Release the `markSeen` of a turn that failed without delivering anything,
1051
+ * so the next poll can pick the message up again.
1052
+ *
1053
+ * Bounded: a message is retried at most `MAX_DELIVERY_ATTEMPTS` times, after
1054
+ * which the mark stays and the id becomes a tombstone. That keeps a mail that
1055
+ * always fails from being re-executed forever, while a transient Harness
1056
+ * outage no longer drops it permanently.
1057
+ *
1058
+ * Returns true when the message was released and will be retried.
1059
+ */
1060
+ async #releaseFailedDelivery(messageId) {
1061
+ const attempts = (this.#deliveryAttempts.get(messageId) ?? 0) + 1;
1062
+ if (attempts >= MAX_DELIVERY_ATTEMPTS) {
1063
+ // Give up: keep the mark so the id is never replayed, and stop tracking
1064
+ // it. The sender has already been told the turn failed.
1065
+ this.#deliveryAttempts.delete(messageId);
1066
+ this.#logger.warn?.(
1067
+ `[dsh-im:${this.#descriptor.key}] giving up on a message after `
1068
+ + `${attempts} failed delivery attempts; it will not be retried.`,
1069
+ );
1070
+ return false;
1071
+ }
1072
+ this.#deliveryAttempts.set(messageId, attempts);
1073
+ if (typeof this.#state?.unmarkSeen !== 'function') return false;
1074
+ try {
1075
+ await this.#state.unmarkSeen(messageId);
1076
+ return true;
1077
+ } catch (error) {
1078
+ this.#logger.warn?.(
1079
+ `[dsh-im:${this.#descriptor.key}] unable to release a failed message for retry:`,
1080
+ error,
1081
+ );
1082
+ return false;
1083
+ }
1084
+ }
1085
+
961
1086
  /**
962
1087
  * Advance the pending interaction with one answer.
963
1088
  *
@@ -1000,7 +1125,7 @@ export class TextHarnessBridge {
1000
1125
  }
1001
1126
 
1002
1127
  const target = message.replyTarget;
1003
- const text = cleanText(message.content);
1128
+ const text = controlTextOf(message);
1004
1129
  if (!text || hasInboundImages(message) || hasInboundFiles(message)) {
1005
1130
  try {
1006
1131
  await this.#bot.sendText(target, t('请用文字回答当前问题。'));
@@ -1,3 +1,4 @@
1
+ import { extractConnectionEvidence, atConnectionStage, createConnectionDiagnostics } from './connection-error.mjs';
1
2
  import { connectionTestMessage } from './connection-test.mjs';
2
3
  import { t } from './i18n.mjs';
3
4
  import { publicMessageFailure } from './message-failure.mjs';
@@ -20,6 +21,7 @@ export class TokenBotController {
20
21
  #createRuntime;
21
22
  #deleteState;
22
23
  #logger;
24
+ #diagnostics;
23
25
  #runtimes = new Map();
24
26
  #errors = new Map();
25
27
  #transitions = new Map();
@@ -61,6 +63,14 @@ export class TokenBotController {
61
63
  this.#createRuntime = createRuntime;
62
64
  this.#deleteState = deleteState;
63
65
  this.#logger = logger;
66
+ this.#diagnostics = createConnectionDiagnostics({ channel: descriptor.key, logger });
67
+ }
68
+
69
+ get diagnostics() { return this.#diagnostics; }
70
+
71
+ #failure(error, code, message) {
72
+ const stage = code.startsWith('qr-') ? 'qr.begin' : code === 'activation-failed' ? 'activation' : 'connection.start';
73
+ return this.#diagnostics.report(error, { reuse: true, stage, publicError: { code, message } }).publicError;
64
74
  }
65
75
 
66
76
  async initialize() {
@@ -68,30 +78,27 @@ export class TokenBotController {
68
78
  for (const config of this.#configStore.list()) {
69
79
  await this.#withBotTransition(config.botId, async () => {
70
80
  if (this.#closed || this.#runtimes.get(config.botId)?.status?.ready) return;
71
- const token = await this.#resolveToken(config.tokenRef);
72
- if (!token) {
73
- this.#errors.set(config.botId, safeError(
74
- 'missing-token',
75
- t('{label}机器人凭据缺失,请移除后重新接入。', {
76
- label: this.#descriptor.label,
77
- }),
78
- ));
79
- return;
80
- }
81
81
  try {
82
+ const token = await this.#resolveToken(config.tokenRef);
83
+ if (!token) {
84
+ this.#errors.set(config.botId, safeError(
85
+ 'missing-token',
86
+ t('{label}机器人凭据缺失,请移除后重新接入。', {
87
+ label: this.#descriptor.label,
88
+ }),
89
+ ));
90
+ return;
91
+ }
82
92
  await this.#startRuntime(config, token);
83
93
  this.#errors.delete(config.botId);
84
94
  } catch (error) {
85
- this.#errors.set(config.botId, safeError(
95
+ this.#errors.set(config.botId, this.#failure(error,
86
96
  'connection-failed',
87
97
  t('{label}连接未就绪,插件会自动重试。', {
88
98
  label: this.#descriptor.label,
89
99
  }),
90
100
  ));
91
- this.#logger.warn?.(
92
- `[dsh-im:${this.#descriptor.key}] bot ${config.botId} failed to initialize:`,
93
- error,
94
- );
101
+
95
102
  } finally {
96
103
  this.#touch();
97
104
  }
@@ -112,7 +119,7 @@ export class TokenBotController {
112
119
  await this.#withBotTransition(identity.botId, async () => {
113
120
  if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`);
114
121
  const previousConfig = this.#configStore.getByPlatformId(platformId);
115
- const previousToken = await this.#credentials.resolve(identity.tokenRef).catch(() => undefined);
122
+ const previousToken = await atConnectionStage('credential.read', () => this.#credentials.resolve(identity.tokenRef), 'credential-store');
116
123
  const config = {
117
124
  botId: identity.botId,
118
125
  platformId,
@@ -122,9 +129,9 @@ export class TokenBotController {
122
129
  createdAt: previousConfig?.createdAt ?? new Date().toISOString(),
123
130
  connectedAt: new Date().toISOString(),
124
131
  };
125
- await this.#credentials.set(identity.tokenRef, normalizedToken);
132
+ await atConnectionStage('credential.save', () => this.#credentials.set(identity.tokenRef, normalizedToken), 'credential-store');
126
133
  try {
127
- await this.#configStore.save(config);
134
+ await atConnectionStage('account.save', () => this.#configStore.save(config), 'account-config');
128
135
  } catch (error) {
129
136
  await this.#restoreCredential(identity.tokenRef, previousToken);
130
137
  throw error;
@@ -133,16 +140,13 @@ export class TokenBotController {
133
140
  await this.#startRuntime(config, normalizedToken);
134
141
  this.#errors.delete(identity.botId);
135
142
  } catch (error) {
136
- this.#errors.set(identity.botId, safeError(
143
+ this.#errors.set(identity.botId, this.#failure(error,
137
144
  'connection-failed',
138
145
  t('{label}机器人已接入,消息连接暂未就绪。', {
139
146
  label: this.#descriptor.label,
140
147
  }),
141
148
  ));
142
- this.#logger.warn?.(
143
- `[dsh-im:${this.#descriptor.key}] bot ${identity.botId} credential connection failed:`,
144
- error,
145
- );
149
+
146
150
  }
147
151
  this.#touch();
148
152
  });
@@ -159,7 +163,7 @@ export class TokenBotController {
159
163
  await this.#startRuntime(config, token);
160
164
  this.#errors.delete(botId);
161
165
  } catch (error) {
162
- this.#errors.set(botId, safeError(
166
+ this.#errors.set(botId, this.#failure(error,
163
167
  'connection-failed',
164
168
  t('{label}连接仍未就绪,请稍后重试。', {
165
169
  label: this.#descriptor.label,
@@ -184,12 +188,12 @@ export class TokenBotController {
184
188
  if (!token) throw new Error(`${this.#descriptor.label} bot token is missing`);
185
189
  if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`);
186
190
  const nextConfig = update(config);
187
- const savedConfig = await this.#configStore.save(nextConfig);
191
+ const savedConfig = await atConnectionStage('account.save', () => this.#configStore.save(nextConfig), 'account-config');
188
192
  try {
189
193
  await this.#startRuntime(savedConfig, token);
190
194
  this.#errors.delete(botId);
191
195
  } catch (error) {
192
- this.#errors.set(botId, safeError(
196
+ this.#errors.set(botId, this.#failure(error,
193
197
  'connection-failed',
194
198
  t('{label}连接仍未就绪,请稍后重试。', {
195
199
  label: this.#descriptor.label,
@@ -244,31 +248,32 @@ export class TokenBotController {
244
248
  }
245
249
 
246
250
  async deleteBot(botId) {
251
+ const warnings = [];
247
252
  const config = this.#configStore.get(botId);
248
253
  if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);
249
254
  await this.#withBotTransition(botId, async () => {
250
- const previous = await this.#credentials.resolve(config.tokenRef).catch(() => undefined);
255
+ const previous = await atConnectionStage('credential.read', () => this.#credentials.resolve(config.tokenRef), 'credential-store');
251
256
  await this.#stopRuntime(botId);
252
257
  try {
253
- await this.#credentials.unset(config.tokenRef);
254
- await this.#configStore.remove(botId);
258
+ await atConnectionStage('credential.remove', () => this.#credentials.unset(config.tokenRef), 'credential-store');
259
+ await atConnectionStage('account.remove', () => this.#configStore.remove(botId), 'account-config');
255
260
  } catch (error) {
256
- if (previous?.value) {
257
- await this.#credentials.set(config.tokenRef, previous.value).catch(() => undefined);
258
- await this.#startRuntime(config, previous.value).catch(() => undefined);
261
+ if (!this.#configStore.get(botId)) {
262
+ warnings.push(this.#diagnostics.report(error, { operation: 'bot.delete', stage: 'workspace.cleanup', warning: true,
263
+ publicError: { code: 'workspace-cleanup-failed', message: '账号已移除,但本地状态清理失败。' } }).publicError);
264
+ } else {
265
+ if (previous?.value) {
266
+ await atConnectionStage('credential.save', () => this.#credentials.set(config.tokenRef, previous.value), 'credential-store').catch(() => undefined);
267
+ await this.#startRuntime(config, previous.value).catch(() => undefined);
268
+ }
269
+ throw new Error(`Unable to remove the ${this.#descriptor.label} bot safely.`, { cause: error });
259
270
  }
260
- throw new Error(`Unable to remove the ${this.#descriptor.label} bot safely.`, { cause: error });
261
271
  }
262
- await this.#deleteState({ botId, config }).catch((error) => {
263
- this.#logger.warn?.(
264
- `[dsh-im:${this.#descriptor.key}] bot ${botId} state cleanup failed:`,
265
- error,
266
- );
267
- });
272
+ await this.#deleteState({ botId, config }).catch(error => { warnings.push(this.#diagnostics.report(error, { reuse: true, operation: 'bot.delete', stage: 'state.cleanup', resource: 'account-state', warning: true, publicError: { code: 'cleanup-failed', message: '账号已移除,但本地状态清理失败。' } }).publicError); });
268
273
  this.#errors.delete(botId);
269
274
  this.#touch();
270
275
  });
271
- return this.status();
276
+ return { ...this.status(), ...(warnings.length ? { warnings } : {}) };
272
277
  }
273
278
 
274
279
  status() {
@@ -309,7 +314,7 @@ export class TokenBotController {
309
314
  messagesReplied: runtimeStatus?.messagesReplied ?? 0,
310
315
  },
311
316
  lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
312
- error: structuredClone(this.#errors.get(config.botId) ?? null),
317
+ error: structuredClone(runtimeStatus?.error ?? this.#errors.get(config.botId) ?? null),
313
318
  };
314
319
  });
315
320
  const connectedCount = bots.filter((bot) => bot.connected).length;
@@ -342,7 +347,7 @@ export class TokenBotController {
342
347
  } catch (error) {
343
348
  this.#logger.warn?.(
344
349
  `[dsh-im:${this.#descriptor.key}] bot ${botId} command menu refresh failed:`,
345
- error,
350
+ extractConnectionEvidence(error).details,
346
351
  );
347
352
  return false;
348
353
  }
@@ -361,7 +366,7 @@ export class TokenBotController {
361
366
  if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`);
362
367
  await this.#stopRuntime(config.botId);
363
368
  if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`);
364
- const runtime = await this.#createRuntime({ botId: config.botId, config, token });
369
+ const runtime = await atConnectionStage('runtime.prepare', () => this.#createRuntime({ botId: config.botId, config, token }));
365
370
  if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
366
371
  throw new TypeError(`createRuntime returned an invalid ${this.#descriptor.label} runtime`);
367
372
  }
@@ -381,19 +386,19 @@ export class TokenBotController {
381
386
  await runtime?.stop().catch((error) => {
382
387
  this.#logger.warn?.(
383
388
  `[dsh-im:${this.#descriptor.key}] bot ${botId} failed to stop cleanly:`,
384
- error,
389
+ extractConnectionEvidence(error).details,
385
390
  );
386
391
  });
387
392
  }
388
393
 
389
394
  async #resolveToken(ref) {
390
- const result = await this.#credentials.resolve(ref).catch(() => undefined);
395
+ const result = await atConnectionStage('credential.read', () => this.#credentials.resolve(ref), 'credential-store');
391
396
  return cleanString(result?.value);
392
397
  }
393
398
 
394
399
  async #restoreCredential(ref, previous) {
395
- if (previous?.value) await this.#credentials.set(ref, previous.value).catch(() => undefined);
396
- else await this.#credentials.unset(ref).catch(() => undefined);
400
+ if (previous?.value) await atConnectionStage('credential.save', () => this.#credentials.set(ref, previous.value), 'credential-store').catch(() => undefined);
401
+ else await atConnectionStage('credential.remove', () => this.#credentials.unset(ref), 'credential-store').catch(() => undefined);
397
402
  }
398
403
 
399
404
  #withBotTransition(botId, operation) {
@@ -522,7 +522,7 @@ export class SlackApi {
522
522
  });
523
523
  } catch (error) {
524
524
  if (error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error;
525
- throw new Error(`Slack ${method} transport failed`);
525
+ throw new Error(`Slack ${method} transport failed`, { cause: error });
526
526
  }
527
527
 
528
528
  if (tokenKind === 'bot') {
@@ -533,8 +533,8 @@ export class SlackApi {
533
533
  let payload;
534
534
  try {
535
535
  payload = await response.json();
536
- } catch {
537
- throw new Error(`Slack ${method} returned invalid JSON`);
536
+ } catch (cause) {
537
+ throw Object.assign(new Error(`Slack ${method} returned invalid JSON`, { cause }), { status: response?.status });
538
538
  }
539
539
  if (response.status === 429 && retry) {
540
540
  const seconds = Number(response.headers.get('retry-after')) || 1;