@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
@@ -1,3 +1,4 @@
1
+ import { connectionErrorChain, connectionStageError } from '../shared/connection-error.mjs';
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { createRequire } from 'node:module';
3
4
 
@@ -62,17 +63,7 @@ export function installedDingtalkConnectionDependencies() {
62
63
  return installedDependencyVersions;
63
64
  }
64
65
 
65
- function errorChain(error) {
66
- const chain = [];
67
- const seen = new Set();
68
- let current = error;
69
- while (current && typeof current === 'object' && chain.length < 4 && !seen.has(current)) {
70
- seen.add(current);
71
- chain.push(current);
72
- current = current.cause;
73
- }
74
- return chain;
75
- }
66
+ function errorChain(error) { return connectionErrorChain(error).chain; }
76
67
 
77
68
  function statusFrom(error) {
78
69
  if (Number.isInteger(error?.status)) return error.status;
@@ -153,7 +144,9 @@ export function dingtalkRuntimeStartError(code, cause) {
153
144
  );
154
145
  error.name = 'DingtalkRuntimeStartError';
155
146
  error.code = STAGE_CODES.has(code) ? code : 'dingtalk-runtime-prepare-failed';
156
- return error;
147
+ return connectionStageError(error, ({ 'dingtalk-harness-connect-failed': 'harness.check',
148
+ 'dingtalk-runtime-prepare-failed': 'runtime.prepare', 'dingtalk-stream-client-load-failed': 'sdk.load',
149
+ 'dingtalk-stream-listener-failed': 'runtime.prepare', 'dingtalk-stream-connect-failed': 'connection.start' })[error.code]);
157
150
  }
158
151
 
159
152
  /** Creates browser-safe guidance plus a redacted Host-log diagnostic for one connection failure. */
@@ -68,6 +68,8 @@ export class DingtalkDeviceAuthError extends Error {
68
68
  this.name = 'DingtalkDeviceAuthError';
69
69
  this.code = code;
70
70
  this.action = action;
71
+ if (Number.isInteger(options.status)) this.status = options.status;
72
+ if (Number.isFinite(options.timeoutMs)) this.timeoutMs = options.timeoutMs;
71
73
  }
72
74
  }
73
75
 
@@ -199,7 +201,7 @@ export class DingtalkDeviceAuth {
199
201
  'timeout',
200
202
  `DingTalk ${action} request timed out`,
201
203
  action,
202
- { cause: error },
204
+ { cause: error, timeoutMs: this.#timeoutMs },
203
205
  );
204
206
  }
205
207
  if (error?.name === 'AbortError') throw error;
@@ -215,6 +217,7 @@ export class DingtalkDeviceAuth {
215
217
  'http-error',
216
218
  `DingTalk ${action} request failed`,
217
219
  action,
220
+ { status: response?.status },
218
221
  );
219
222
  }
220
223
  let value;
