@xmanrui/dsh-im 4.22.0 → 4.23.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 (137) hide show
  1. package/README.en.md +3 -3
  2. package/README.md +3 -3
  3. package/lib/client.js +2505 -1152
  4. package/lib/index.js +290 -299
  5. package/package.json +8 -1
  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 +16 -3
  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 +5 -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/wecom/api.js +7 -1
  24. package/plugin-src/client/channels/wecom/index.js +16 -3
  25. package/plugin-src/client/channels/wecom-app/api.js +6 -1
  26. package/plugin-src/client/channels/wecom-app/index.js +16 -3
  27. package/plugin-src/client/channels/weixin/api.js +2 -1
  28. package/plugin-src/client/channels/weixin/connection-error.js +1 -71
  29. package/plugin-src/client/channels/whatsapp/api.js +5 -0
  30. package/plugin-src/client/channels/whatsapp/index.js +16 -3
  31. package/plugin-src/client/connection-error.js +87 -0
  32. package/plugin-src/client/global-settings.js +81 -1
  33. package/plugin-src/client/i18n.js +61 -0
  34. package/plugin-src/client/index.js +30 -2
  35. package/plugin-src/client/last-message-error.js +2 -1
  36. package/plugin-src/client/styles.js +6 -0
  37. package/plugin-src/host/build.mjs +4 -0
  38. package/plugin-src/host/channels/dingtalk/production.mjs +2 -0
  39. package/plugin-src/host/channels/dingtalk/rpc.mjs +7 -4
  40. package/plugin-src/host/channels/email/availability.mjs +60 -0
  41. package/plugin-src/host/channels/email/index.mjs +36 -0
  42. package/plugin-src/host/channels/email/production.mjs +58 -0
  43. package/plugin-src/host/channels/email/rpc.mjs +163 -0
  44. package/plugin-src/host/channels/feishu/production.mjs +3 -0
  45. package/plugin-src/host/channels/feishu/rpc.mjs +7 -4
  46. package/plugin-src/host/channels/imessage/rpc.mjs +13 -6
  47. package/plugin-src/host/channels/office/rpc.mjs +3 -1
  48. package/plugin-src/host/channels/qq/production.mjs +2 -0
  49. package/plugin-src/host/channels/qq/rpc.mjs +5 -3
  50. package/plugin-src/host/channels/shared/production.mjs +52 -5
  51. package/plugin-src/host/channels/shared/rpc.mjs +5 -3
  52. package/plugin-src/host/channels/shared/startup-error.mjs +4 -3
  53. package/plugin-src/host/channels/shared/startup.mjs +11 -6
  54. package/plugin-src/host/channels/slack/production.mjs +2 -0
  55. package/plugin-src/host/channels/slack/rpc.mjs +5 -3
  56. package/plugin-src/host/channels/wecom/production.mjs +2 -0
  57. package/plugin-src/host/channels/wecom/rpc.mjs +5 -3
  58. package/plugin-src/host/channels/wecom-app/production.mjs +2 -0
  59. package/plugin-src/host/channels/wecom-app/rpc.mjs +5 -3
  60. package/plugin-src/host/channels/weixin/production.mjs +2 -0
  61. package/plugin-src/host/channels/whatsapp/production.mjs +2 -0
  62. package/plugin-src/host/channels/whatsapp/rpc.mjs +5 -3
  63. package/plugin-src/host/delivery-adapter.mjs +11 -0
  64. package/plugin-src/host/image-input-rpc.mjs +24 -0
  65. package/plugin-src/host/inbound-ttl-rpc.mjs +6 -1
  66. package/plugin-src/host/index.mjs +3 -0
  67. package/plugin-src/management-rpc.mjs +12 -2
  68. package/scripts/verify-package.mjs +3 -1
  69. package/src/channels/dingtalk/connection-error.mjs +5 -12
  70. package/src/channels/dingtalk/device-auth.mjs +4 -1
  71. package/src/channels/dingtalk/dingtalk-bridge.mjs +6 -4
  72. package/src/channels/dingtalk/dingtalk-controller.mjs +60 -45
  73. package/src/channels/dingtalk/dingtalk-runtime.mjs +20 -4
  74. package/src/channels/discord/discord-api.mjs +3 -3
  75. package/src/channels/discord/discord-runtime.mjs +18 -12
  76. package/src/channels/email/config-store.mjs +194 -0
  77. package/src/channels/email/email-api.mjs +20 -0
  78. package/src/channels/email/email-bridge.mjs +17 -0
  79. package/src/channels/email/email-controller.mjs +824 -0
  80. package/src/channels/email/email-runtime.mjs +692 -0
  81. package/src/channels/email/harness-client.mjs +7 -0
  82. package/src/channels/email/mail-format.mjs +91 -0
  83. package/src/channels/email/state-store.mjs +176 -0
  84. package/src/channels/email/transport.mjs +64 -0
  85. package/src/channels/email/transports/agent-mail.mjs +593 -0
  86. package/src/channels/email/transports/agently-cli.mjs +288 -0
  87. package/src/channels/email/transports/imap-smtp.mjs +165 -0
  88. package/src/channels/feishu/bridge.mjs +8 -7
  89. package/src/channels/feishu/feishu-runtime.mjs +15 -8
  90. package/src/channels/feishu/group-message-permission-manager.mjs +2 -1
  91. package/src/channels/feishu/message-utils.mjs +3 -2
  92. package/src/channels/feishu/multi-bot-controller.mjs +56 -47
  93. package/src/channels/feishu/registration-manager.mjs +8 -4
  94. package/src/channels/feishu/repair-manager.mjs +2 -1
  95. package/src/channels/imessage/runtime.mjs +9 -5
  96. package/src/channels/office/office-controller.mjs +23 -12
  97. package/src/channels/office/office-runtime.mjs +6 -3
  98. package/src/channels/office/office-transport.mjs +1 -0
  99. package/src/channels/qq/qq-bridge.mjs +7 -5
  100. package/src/channels/qq/qq-controller.mjs +56 -42
  101. package/src/channels/qq/qq-runtime.mjs +16 -11
  102. package/src/channels/shared/bot-workspace-store.mjs +29 -10
  103. package/src/channels/shared/connection-error.mjs +191 -0
  104. package/src/channels/shared/connection-test.mjs +3 -1
  105. package/src/channels/shared/conversation-state-store.mjs +46 -1
  106. package/src/channels/shared/diagnostic-details.mjs +95 -0
  107. package/src/channels/shared/harness-client.mjs +63 -18
  108. package/src/channels/shared/i18n-en/diagnostics.mjs +55 -0
  109. package/src/channels/shared/i18n-en/email.mjs +25 -0
  110. package/src/channels/shared/i18n-en/image-input.mjs +17 -0
  111. package/src/channels/shared/i18n-en/shared-a.mjs +1 -0
  112. package/src/channels/shared/i18n-en/shared-c.mjs +1 -1
  113. package/src/channels/shared/i18n-en.mjs +6 -0
  114. package/src/channels/shared/image-input-policy.mjs +35 -0
  115. package/src/channels/shared/image-input-settings-store.mjs +60 -0
  116. package/src/channels/shared/image-input.mjs +124 -0
  117. package/src/channels/shared/image-prompt.mjs +15 -7
  118. package/src/channels/shared/message-failure.mjs +12 -0
  119. package/src/channels/shared/semantic/reply-reference.mjs +3 -3
  120. package/src/channels/shared/text-harness-bridge.mjs +90 -11
  121. package/src/channels/shared/token-bot-controller.mjs +52 -47
  122. package/src/channels/slack/slack-api.mjs +3 -3
  123. package/src/channels/slack/slack-controller.mjs +59 -51
  124. package/src/channels/slack/slack-runtime.mjs +18 -12
  125. package/src/channels/telegram/telegram-api.mjs +2 -2
  126. package/src/channels/telegram/telegram-runtime.mjs +17 -13
  127. package/src/channels/wecom/wecom-bridge.mjs +15 -6
  128. package/src/channels/wecom/wecom-controller.mjs +51 -37
  129. package/src/channels/wecom/wecom-runtime.mjs +10 -6
  130. package/src/channels/wecom-app/wecom-app-bridge.mjs +7 -5
  131. package/src/channels/wecom-app/wecom-app-controller.mjs +50 -37
  132. package/src/channels/wecom-app/wecom-app-runtime.mjs +2 -1
  133. package/src/channels/weixin/connection-error.mjs +26 -92
  134. package/src/channels/weixin/diagnostic-details.mjs +1 -63
  135. package/src/channels/weixin/weixin-bridge.mjs +7 -5
  136. package/src/channels/whatsapp/whatsapp-controller.mjs +34 -17
  137. 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;
@@ -161,6 +183,10 @@ export class TextHarnessBridge {
161
183
  #approvals;
162
184
  #batches = new BatchInputManager();
163
185
  #interactionCard;
186
+ // Delivery attempts per message id. A failed turn releases its `markSeen` so
187
+ // the next poll can retry, and this counter is what keeps that from becoming
188
+ // an infinite loop on a mail that always fails.
189
+ #deliveryAttempts = new Map();
164
190
 
165
191
  constructor({
166
192
  descriptor,
@@ -229,7 +255,11 @@ export class TextHarnessBridge {
229
255
  const decision = accessDecision ?? evaluateInboundAccess(this.#accessPolicy, {
230
256
  conversationType: kind,
231
257
  senderIds: [senderId, cleanText(normalized.senderAlternateId)].filter(Boolean),
232
- text: normalized.content,
258
+ // The same control text the command runner below parses. A channel may
259
+ // decorate `content` for the model (email prepends the mail headers), and
260
+ // a decorated body never looks like a command — so reading `content` here
261
+ // recognized no command at all and skipped the command-permission gate.
262
+ text: controlTextOf(normalized),
233
263
  hasImages,
234
264
  hasFiles,
235
265
  });
@@ -282,7 +312,7 @@ export class TextHarnessBridge {
282
312
 
283
313
  const key = `${normalized.kind}:${normalized.conversationId}`;
284
314
  const pending = this.#pendingInteractions.get(key);
285
- const text = cleanText(normalized.content);
315
+ const text = controlTextOf(normalized);
286
316
  const batchCommand = isBatchInputCommand(text);
287
317
  if (batchCommand && normalized.kind === 'group' && normalized.addressed === true) {
288
318
  return this.#finishLocalMessage(
@@ -362,7 +392,12 @@ export class TextHarnessBridge {
362
392
  key,
363
393
  actor: senderId,
364
394
  messageId,
365
- text: hasInboundImages(normalized) || hasInboundFiles(normalized) ? '' : normalized.content,
395
+ // Approval decisions are parsed from the control text, exactly like the
396
+ // commands above: a decorated `content` hid "批准" behind the mail headers,
397
+ // so a plain-body approval never claimed its pending request.
398
+ text: hasInboundImages(normalized) || hasInboundFiles(normalized)
399
+ ? ''
400
+ : controlTextOf(normalized),
366
401
  addressed: normalized.kind !== 'group' || normalized.addressed === true,
367
402
  hasPendingQuestion: Boolean(pending),
368
403
  questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
@@ -441,7 +476,7 @@ export class TextHarnessBridge {
441
476
  const failure = setLastMessageFailure(this.#status, error);
442
477
  this.#logger.error?.(
443
478
  `[dsh-im:${this.#descriptor.key}] failed to process a batch input message [${failure.referenceId}]:`,
444
- error,
479
+ messageFailureDiagnostic(error, failure),
445
480
  );
446
481
  }).finally(() => {
447
482
  this.#acceptedMessageIds.delete(messageId);
@@ -500,7 +535,7 @@ export class TextHarnessBridge {
500
535
  const target = message.replyTarget;
501
536
  try {
502
537
  const result = await runner(
503
- cleanText(message.content),
538
+ controlTextOf(message),
504
539
  this.#harness,
505
540
  this.#state,
506
541
  key,
@@ -550,7 +585,7 @@ export class TextHarnessBridge {
550
585
  const failure = setLastMessageFailure(this.#status, error);
551
586
  this.#logger.error?.(
552
587
  `[dsh-im:${this.#descriptor.key}] failed to process a command [${failure.referenceId}]:`,
553
- error,
588
+ messageFailureDiagnostic(error, failure),
554
589
  );
555
590
  await this.#bot.sendText(target, messageFailureText(failure)).catch(() => undefined);
556
591
  }
@@ -615,7 +650,7 @@ export class TextHarnessBridge {
615
650
  }
616
651
 
617
652
  const target = message.replyTarget;
618
- const text = cleanText(message.content);
653
+ const text = controlTextOf(message);
619
654
  const batchSubmission = message.batchSubmission;
620
655
  let stream = null;
621
656
  let semanticStream = false;
@@ -710,7 +745,7 @@ export class TextHarnessBridge {
710
745
  }
711
746
  }
712
747
  let content = hasImages || hasReply
713
- ? await promptContentForInboundMessage(message, { signal: this.#signal })
748
+ ? await promptContentForInboundMessage(message, { signal: this.#signal, deferImages: true })
714
749
  : undefined;
715
750
  const snapshot = this.#acceptedMessageIds.get(messageId);
716
751
  let contextEnhanced = false;
@@ -790,6 +825,7 @@ export class TextHarnessBridge {
790
825
  },
791
826
  onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
792
827
  files: message.files,
828
+ images: message.images,
793
829
  },
794
830
  });
795
831
  stopKeepalive();
@@ -877,6 +913,8 @@ export class TextHarnessBridge {
877
913
  clearLastMessageFailure(this.#status);
878
914
  }
879
915
  }
916
+ // The turn produced an answer, so its retry budget is spent and reset.
917
+ this.#deliveryAttempts.delete(messageId);
880
918
  return delivery.receipt;
881
919
  } catch (error) {
882
920
  stopKeepalive();
@@ -934,7 +972,7 @@ export class TextHarnessBridge {
934
972
  : messageFailureText(failure);
935
973
  this.#logger.error?.(
936
974
  `[dsh-im:${this.#descriptor.key}] failed to process a message [${failure.referenceId}]:`,
937
- error,
975
+ messageFailureDiagnostic(error, failure),
938
976
  );
939
977
  if (await presentStreamFailure(failureText)) {
940
978
  return error.deliveryReceipt;
@@ -948,6 +986,10 @@ export class TextHarnessBridge {
948
986
  sendError,
949
987
  );
950
988
  }
989
+ // The turn produced no answer, so this id is "attempted", not "handled".
990
+ // Releasing the mark lets the next poll retry it; `alreadyRecorded` means
991
+ // an outer caller owns the mark, so it is left alone there.
992
+ if (!alreadyRecorded) await this.#releaseFailedDelivery(messageId);
951
993
  return error.deliveryReceipt;
952
994
  } finally {
953
995
  stopKeepalive();
@@ -958,6 +1000,43 @@ export class TextHarnessBridge {
958
1000
  }
959
1001
  }
960
1002
 
1003
+ /**
1004
+ * Release the `markSeen` of a turn that failed without delivering anything,
1005
+ * so the next poll can pick the message up again.
1006
+ *
1007
+ * Bounded: a message is retried at most `MAX_DELIVERY_ATTEMPTS` times, after
1008
+ * which the mark stays and the id becomes a tombstone. That keeps a mail that
1009
+ * always fails from being re-executed forever, while a transient Harness
1010
+ * outage no longer drops it permanently.
1011
+ *
1012
+ * Returns true when the message was released and will be retried.
1013
+ */
1014
+ async #releaseFailedDelivery(messageId) {
1015
+ const attempts = (this.#deliveryAttempts.get(messageId) ?? 0) + 1;
1016
+ if (attempts >= MAX_DELIVERY_ATTEMPTS) {
1017
+ // Give up: keep the mark so the id is never replayed, and stop tracking
1018
+ // it. The sender has already been told the turn failed.
1019
+ this.#deliveryAttempts.delete(messageId);
1020
+ this.#logger.warn?.(
1021
+ `[dsh-im:${this.#descriptor.key}] giving up on a message after `
1022
+ + `${attempts} failed delivery attempts; it will not be retried.`,
1023
+ );
1024
+ return false;
1025
+ }
1026
+ this.#deliveryAttempts.set(messageId, attempts);
1027
+ if (typeof this.#state?.unmarkSeen !== 'function') return false;
1028
+ try {
1029
+ await this.#state.unmarkSeen(messageId);
1030
+ return true;
1031
+ } catch (error) {
1032
+ this.#logger.warn?.(
1033
+ `[dsh-im:${this.#descriptor.key}] unable to release a failed message for retry:`,
1034
+ error,
1035
+ );
1036
+ return false;
1037
+ }
1038
+ }
1039
+
961
1040
  /**
962
1041
  * Advance the pending interaction with one answer.
963
1042
  *
@@ -1000,7 +1079,7 @@ export class TextHarnessBridge {
1000
1079
  }
1001
1080
 
1002
1081
  const target = message.replyTarget;
1003
- const text = cleanText(message.content);
1082
+ const text = controlTextOf(message);
1004
1083
  if (!text || hasInboundImages(message) || hasInboundFiles(message)) {
1005
1084
  try {
1006
1085
  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;