@@ -65,6 +65,7 @@ import {
65
65
  import { recoverAssistantTextByTimestamp } from '../shared/session-reply-recovery.mjs';
66
66
  import { DINGTALK_RECENT_OUTBOUND_MATCH_TOLERANCE_MS } from './state-store.mjs';
67
67
  import {
68
+ messageFailureDiagnostic,
68
69
  channelDeliveryFailure,
69
70
  clearLastMessageFailure,
70
71
  messageFailureText,
@@ -740,7 +741,7 @@ export class DingtalkHarnessBridge {
740
741
  const failure = setLastMessageFailure(this.#status, error);
741
742
  this.#logger.error?.(
742
743
  `[dsh-dingtalk] failed to process a command [${failure.referenceId}]`,
743
- safeErrorDiagnostic(error),
744
+ messageFailureDiagnostic(error, failure),
744
745
  );
745
746
  return this.#send(sessionWebhook, messageFailureText(failure), this.#atUsersFor(message)).catch(() => undefined);
746
747
  }).finally(() => {
@@ -1207,7 +1208,7 @@ export class DingtalkHarnessBridge {
1207
1208
  const failure = setLastMessageFailure(this.#status, error);
1208
1209
  this.#logger.error?.(
1209
1210
  `[dsh-dingtalk] failed to process a batch input message [${failure.referenceId}]`,
1210
- safeErrorDiagnostic(error),
1211
+ messageFailureDiagnostic(error, failure),
1211
1212
  );
1212
1213
  await this.#send(sessionWebhook, messageFailureText(failure)).catch(() => undefined);
1213
1214
  }).finally(() => {
@@ -1338,7 +1339,7 @@ export class DingtalkHarnessBridge {
1338
1339
  signal: this.#signal,
1339
1340
  });
1340
1341
  let content = hasInboundImages(modelMessage) || hasReply
1341
- ? await promptContentForInboundMessage(modelMessage, { signal: this.#signal })
1342
+ ? await promptContentForInboundMessage(modelMessage, { signal: this.#signal, deferImages: true })
1342
1343
  : undefined;
1343
1344
  const snapshot = this.#acceptedMessageIds.get(messageId);
1344
1345
  let contextEnhanced = false;
@@ -1395,6 +1396,7 @@ export class DingtalkHarnessBridge {
1395
1396
  }),
1396
1397
  onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
1397
1398
  files: modelMessage.files,
1399
+ images: modelMessage.images,
1398
1400
  },
1399
1401
  });
1400
1402
  if (batchSubmission) {
@@ -1486,7 +1488,7 @@ export class DingtalkHarnessBridge {
1486
1488
  });
1487
1489
  this.#logger.error?.(
1488
1490
  `[dsh-dingtalk] failed to process an inbound message [${failure.referenceId}]`,
1489
- safeErrorDiagnostic(error),
1491
+ messageFailureDiagnostic(error, failure),
1490
1492
  );
1491
1493
  try {
1492
1494
  const errorText = messageFailureText(failure);
@@ -1,3 +1,4 @@
1
+ import { extractConnectionEvidence, atConnectionStage, createConnectionDiagnostics } from '../shared/connection-error.mjs';
1
2
  import { randomUUID } from 'node:crypto';
2
3
 
3
4
  import {
@@ -135,6 +136,7 @@ export class DingtalkController {
135
136
  #createRuntime;
136
137
  #deleteState;
137
138
  #logger;
139
+ #diagnostics;
138
140
  #clock;
139
141
  #runtimes = new Map();
140
142
  #errors = new Map();
@@ -193,10 +195,17 @@ export class DingtalkController {
193
195
  this.#createRuntime = createRuntime;
194
196
  this.#deleteState = deleteState;
195
197
  this.#logger = logger;
198
+ this.#diagnostics = createConnectionDiagnostics({ channel: 'dingtalk', prefix: 'DT-CONN', logger });
196
199
  this.#clock = clock;
197
200
  }
198
201
 
199
202
  /** Starts all configured DingTalk runtimes whose secrets are available. */
203
+ get diagnostics() { return this.#diagnostics; }
204
+ #failure(error, code, message) {
205
+ const stage = code.startsWith('poll-') ? 'qr.poll' : code === 'activation-failed' ? 'activation' : 'qr.begin';
206
+ return this.#diagnostics.report(error, { reuse: true, stage, publicError: { code, message } }).publicError;
207
+ }
208
+
200
209
  async initialize() {
201
210
  if (this.#closed) return this.status();
202
211
  for (const config of this.#configStore.list()) {
@@ -209,16 +218,17 @@ export class DingtalkController {
209
218
  await this.#withBotTransition(config.botId, async () => {
210
219
  const latest = this.#configStore.get(config.botId);
211
220
  if (!latest || this.#closed) return;
212
- const clientSecret = await this.#resolveSecret(latest.secretRef);
213
- if (!clientSecret) {
214
- this.#errors.set(
215
- latest.botId,
216
- safeError('missing-secret', t('钉钉机器人凭据缺失,请移除后重新扫码。')),
217
- );
218
- this.#touch();
219
- return;
220
- }
221
+ let clientSecret;
221
222
  try {
223
+ clientSecret = await this.#resolveSecret(latest.secretRef);
224
+ if (!clientSecret) {
225
+ this.#errors.set(
226
+ latest.botId,
227
+ safeError('missing-secret', t('钉钉机器人凭据缺失,请移除后重新扫码。')),
228
+ );
229
+ this.#touch();
230
+ return;
231
+ }
222
232
  await this.#startRuntime(latest, clientSecret);
223
233
  this.#errors.delete(latest.botId);
224
234
  } catch (error) {
@@ -282,7 +292,7 @@ export class DingtalkController {
282
292
  record.error = safeError('cancelled', t('扫码接入已取消。'));
283
293
  } else {
284
294
  record.state = 'failed';
285
- record.error = safeError('qr-start-failed', t('无法生成钉钉二维码,请稍后重试。'));
295
+ record.error = this.#failure(error, 'qr-start-failed', t('无法生成钉钉二维码,请稍后重试。'));
286
296
  }
287
297
  if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
288
298
  this.#touch();
@@ -307,7 +317,7 @@ export class DingtalkController {
307
317
  await this.#withBotTransition(identity.botId, async () => {
308
318
  if (this.#closed) throw abortError();
309
319
  const previousConfig = this.#configStore.getByClientId(normalizedClientId);
310
- const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => undefined);
320
+ const previousSecret = await atConnectionStage('credential.read', () => this.#credentials.resolve(identity.secretRef), 'credential-store');
311
321
  if (this.#closed) throw abortError();
312
322
  const config = {
313
323
  botId: identity.botId,
@@ -315,9 +325,9 @@ export class DingtalkController {
315
325
  secretRef: identity.secretRef,
316
326
  approvedSenders: previousConfig?.approvedSenders ?? [],
317
327
  };
318
- await this.#credentials.set(identity.secretRef, normalizedSecret);
328
+ await atConnectionStage('credential.save', () => this.#credentials.set(identity.secretRef, normalizedSecret), 'credential-store');
319
329
  try {
320
- await this.#configStore.save(config);
330
+ await atConnectionStage('account.save', () => this.#configStore.save(config), 'account-config');
321
331
  } catch (error) {
322
332
  await this.#restoreCredential(identity.secretRef, previousSecret);
323
333
  throw error;
@@ -434,30 +444,37 @@ export class DingtalkController {
434
444
 
435
445
  /** Removes one bot, its secret, runtime, and local conversation state. */
436
446
  async deleteBot(botId) {
447
+ const warnings = [];
437
448
  const config = this.#configStore.get(botId);
438
449
  if (!config) throw new Error('Unknown DingTalk bot');
439
450
  await this.#withBotTransition(botId, async () => {
440
- const previousSecret = await this.#credentials.resolve(config.secretRef).catch(() => undefined);
451
+ const previousSecret = await atConnectionStage('credential.read', () => this.#credentials.resolve(config.secretRef), 'credential-store');
441
452
  await this.#stopRuntime(botId);
442
453
  try {
443
- await this.#credentials.unset(config.secretRef);
444
- await this.#configStore.remove(botId);
454
+ await atConnectionStage('credential.remove', () => this.#credentials.unset(config.secretRef), 'credential-store');
455
+ await atConnectionStage('account.remove', () => this.#configStore.remove(botId), 'account-config');
445
456
  } catch (error) {
446
- if (cleanString(previousSecret?.value)) {
447
- await this.#credentials.set(config.secretRef, previousSecret.value).catch(() => undefined);
448
- await this.#startRuntime(config, previousSecret.value).catch(() => undefined);
457
+ if (!this.#configStore.get(botId)) {
458
+ warnings.push(this.#diagnostics.report(error, { operation: 'bot.delete', stage: 'workspace.cleanup', warning: true,
459
+ publicError: { code: 'workspace-cleanup-failed', message: '账号已移除,但本地状态清理失败。' } }).publicError);
460
+ } else {
461
+ if (cleanString(previousSecret?.value)) {
462
+ await atConnectionStage('credential.save', () => this.#credentials.set(config.secretRef, previousSecret.value), 'credential-store').catch(() => undefined);
463
+ await this.#startRuntime(config, previousSecret.value).catch(() => undefined);
464
+ }
465
+ throw new Error('Unable to remove the DingTalk bot safely.', { cause: error });
449
466
  }
450
- throw new Error('Unable to remove the DingTalk bot safely.', { cause: error });
451
467
  }
452
468
  try {
453
469
  await this.#deleteState({ botId, config });
454
- } catch {
455
- this.#logger.warn?.(`[dsh-dingtalk] bot ${botId} state cleanup failed`);
470
+ } catch (error) {
471
+ warnings.push(this.#diagnostics.report(error, { reuse: true, operation: 'bot.delete', stage: 'state.cleanup', warning: true,
472
+ publicError: { code: 'cleanup-failed', message: '账号已移除,但本地状态清理失败。' } }).publicError);
456
473
  }
457
474
  this.#errors.delete(botId);
458
475
  this.#touch();
459
476
  });
460
- return this.status();
477
+ return { ...this.status(), ...(warnings.length ? { warnings } : {}) };
461
478
  }
462
479
 
463
480
  /** Approves one opaque pending-sender request for a bot. */
@@ -517,7 +534,7 @@ export class DingtalkController {
517
534
  currentStatus = { state: 'error' };
518
535
  }
519
536
  const connected = isRuntimeConnected(runtime, currentStatus);
520
- const accountError = this.#errors.get(config.botId);
537
+ const accountError = currentStatus.error ?? this.#errors.get(config.botId);
521
538
  const state = connected ? 'connected' : accountError ? 'error' : 'offline';
522
539
  const approvedIds = new Set(config.approvedSenders.map((sender) => sender.staffId));
523
540
  const pending = internalPendingSenders(currentStatus)
@@ -626,7 +643,7 @@ export class DingtalkController {
626
643
  if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
627
644
  } else if (record.state === 'connecting') {
628
645
  record.state = 'failed';
629
- record.error = safeError(
646
+ record.error = this.#failure(error,
630
647
  'activation-failed',
631
648
  t('钉钉已授权,但无法安全保存接入配置。'),
632
649
  );
@@ -634,7 +651,7 @@ export class DingtalkController {
634
651
  this.#logger.error?.('[dsh-dingtalk] bot activation failed');
635
652
  } else {
636
653
  record.state = 'pending';
637
- record.error = safeError('poll-failed', t('钉钉授权查询暂时失败,正在重试。'));
654
+ record.error = this.#failure(error, 'poll-failed', t('钉钉授权查询暂时失败,正在重试。'));
638
655
  }
639
656
  } finally {
640
657
  this.#touch();
@@ -645,7 +662,7 @@ export class DingtalkController {
645
662
  async #activateBot(record, { clientId, clientSecret }) {
646
663
  const identity = deriveDingtalkBotIdentity(clientId);
647
664
  const previousConfig = this.#configStore.getByClientId(clientId);
648
- const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => undefined);
665
+ const previousSecret = await atConnectionStage('credential.read', () => this.#credentials.resolve(identity.secretRef), 'credential-store');
649
666
  const config = {
650
667
  botId: identity.botId,
651
668
  clientId,
@@ -655,12 +672,12 @@ export class DingtalkController {
655
672
  return this.#withBotTransition(identity.botId, async () => {
656
673
  const rollback = async () => {
657
674
  await this.#stopRuntime(identity.botId);
658
- if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined);
675
+ if (previousConfig) await atConnectionStage('account.save', () => this.#configStore.save(previousConfig), 'account-config').catch(() => undefined);
659
676
  else if (this.#configStore.get(identity.botId)) {
660
- const removed = await this.#configStore.remove(identity.botId).catch(() => null);
677
+ const removed = await atConnectionStage('account.remove', () => this.#configStore.remove(identity.botId), 'account-config').catch(() => null);
661
678
  if (removed) {
662
679
  await this.#deleteState({ botId: identity.botId, config }).catch((cleanupError) => {
663
- this.#logger.warn?.('[dsh-dingtalk] failed to clean up cancelled bot state:', cleanupError);
680
+ this.#logger.warn?.('[dsh-dingtalk] failed to clean up cancelled bot state:', extractConnectionEvidence(cleanupError).details);
664
681
  });
665
682
  }
666
683
  }
@@ -669,10 +686,10 @@ export class DingtalkController {
669
686
  await this.#startRuntime(previousConfig, previousSecret.value).catch(() => undefined);
670
687
  }
671
688
  };
672
- await this.#credentials.set(identity.secretRef, clientSecret);
689
+ await atConnectionStage('credential.save', () => this.#credentials.set(identity.secretRef, clientSecret), 'credential-store');
673
690
  try {
674
691
  this.#assertAttemptActive(record);
675
- await this.#configStore.save(config);
692
+ await atConnectionStage('account.save', () => this.#configStore.save(config), 'account-config');
676
693
  this.#assertAttemptActive(record);
677
694
  } catch (error) {
678
695
  await rollback();
@@ -703,12 +720,12 @@ export class DingtalkController {
703
720
  return this.#withBotTransition(previousConfig.botId, async () => {
704
721
  const clientSecret = await this.#resolveSecret(previousConfig.secretRef);
705
722
  if (!clientSecret) throw new Error('The DingTalk client secret is missing');
706
- await this.#configStore.save(nextConfig);
723
+ await atConnectionStage('account.save', () => this.#configStore.save(nextConfig), 'account-config');
707
724
  try {
708
725
  await this.#startRuntime(nextConfig, clientSecret);
709
726
  this.#errors.delete(previousConfig.botId);
710
727
  } catch (error) {
711
- await this.#configStore.save(previousConfig).catch(() => undefined);
728
+ await atConnectionStage('account.save', () => this.#configStore.save(previousConfig), 'account-config').catch(() => undefined);
712
729
  await this.#startRuntime(previousConfig, clientSecret).catch(() => undefined);
713
730
  const publicError = this.#rememberConnectionFailure({
714
731
  config: previousConfig,
@@ -730,11 +747,11 @@ export class DingtalkController {
730
747
  if (this.#closed) throw abortError();
731
748
  let runtime;
732
749
  try {
733
- runtime = await this.#createRuntime({
750
+ runtime = await atConnectionStage('runtime.prepare', () => this.#createRuntime({
734
751
  botId: config.botId,
735
752
  config: structuredClone(config),
736
753
  clientSecret,
737
- });
754
+ }));
738
755
  } catch (error) {
739
756
  throw dingtalkRuntimeStartError('dingtalk-runtime-prepare-failed', error);
740
757
  }
@@ -774,12 +791,10 @@ export class DingtalkController {
774
791
  clientId: config.clientId,
775
792
  clientSecret,
776
793
  });
777
- this.#errors.set(config.botId, failure.publicError);
778
- this.#logger.error?.(
779
- `[dsh-dingtalk] ${context} [${failure.publicError.referenceId}]`,
780
- failure.diagnostic,
781
- );
782
- return failure.publicError;
794
+ const reported = this.#diagnostics.report(error, { reuse: true, botId: config.botId, stage: 'connection.start', publicError: { ...failure.publicError, details: { dependencies: failure.diagnostic.dependencies, proxyConfigured: failure.diagnostic.proxy.configured } } }).publicError;
795
+ const visible = { ...failure.publicError, details: reported.details };
796
+ this.#errors.set(config.botId, visible);
797
+ return visible;
783
798
  }
784
799
 
785
800
  async #stopRuntime(botId) {
@@ -791,14 +806,14 @@ export class DingtalkController {
791
806
  }
792
807
 
793
808
  async #resolveSecret(secretRef) {
794
- const result = await this.#credentials.resolve(secretRef).catch(() => undefined);
809
+ const result = await atConnectionStage('credential.read', () => this.#credentials.resolve(secretRef), 'credential-store');
795
810
  return cleanString(result?.value);
796
811
  }
797
812
 
798
813
  async #restoreCredential(secretRef, previous) {
799
814
  try {
800
- if (cleanString(previous?.value)) await this.#credentials.set(secretRef, previous.value);
801
- else await this.#credentials.unset(secretRef);
815
+ if (cleanString(previous?.value)) await atConnectionStage('credential.save', () => this.#credentials.set(secretRef, previous.value), 'credential-store');
816
+ else await atConnectionStage('credential.remove', () => this.#credentials.unset(secretRef), 'credential-store');
802
817
  } catch {
803
818
  this.#logger.error?.(`[dsh-dingtalk] failed to restore credential ${secretRef}`);
804
819
  }
@@ -1,3 +1,4 @@
1
+ import { atConnectionStage, createConnectionDiagnostics } from '../shared/connection-error.mjs';
1
2
  import { createDingtalkApi } from './dingtalk-api.mjs';
2
3
  import {
3
4
  createDingtalkBridgeStatus,
@@ -87,7 +88,7 @@ async function connectStream(client, timeoutMs, pollIntervalMs, signal) {
87
88
  void connectTask.then(() => client.disconnect()).catch(() => undefined);
88
89
  }
89
90
  if (signal.aborted) throw signal.reason;
90
- throw new Error(`DingTalk Stream handshake timed out after ${timeoutMs}ms`);
91
+ throw Object.assign(new Error(`DingTalk Stream handshake timed out after ${timeoutMs}ms`), { code: 'ETIMEDOUT', timeoutMs, cause: error });
91
92
  }
92
93
  throw error;
93
94
  }
@@ -134,6 +135,7 @@ export class DingtalkRuntime {
134
135
  #contextEnhancement;
135
136
  #accessPolicy;
136
137
  #logger;
138
+ #diagnostics;
137
139
  #replyTimeoutMs;
138
140
  #maxMessageChars;
139
141
  #connectTimeoutMs;
@@ -176,6 +178,7 @@ export class DingtalkRuntime {
176
178
  this.#contextEnhancement = contextEnhancement;
177
179
  this.#accessPolicy = accessPolicy;
178
180
  this.#logger = logger;
181
+ this.#diagnostics = createConnectionDiagnostics({ channel: 'dingtalk', logger, prefix: 'DT-CONN' });
179
182
  this.#replyTimeoutMs = replyTimeoutMs;
180
183
  this.#maxMessageChars = maxMessageChars;
181
184
  this.#connectTimeoutMs = connectTimeoutMs;
@@ -228,7 +231,7 @@ export class DingtalkRuntime {
228
231
  let startStage = 'dingtalk-harness-connect-failed';
229
232
 
230
233
  try {
231
- await this.#harness.ensureRunning({ signal });
234
+ await atConnectionStage('harness.check', () => this.#harness.ensureRunning({ signal }));
232
235
  this.#status.harnessReachable = true;
233
236
  startStage = 'dingtalk-runtime-prepare-failed';
234
237
  if (typeof this.#state.removePendingSenderByStaffId === 'function') {
@@ -348,12 +351,22 @@ export class DingtalkRuntime {
348
351
  this.#status.lastConnectedAt = Date.now();
349
352
  this.#status.lastCheckedAt = Date.now();
350
353
  this.#status.lastError = null;
354
+ this.#status.error = null;
355
+ this.#diagnostics.clear();
351
356
  this.#connectionMonitor = setInterval(() => {
352
357
  const connected = streamIsOpen(client);
353
358
  this.#status.ready = connected;
354
359
  this.#status.dingtalkStreamState = connected ? 'connected' : 'reconnecting';
355
360
  this.#status.lastCheckedAt = Date.now();
356
- if (connected) this.#status.lastError = null;
361
+ if (connected) {
362
+ this.#status.lastError = null;
363
+ this.#status.error = null;
364
+ this.#diagnostics.clear();
365
+ } else {
366
+ this.#status.error = this.#diagnostics.report(null, { operation: 'connection.monitor', automatic: true, botId: this.#config.botId,
367
+ publicError: { code: 'stream-connect-failed', message: t('钉钉连接未就绪,请稍后重试。') } }).publicError;
368
+ this.#status.lastError = this.#status.error.message;
369
+ }
357
370
  }, 1_000);
358
371
  this.#connectionMonitor.unref?.();
359
372
  return this.status;
@@ -362,7 +375,7 @@ export class DingtalkRuntime {
362
375
  const failure = aborted ? error : dingtalkRuntimeStartError(startStage, error);
363
376
  this.#status.ready = false;
364
377
  this.#status.dingtalkStreamState = aborted ? 'idle' : 'failed';
365
- this.#status.lastError = aborted ? null : (failure?.message ?? String(failure));
378
+ this.#status.lastError = aborted ? null : t('钉钉连接未就绪,请稍后重试。');
366
379
  await this.stop({ preserveError: !aborted });
367
380
  throw failure;
368
381
  }
@@ -370,6 +383,7 @@ export class DingtalkRuntime {
370
383
 
371
384
  async stop({ preserveError = false } = {}) {
372
385
  const lastError = preserveError ? this.#status.lastError : null;
386
+ const publicError = preserveError ? this.#status.error : null;
373
387
  const abortController = this.#abortController;
374
388
  this.#abortController = null;
375
389
  abortController?.abort(new DOMException('DingTalk runtime stopped', 'AbortError'));
@@ -393,6 +407,8 @@ export class DingtalkRuntime {
393
407
  this.#bridge = null;
394
408
  this.#status.dingtalkStreamState = preserveError ? 'failed' : 'idle';
395
409
  this.#status.lastError = lastError;
410
+ this.#status.error = publicError;
411
+ if (!preserveError) this.#diagnostics.clear();
396
412
  return this.status;
397
413
  }
398
414
 
@@ -295,16 +295,16 @@ export class DiscordApi {
295
295
  });
296
296
  } catch (error) {
297
297
  if (error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error;
298
- throw new Error(`Discord ${method} transport failed`);
298
+ throw new Error(`Discord ${method} transport failed`, { cause: error });
299
299
  }
300
300
 
301
301
  let parsed = null;
302
302
  if (expectBody || response.status === 429 || !response.ok) {
303
303
  try {
304
304
  parsed = await response.json();
305
- } catch {
305
+ } catch (cause) {
306
306
  if (expectBody) {
307
- const error = new Error(`Discord ${method} returned invalid JSON`);
307
+ const error = new Error(`Discord ${method} returned invalid JSON`, { cause });
308
308
  error.status = response?.status;
309
309
  throw error;
310
310
  }
@@ -1,3 +1,4 @@
1
+ import { extractConnectionEvidence, createConnectionDiagnostics, atConnectionStage } from '../shared/connection-error.mjs';
1
2
  import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
2
3
  import { fetchFileStream } from '../shared/file-download.mjs';
3
4
  import { fetchImageBuffer } from '../shared/image-prompt.mjs';
@@ -534,6 +535,7 @@ export class DiscordRuntime {
534
535
  #contextEnhancement;
535
536
  #accessPolicy;
536
537
  #logger;
538
+ #diagnostics;
537
539
  #replyTimeoutMs;
538
540
  #connectTimeoutMs;
539
541
  #createApi;
@@ -582,7 +584,7 @@ export class DiscordRuntime {
582
584
  this.#state = state;
583
585
  this.#contextEnhancement = contextEnhancement;
584
586
  this.#accessPolicy = accessPolicy;
585
- this.#logger = logger;
587
+ this.#logger = logger; this.#diagnostics = createConnectionDiagnostics({ channel: 'discord', logger });
586
588
  this.#replyTimeoutMs = replyTimeoutMs;
587
589
  this.#connectTimeoutMs = connectTimeoutMs;
588
590
  this.#createApi = createApi;
@@ -639,8 +641,8 @@ export class DiscordRuntime {
639
641
  this.#routing.clear();
640
642
  this.#status.startedAt = new Date().toISOString();
641
643
  this.#status.connectionState = 'connecting';
642
- this.#status.lastError = null;
643
- await this.#harness.ensureRunning();
644
+ this.#status.lastError = null; this.#status.error = null; this.#diagnostics.clear();
645
+ await atConnectionStage('harness.check', () => this.#harness.ensureRunning());
644
646
  this.#status.harnessReachable = true;
645
647
  const controller = new AbortController();
646
648
  this.#abortController = controller;
@@ -672,7 +674,7 @@ export class DiscordRuntime {
672
674
  await Promise.race([
673
675
  this.#openSocket(false),
674
676
  new Promise((_, reject) => {
675
- timer = setTimeout(() => reject(new Error('Discord Gateway did not become ready in time')), this.#connectTimeoutMs);
677
+ timer = setTimeout(() => reject(Object.assign(new Error('Discord Gateway did not become ready in time'), { code: 'ETIMEDOUT', timeoutMs: this.#connectTimeoutMs })), this.#connectTimeoutMs);
676
678
  }),
677
679
  ]);
678
680
  } finally {
@@ -682,7 +684,8 @@ export class DiscordRuntime {
682
684
  } catch (error) {
683
685
  this.#status.ready = false;
684
686
  this.#status.connectionState = 'failed';
685
- this.#status.lastError = error?.message ?? String(error);
687
+ this.#status.error = this.#diagnostics.report(error, { operation: 'connection.restore', reuse: true, botId: this.#config?.botId, automatic: true }).publicError;
688
+ this.#status.lastError = this.#status.error.message;
686
689
  await this.stop();
687
690
  throw error;
688
691
  }
@@ -705,7 +708,7 @@ export class DiscordRuntime {
705
708
  this.#status.connectionState = 'connected';
706
709
  this.#status.lastCheckedAt = now;
707
710
  this.#status.lastConnectedAt = now;
708
- this.#status.lastError = null;
711
+ this.#status.lastError = null; this.#status.error = null; this.#diagnostics.clear();
709
712
  resolve();
710
713
  };
711
714
  addSocketListener(socket, 'message', (event) => {
@@ -790,7 +793,7 @@ export class DiscordRuntime {
790
793
  if (generation !== this.#generation || this.#stopped) return;
791
794
  this.#logger.error?.(
792
795
  `[dsh-im:discord] bot ${this.#config.botId} message handling failed:`,
793
- error,
796
+ extractConnectionEvidence(error).details,
794
797
  );
795
798
  });
796
799
  }
@@ -807,7 +810,8 @@ export class DiscordRuntime {
807
810
  const error = gatewayCloseError(Number(event.code) || 0);
808
811
  this.#status.ready = false;
809
812
  this.#status.connectionState = 'connecting';
810
- this.#status.lastError = error.message;
813
+ this.#status.error = this.#diagnostics.report(error, { operation: 'connection.monitor', botId: this.#config?.botId, automatic: true }).publicError;
814
+ this.#status.lastError = this.#status.error.message;
811
815
  if (!settled) {
812
816
  settled = true;
813
817
  reject(error);
@@ -818,9 +822,11 @@ export class DiscordRuntime {
818
822
  }
819
823
  this.#scheduleReconnect();
820
824
  });
821
- addSocketListener(socket, 'error', () => {
825
+ addSocketListener(socket, 'error', (event) => {
826
+ const error = event?.error ?? new Error('WebSocket error');
822
827
  if (generation !== this.#generation || this.#stopped) return;
823
- this.#status.lastError = 'Discord Gateway WebSocket error';
828
+ this.#status.error = this.#diagnostics.report(error, { operation: 'connection.monitor', botId: this.#config?.botId, automatic: true }).publicError;
829
+ this.#status.lastError = this.#status.error.message;
824
830
  });
825
831
  });
826
832
  }
@@ -931,7 +937,7 @@ export class DiscordRuntime {
931
937
  this.#reconnectTimer = null;
932
938
  void this.#openSocket(Boolean(this.#sessionId)).catch((error) => {
933
939
  if (this.#stopped) return;
934
- this.#logger.warn?.('[dsh-im:discord] Gateway reconnect failed:', error);
940
+ this.#logger.warn?.('[dsh-im:discord] Gateway reconnect failed:', extractConnectionEvidence(error).details);
935
941
  this.#scheduleReconnect();
936
942
  });
937
943
  }, delay);
@@ -955,7 +961,7 @@ export class DiscordRuntime {
955
961
  try {
956
962
  if (socket && socket.readyState < 2) socket.close(1000, 'Plugin stopped');
957
963
  } catch (error) {
958
- this.#logger.warn?.(`[dsh-im:discord] bot ${this.#config.botId} failed to close Gateway:`, error);
964
+ this.#logger.warn?.(`[dsh-im:discord] bot ${this.#config.botId} failed to close Gateway:`, extractConnectionEvidence(error).details);
959
965
  }
960
966
  await Promise.race([
961
967
  bridge?.waitForIdle() ?? Promise.resolve(),