@xmanrui/dsh-im 4.19.2 → 4.20.1

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 (42) hide show
  1. package/README.en.md +79 -1
  2. package/README.md +79 -1
  3. package/lib/client.js +594 -217
  4. package/lib/index.js +279 -276
  5. package/package.json +9 -1
  6. package/plugin-src/client/channels/weixin/api.js +35 -17
  7. package/plugin-src/client/channels/weixin/connection-error.js +68 -0
  8. package/plugin-src/client/channels/weixin/index.js +36 -12
  9. package/plugin-src/client/i18n.js +2 -0
  10. package/plugin-src/host/channels/shared/startup.mjs +7 -4
  11. package/plugin-src/host/channels/weixin/connection-supervisor.mjs +13 -1
  12. package/plugin-src/host/channels/weixin/index.mjs +12 -3
  13. package/plugin-src/host/channels/weixin/production.mjs +53 -3
  14. package/plugin-src/host/channels/weixin/rpc.mjs +22 -17
  15. package/src/channels/dingtalk/dingtalk-bridge.mjs +4 -1
  16. package/src/channels/dingtalk/dingtalk-menu.mjs +8 -4
  17. package/src/channels/feishu/bridge.mjs +44 -13
  18. package/src/channels/qq/qq-bridge.mjs +12 -4
  19. package/src/channels/qq/qq-menu.mjs +11 -8
  20. package/src/channels/shared/bot-workspace-store.mjs +532 -40
  21. package/src/channels/shared/command-catalog.mjs +5 -0
  22. package/src/channels/shared/compact-command.mjs +14 -4
  23. package/src/channels/shared/control-command.mjs +1 -1
  24. package/src/channels/shared/deferred-delivery-coordinator.mjs +1 -1
  25. package/src/channels/shared/history-command.mjs +1 -1
  26. package/src/channels/shared/i18n-en/shared-a.mjs +41 -0
  27. package/src/channels/shared/i18n-en/wecom.mjs +1 -1
  28. package/src/channels/shared/i18n-en/weixin.mjs +2 -0
  29. package/src/channels/shared/model-command.mjs +5 -3
  30. package/src/channels/shared/workspace-command.mjs +114 -9
  31. package/src/channels/shared/workspace-session.mjs +55 -5
  32. package/src/channels/telegram/telegram-runtime.mjs +1 -1
  33. package/src/channels/wecom/wecom-bridge.mjs +14 -46
  34. package/src/channels/wecom/wecom-runtime.mjs +1 -1
  35. package/src/channels/weixin/connection-error.en.mjs +116 -0
  36. package/src/channels/weixin/connection-error.mjs +204 -0
  37. package/src/channels/weixin/diagnostic-details.mjs +40 -0
  38. package/src/channels/weixin/state-store.mjs +4 -3
  39. package/src/channels/weixin/weixin-api.mjs +20 -8
  40. package/src/channels/weixin/weixin-bridge.mjs +3 -2
  41. package/src/channels/weixin/weixin-controller.mjs +133 -104
  42. package/src/channels/weixin/weixin-runtime.mjs +35 -24
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { createWeixinDiagnostics, knownWeixinErrorCode, weixinStageError } from './connection-error.mjs';
2
3
 
3
4
  import {
4
5
  normalizeWeixinApiBaseUrl,
@@ -22,26 +23,6 @@ const ACTIVE_ATTEMPT_STATES = new Set([
22
23
  ]);
23
24
  const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']);
24
25
  const QR_TTL_MS = 5 * 60_000;
25
- const ACTIVATION_ERROR_MESSAGES = Object.freeze({
26
- 'credential-read-failed': '微信已授权,但无法读取现有登录凭据。请检查 DSH 凭据存储。',
27
- 'credential-save-failed': '微信已授权,但登录凭据无法写入 DSH 凭据存储。请检查凭据存储是否可写。',
28
- 'account-config-save-failed': '微信已授权,但账号配置无法写入本机。请检查 DSH_HOME 目录权限。',
29
- 'runtime-prepare-failed': '微信已授权,但无法初始化账号状态或工作区。请检查 DSH_HOME 和工作区目录。',
30
- 'harness-connect-failed': '微信已授权,但插件无法连接本机 Harness。请检查 dsh web 地址和端口。',
31
- 'harness-timeout': '微信已授权,但 Harness 健康检查超时。请确认 dsh web 未阻塞。',
32
- 'harness-auth-required': '微信已授权,但 Harness 健康检查需要身份认证。请检查代理、网关或自定义鉴权配置。',
33
- 'harness-proxy-auth-required': '微信已授权,但本机 Harness 请求被代理要求认证。请让回环地址绕过代理,并检查 NO_PROXY 配置。',
34
- 'harness-loopback-forbidden': '微信已授权,但 Harness 异常拒绝了回环地址的健康检查。请检查 HTTP 代理、Harness 源码版本和构建产物。',
35
- 'harness-host-untrusted': '微信已授权,但 Harness 的 Host 信任检查拒绝了非回环地址请求。请检查 harnessBaseUrl 与 trustedHosts 配置。',
36
- 'harness-request-forbidden': '微信已授权,但健康检查收到了非 Harness 标准的 403 拒绝响应。请检查代理或网关配置。',
37
- 'harness-api-not-found': '微信已授权,但找不到 Harness 健康检查接口。请确认 Harness 与插件版本兼容。',
38
- 'harness-http-failed': '微信已授权,但 Harness 健康检查返回服务错误。请查看 dsh web 日志。',
39
- 'harness-response-invalid': '微信已授权,但 Harness 返回了无法识别的响应。请确认 Harness 与插件版本兼容。',
40
- 'harness-rpc-rejected': '微信已授权,但 Harness 拒绝了健康检查请求。请查看 dsh web 日志。',
41
- 'harness-check-unknown-failed': '微信已授权,但 Harness 健康检查发生未知错误。请查看 dsh web 日志。',
42
- 'connection-start-failed': '微信已授权,但消息连接初始化失败。请查看 dsh web 日志后重试。',
43
- });
44
-
45
26
  function cleanString(value) {
46
27
  return typeof value === 'string' && value.trim() ? value.trim() : null;
47
28
  }
@@ -75,24 +56,8 @@ function safeAccountError(code, message) {
75
56
  return Object.freeze({ code, message });
76
57
  }
77
58
 
78
- function activationStageError(code, cause) {
79
- const error = new Error(`Weixin activation failed during ${code}`, { cause });
80
- error.name = 'WeixinActivationStageError';
81
- error.code = code;
82
- return error;
83
- }
84
-
85
- function publicProvisioningError(error) {
86
- if (error instanceof WeixinApiError) return safeAccountError(error.code, error.message);
87
- const message = ACTIVATION_ERROR_MESSAGES[error?.code];
88
- return message
89
- ? safeAccountError(error.code, t(message))
90
- : safeAccountError('activation-unknown-failed', t('微信已授权,但激活过程中发生未知错误。请查看 dsh web 日志。'));
91
- }
92
-
93
59
  function preserveActivationError(error, fallbackCode) {
94
- if (error instanceof WeixinApiError || ACTIVATION_ERROR_MESSAGES[error?.code]) return error;
95
- return activationStageError(fallbackCode, error);
60
+ return knownWeixinErrorCode(error?.code) ? error : weixinStageError(fallbackCode, error);
96
61
  }
97
62
 
98
63
  export class WeixinController {
@@ -101,7 +66,7 @@ export class WeixinController {
101
66
  #configStore;
102
67
  #createRuntime;
103
68
  #deleteState;
104
- #logger;
69
+ #diagnostics;
105
70
  #runtimes = new Map();
106
71
  #errors = new Map();
107
72
  #attempts = new Map();
@@ -117,6 +82,7 @@ export class WeixinController {
117
82
  createRuntime,
118
83
  deleteState = async () => {},
119
84
  logger = console,
85
+ diagnostics,
120
86
  }) {
121
87
  if (!api || typeof api.beginLogin !== 'function' || typeof api.pollLogin !== 'function') {
122
88
  throw new TypeError('WeixinController requires a Weixin API client');
@@ -139,7 +105,7 @@ export class WeixinController {
139
105
  this.#configStore = configStore;
140
106
  this.#createRuntime = createRuntime;
141
107
  this.#deleteState = deleteState;
142
- this.#logger = logger;
108
+ this.#diagnostics = diagnostics ?? createWeixinDiagnostics({ logger });
143
109
  }
144
110
 
145
111
  async initialize() {
@@ -152,21 +118,14 @@ export class WeixinController {
152
118
  if (!latest || this.#closed) return;
153
119
  try {
154
120
  const token = await this.#resolveToken(latest.tokenRef);
155
- if (!token) {
156
- this.#errors.set(
157
- latest.botId,
158
- safeAccountError('missing-token', t('登录凭据缺失,请移除账号后重新扫码。')),
159
- );
160
- return;
161
- }
162
- await this.#startRuntime(latest, token);
121
+ if (!token) throw weixinStageError('missing-token');
122
+ await this.#startRuntime(latest, token, { operation: 'connection.restore', automatic: true });
163
123
  this.#errors.delete(latest.botId);
124
+ this.#diagnostics.clear(latest.botId);
164
125
  } catch (error) {
165
- this.#errors.set(
166
- latest.botId,
167
- safeAccountError('connection-failed', t('微信连接未就绪,插件会自动重试。')),
168
- );
169
- this.#logger.warn?.(`[dsh-weixin] account ${latest.botId} failed to initialize:`, error);
126
+ this.#errors.set(latest.botId, this.#diagnostics.report(error, {
127
+ operation: 'connection.restore', stage: 'connection.start', botId: latest.botId, automatic: true,
128
+ }).publicError);
170
129
  } finally {
171
130
  this.#touch();
172
131
  }
@@ -175,6 +134,17 @@ export class WeixinController {
175
134
  return this.status();
176
135
  }
177
136
 
137
+ reportRestoreFailure(error) {
138
+ for (const config of this.#configStore.list()) {
139
+ if (this.#runtimes.get(config.botId)?.status?.ready) continue;
140
+ this.#errors.set(config.botId, this.#diagnostics.report(error, {
141
+ operation: 'connection.restore', stage: 'harness.check', code: 'harness-check-unknown-failed',
142
+ botId: config.botId, automatic: true,
143
+ }).publicError);
144
+ }
145
+ this.#touch();
146
+ }
147
+
178
148
  async startProvisioning() {
179
149
  if (this.#closed) throw new Error('dsh-weixin controller is closed');
180
150
  if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
@@ -198,7 +168,13 @@ export class WeixinController {
198
168
 
199
169
  try {
200
170
  const localTokens = (await Promise.all(
201
- this.#configStore.list().slice(-10).map(async (config) => this.#resolveToken(config.tokenRef)),
171
+ this.#configStore.list().slice(-10).map(async (config) => {
172
+ try { return await this.#resolveToken(config.tokenRef); }
173
+ catch (error) {
174
+ this.#diagnostics.report(error, { operation: 'provision.begin', botId: config.botId, warning: true });
175
+ return undefined;
176
+ }
177
+ }),
202
178
  )).filter(Boolean);
203
179
  const login = await this.#api.beginLogin({
204
180
  localTokens,
@@ -218,10 +194,8 @@ export class WeixinController {
218
194
  record.error = safeAccountError('cancelled', t('扫码绑定已取消。'));
219
195
  } else {
220
196
  record.state = 'failed';
221
- record.error = safeAccountError(
222
- error instanceof WeixinApiError ? error.code : 'qr-start-failed',
223
- error instanceof WeixinApiError ? error.message : t('无法生成微信二维码,请稍后重试。'),
224
- );
197
+ error = this.#diagnostics.report(error, { operation: 'provision.begin', stage: 'qr.begin', code: 'qr-start-failed' });
198
+ record.error = error.publicError;
225
199
  }
226
200
  if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
227
201
  this.#touch();
@@ -236,7 +210,7 @@ export class WeixinController {
236
210
  async submitVerification(attemptId, verifyCode) {
237
211
  const record = this.#attempts.get(attemptId);
238
212
  if (!record || record.state !== 'needs_verification') {
239
- throw new Error('The provisioning attempt is not waiting for a verification code');
213
+ throw weixinStageError(record ? 'provision-state-invalid' : 'provision-attempt-not-found', undefined, 'qr.verify');
240
214
  }
241
215
  const code = cleanString(verifyCode);
242
216
  if (!code || !/^\d{4,8}$/.test(code)) {
@@ -270,14 +244,16 @@ export class WeixinController {
270
244
  const config = this.#configStore.get(botId);
271
245
  if (!config) throw new Error('Unknown Weixin account');
272
246
  await this.#withBotTransition(botId, async () => {
273
- const token = await this.#resolveToken(config.tokenRef);
274
- if (!token) throw new Error('The Weixin token is missing');
275
247
  try {
276
- await this.#startRuntime(config, token);
248
+ const token = await this.#resolveToken(config.tokenRef);
249
+ if (!token) throw weixinStageError('missing-token');
250
+ await this.#startRuntime(config, token, { operation: 'bot.reconnect' });
277
251
  this.#errors.delete(botId);
252
+ this.#diagnostics.clear(botId);
278
253
  } catch (error) {
279
- this.#errors.set(botId, safeAccountError('connection-failed', t('微信连接仍未就绪,请稍后重试。')));
280
- throw error;
254
+ const failure = this.#diagnostics.report(error, { operation: 'bot.reconnect', stage: 'connection.start', botId });
255
+ this.#errors.set(botId, failure.publicError);
256
+ throw failure;
281
257
  } finally {
282
258
  this.#touch();
283
259
  }
@@ -315,29 +291,53 @@ export class WeixinController {
315
291
 
316
292
  async deleteBot(botId) {
317
293
  const config = this.#configStore.get(botId);
318
- if (!config) throw new Error('Unknown Weixin account');
294
+ if (!config) throw weixinStageError('workspace-bot-not-found', undefined, 'account.remove');
295
+ const warnings = [];
296
+ const context = { operation: 'bot.delete', botId };
319
297
  await this.#withBotTransition(botId, async () => {
320
- const previousToken = await this.#credentials.resolve(config.tokenRef).catch(() => undefined);
321
- await this.#stopRuntime(botId);
298
+ const previousToken = await this.#credentials.resolve(config.tokenRef).catch(error => {
299
+ warnings.push(this.#diagnostics.report(weixinStageError('credential-read-failed', error), { ...context, warning: true }).publicError);
300
+ });
301
+ warnings.push(...await this.#stopRuntime(botId));
302
+ let stage = 'credential-remove-failed';
322
303
  try {
323
- await this.#credentials.unset(config.tokenRef);
304
+ await this.#writeCredential(config.tokenRef, undefined);
305
+ stage = 'account-config-remove-failed';
324
306
  await this.#configStore.remove(botId);
325
307
  } catch (error) {
326
- if (previousToken?.value) {
327
- await this.#credentials.set(config.tokenRef, previousToken.value).catch(() => undefined);
328
- await this.#startRuntime(config, previousToken.value).catch(() => undefined);
308
+ // A config removal observer may throw after the account was durably removed.
309
+ if (stage === 'account-config-remove-failed' && !this.#configStore.get(botId)) {
310
+ warnings.push(this.#diagnostics.report(weixinStageError('workspace-cleanup-failed', error), { ...context, warning: true }).publicError);
311
+ } else {
312
+ const failure = this.#diagnostics.report(weixinStageError(stage, error), context);
313
+ let rollback = previousToken?.value ? 'succeeded' : 'unknown';
314
+ if (previousToken?.value) {
315
+ for (const restore of [
316
+ () => this.#credentials.set(config.tokenRef, previousToken.value),
317
+ () => this.#startRuntime(config, previousToken.value, context),
318
+ ]) {
319
+ try { await restore(); }
320
+ catch (restoreError) {
321
+ rollback = 'failed';
322
+ this.#diagnostics.report(weixinStageError('rollback-failed', restoreError), { ...context, warning: true, parentReferenceId: failure.publicError.details.referenceId });
323
+ }
324
+ }
325
+ }
326
+ if (rollback === 'succeeded' && !this.#runtimes.get(botId)?.status?.ready) rollback = 'unknown';
327
+ this.#diagnostics.outcome(failure, rollback);
328
+ throw failure;
329
329
  }
330
- throw new Error('Unable to remove the Weixin account safely.', { cause: error });
331
330
  }
332
331
  try {
333
332
  await this.#deleteState({ botId, config });
334
333
  } catch (error) {
335
- this.#logger.warn?.(`[dsh-weixin] account ${botId} state cleanup failed:`, error);
334
+ warnings.push(this.#diagnostics.report(weixinStageError('account-state-cleanup-failed', error), { ...context, warning: true }).publicError);
336
335
  }
337
336
  this.#errors.delete(botId);
337
+ this.#diagnostics.clear(botId);
338
338
  this.#touch();
339
339
  });
340
- return this.status();
340
+ return { ...this.status(), ...(warnings.length ? { warnings } : {}) };
341
341
  }
342
342
 
343
343
  status() {
@@ -353,7 +353,7 @@ export class WeixinController {
353
353
  : this.#errors.has(config.botId) || runtimeStatus?.weixinConnectionState === 'failed'
354
354
  ? 'error'
355
355
  : 'offline';
356
- const error = this.#errors.get(config.botId) ?? (state === 'error'
356
+ const error = connected ? null : this.#errors.get(config.botId) ?? runtimeStatus?.connectionError ?? (state === 'error'
357
357
  ? safeAccountError('connection-failed', t('微信连接未就绪,插件会自动重试。'))
358
358
  : null);
359
359
  return {
@@ -409,6 +409,7 @@ export class WeixinController {
409
409
  this.#closed = true;
410
410
  if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
411
411
  await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
412
+ this.#diagnostics.clear();
412
413
  }
413
414
 
414
415
  async #runProvisioning(record) {
@@ -494,9 +495,12 @@ export class WeixinController {
494
495
  record.state = 'cancelled';
495
496
  record.error = safeAccountError('cancelled', t('扫码绑定已取消。'));
496
497
  } else {
498
+ const failedStage = record.state === 'connecting' ? 'activation' : 'qr.poll';
497
499
  record.state = 'failed';
498
- record.error = publicProvisioningError(error);
499
- this.#logger.error?.('[dsh-weixin] provisioning failed:', error);
500
+ record.error = this.#diagnostics.report(error, {
501
+ operation: 'provision.poll', stage: failedStage,
502
+ code: 'activation-unknown-failed', rollback: error?.rollback,
503
+ }).publicError;
500
504
  }
501
505
  } finally {
502
506
  record.pendingVerifyCode = null;
@@ -524,49 +528,64 @@ export class WeixinController {
524
528
  try {
525
529
  previousToken = await this.#credentials.resolve(identity.tokenRef);
526
530
  } catch (error) {
527
- throw activationStageError('credential-read-failed', error);
531
+ throw weixinStageError('credential-read-failed', error);
528
532
  }
529
533
 
530
534
  return this.#withBotTransition(identity.botId, async () => {
531
535
  try {
532
536
  try {
533
- await this.#credentials.set(identity.tokenRef, token);
537
+ await this.#writeCredential(identity.tokenRef, token);
534
538
  } catch (error) {
535
- throw activationStageError('credential-save-failed', error);
539
+ throw weixinStageError('credential-save-failed', error);
536
540
  }
537
541
  this.#assertAttemptActive(record);
538
542
  try {
539
543
  await this.#configStore.save(config);
540
544
  } catch (error) {
541
- throw activationStageError('account-config-save-failed', error);
545
+ throw weixinStageError('account-config-save-failed', error);
542
546
  }
543
547
  this.#assertAttemptActive(record);
544
- await this.#startRuntime(config, token);
548
+ await this.#startRuntime(config, token, { operation: 'provision.poll' });
545
549
  this.#assertAttemptActive(record);
546
550
  this.#errors.delete(identity.botId);
547
551
  this.#touch();
548
552
  return identity.botId;
549
553
  } catch (error) {
550
- await this.#stopRuntime(identity.botId);
551
- if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined);
552
- else if (this.#configStore.get(identity.botId)) {
553
- const removed = await this.#configStore.remove(identity.botId).catch(() => null);
554
- if (removed) {
555
- await this.#deleteState({ botId: identity.botId, config }).catch((cleanupError) => {
556
- this.#logger.warn?.('[dsh-weixin] failed to clean up cancelled bot state:', cleanupError);
554
+ const cancelled = record.controller.signal.aborted || error?.name === 'AbortError';
555
+ const failure = cancelled ? error : this.#diagnostics.report(error, {
556
+ operation: 'provision.poll', stage: 'activation', code: 'activation-unknown-failed', botId: identity.botId,
557
+ });
558
+ let rollback = 'succeeded';
559
+ const recover = async (action) => {
560
+ try { return await action(); }
561
+ catch (restoreError) {
562
+ rollback = 'failed';
563
+ this.#diagnostics.report(weixinStageError('rollback-failed', restoreError), {
564
+ operation: cancelled ? 'provision.cancel' : 'provision.poll', botId: identity.botId, warning: true,
565
+ parentReferenceId: failure.publicError?.details.referenceId,
557
566
  });
558
567
  }
568
+ };
569
+ await this.#stopRuntime(identity.botId);
570
+ if (previousConfig) await recover(() => this.#configStore.save(previousConfig));
571
+ else if (this.#configStore.get(identity.botId)) {
572
+ const removed = await recover(() => this.#configStore.remove(identity.botId));
573
+ if (removed) await recover(() => this.#deleteState({ botId: identity.botId, config }));
559
574
  }
560
- await this.#restoreCredential(identity.tokenRef, previousToken);
575
+ await recover(() => previousToken?.value
576
+ ? this.#credentials.set(identity.tokenRef, previousToken.value)
577
+ : this.#credentials.unset(identity.tokenRef));
561
578
  if (previousConfig && previousToken?.value) {
562
- await this.#startRuntime(previousConfig, previousToken.value).catch(() => undefined);
579
+ await recover(() => this.#startRuntime(previousConfig, previousToken.value, { operation: 'provision.poll' }));
580
+ if (rollback === 'succeeded' && !this.#runtimes.get(identity.botId)?.status?.ready) rollback = 'unknown';
563
581
  }
564
- throw error;
582
+ this.#diagnostics.outcome(failure, rollback);
583
+ throw failure;
565
584
  }
566
585
  });
567
586
  }
568
587
 
569
- async #startRuntime(config, token) {
588
+ async #startRuntime(config, token, context = {}) {
570
589
  await this.#stopRuntime(config.botId);
571
590
  let runtime;
572
591
  try {
@@ -575,13 +594,13 @@ export class WeixinController {
575
594
  throw preserveActivationError(error, 'runtime-prepare-failed');
576
595
  }
577
596
  if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
578
- throw activationStageError(
597
+ throw weixinStageError(
579
598
  'runtime-prepare-failed',
580
599
  new TypeError('createRuntime returned an invalid Weixin runtime'),
581
600
  );
582
601
  }
583
602
  try {
584
- await runtime.start();
603
+ await runtime.start({ ...context, botId: config.botId });
585
604
  this.#runtimes.set(config.botId, runtime);
586
605
  } catch (error) {
587
606
  await runtime.stop().catch(() => undefined);
@@ -592,22 +611,32 @@ export class WeixinController {
592
611
  async #stopRuntime(botId) {
593
612
  const runtime = this.#runtimes.get(botId);
594
613
  this.#runtimes.delete(botId);
595
- await runtime?.stop().catch((error) => {
596
- this.#logger.warn?.(`[dsh-weixin] account ${botId} failed to stop cleanly:`, error);
597
- });
614
+ try {
615
+ const stopped = await runtime?.stop();
616
+ return stopped?.warnings ?? [];
617
+ } catch (error) {
618
+ return [this.#diagnostics.report(error, {
619
+ operation: 'connection.close', stage: 'connection.stop', code: 'connection-stop-failed', botId, warning: true,
620
+ }).publicError];
621
+ }
598
622
  }
599
623
 
600
624
  async #resolveToken(ref) {
601
- const result = await this.#credentials.resolve(ref).catch(() => undefined);
602
- return cleanString(result?.value);
625
+ try { return cleanString((await this.#credentials.resolve(ref))?.value); }
626
+ catch (error) { throw weixinStageError('credential-read-failed', error); }
603
627
  }
604
628
 
605
- async #restoreCredential(ref, previous) {
629
+ async #writeCredential(ref, value) {
606
630
  try {
607
- if (previous?.value) await this.#credentials.set(ref, previous.value);
608
- else await this.#credentials.unset(ref);
609
- } catch (error) {
610
- this.#logger.error?.(`[dsh-weixin] failed to restore credential ${ref}:`, error);
631
+ if (value === undefined) await this.#credentials.unset(ref);
632
+ else await this.#credentials.set(ref, value);
633
+ } catch (cause) {
634
+ let description;
635
+ try { description = await this.#credentials.describe?.(ref); } catch { /* Preserve the original write failure. */ }
636
+ if (description?.writable === false) {
637
+ cause = Object.assign(new Error('Credential source is read-only', { cause }), { code: 'read-only' });
638
+ }
639
+ throw weixinStageError(value === undefined ? 'credential-remove-failed' : 'credential-save-failed', cause);
611
640
  }
612
641
  }
613
642
 
@@ -1,4 +1,5 @@
1
- import { DEFAULT_WEIXIN_MAX_MESSAGE_CHARS, WeixinApiError } from './weixin-api.mjs';
1
+ import { createWeixinDiagnostics } from './connection-error.mjs';
2
+ import { DEFAULT_WEIXIN_MAX_MESSAGE_CHARS, WeixinApiError, rejectedProviderResponse } from './weixin-api.mjs';
2
3
  import {
3
4
  createWeixinBridgeStatus, WeixinHarnessBridge, weixinSendError, weixinSendFailureOptions,
4
5
  } from './weixin-bridge.mjs';
@@ -104,6 +105,7 @@ export function createWeixinRuntimeStatus() {
104
105
  harnessReachable: false,
105
106
  lastCheckedAt: null,
106
107
  lastError: null,
108
+ connectionError: null,
107
109
  ...createWeixinBridgeStatus(),
108
110
  };
109
111
  }
@@ -117,6 +119,8 @@ export class WeixinRuntime {
117
119
  #contextEnhancement;
118
120
  #accessPolicy;
119
121
  #logger;
122
+ #diagnostics;
123
+ #startContext = {};
120
124
  #replyTimeoutMs;
121
125
  #maxMessageChars;
122
126
  #startRetryDelaysMs;
@@ -135,6 +139,7 @@ export class WeixinRuntime {
135
139
  contextEnhancement,
136
140
  accessPolicy,
137
141
  logger = console,
142
+ diagnostics,
138
143
  replyTimeoutMs = 600_000,
139
144
  maxMessageChars = DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
140
145
  startRetryDelaysMs,
@@ -150,6 +155,7 @@ export class WeixinRuntime {
150
155
  this.#contextEnhancement = contextEnhancement;
151
156
  this.#accessPolicy = accessPolicy;
152
157
  this.#logger = logger;
158
+ this.#diagnostics = diagnostics ?? createWeixinDiagnostics({ logger });
153
159
  this.#replyTimeoutMs = replyTimeoutMs;
154
160
  this.#maxMessageChars = maxMessageChars;
155
161
  this.#startRetryDelaysMs = startRetryDelays(startRetryDelaysMs);
@@ -159,9 +165,10 @@ export class WeixinRuntime {
159
165
  return structuredClone(this.#status);
160
166
  }
161
167
 
162
- async start() {
168
+ async start(context = {}) {
163
169
  if (this.#status.ready && this.#monitor) return this.status;
164
170
  if (this.#starting) return this.#starting;
171
+ this.#startContext = context;
165
172
  this.#starting = this.#start().finally(() => {
166
173
  this.#starting = null;
167
174
  });
@@ -173,6 +180,7 @@ export class WeixinRuntime {
173
180
  this.#status.startedAt = new Date().toISOString();
174
181
  this.#status.weixinConnectionState = 'connecting';
175
182
  this.#status.lastError = null;
183
+ this.#status.connectionError = null;
176
184
  try {
177
185
  try {
178
186
  await this.#harness.ensureRunning();
@@ -206,8 +214,11 @@ export class WeixinRuntime {
206
214
  if (signal.aborted) return;
207
215
  this.#status.ready = false;
208
216
  this.#status.weixinConnectionState = 'failed';
209
- this.#status.lastError = error?.message ?? String(error);
210
- this.#logger.error?.(`[dsh-weixin] account ${this.#config.botId} monitor stopped:`, error);
217
+ const failure = this.#diagnostics.report(error, {
218
+ operation: 'connection.monitor', stage: 'connection.poll', botId: this.#config.botId, automatic: true,
219
+ });
220
+ this.#status.connectionError = failure.publicError;
221
+ this.#status.lastError = failure.publicError.message;
211
222
  });
212
223
  return this.status;
213
224
  } catch (error) {
@@ -216,7 +227,6 @@ export class WeixinRuntime {
216
227
  this.#bridge = null;
217
228
  this.#status.ready = false;
218
229
  this.#status.weixinConnectionState = 'failed';
219
- this.#status.lastError = error?.message ?? String(error);
220
230
  throw error;
221
231
  }
222
232
  }
@@ -231,10 +241,9 @@ export class WeixinRuntime {
231
241
  } catch (error) {
232
242
  const wait = this.#startRetryDelaysMs[attempt];
233
243
  if (wait === undefined || !retryableStartError(error)) throw error;
234
- this.#logger.warn?.(
235
- `[dsh-weixin] account ${this.#config.botId} start request failed; retrying in ${wait}ms:`,
236
- error,
237
- );
244
+ this.#diagnostics.report(error, {
245
+ ...this.#startContext, stage: 'connection.start', botId: this.#config.botId, automatic: true, warning: true,
246
+ });
238
247
  await delay(wait);
239
248
  }
240
249
  }
@@ -251,13 +260,12 @@ export class WeixinRuntime {
251
260
  signal,
252
261
  });
253
262
  if (signal.aborted) return;
254
- const rejected = (response?.ret !== undefined && response.ret !== 0)
255
- || (response?.errcode !== undefined && response.errcode !== 0);
256
- if (rejected) {
257
- const code = response.errcode ?? response.ret;
263
+ const providerCode = rejectedProviderResponse(response, ['errcode', 'ret']);
264
+ if (providerCode) {
258
265
  throw new WeixinApiError(
259
- code === -14 ? 'stale-token' : 'updates-rejected',
260
- code === -14 ? t('微信登录凭据已失效,请移除账号后重新扫码。') : t('微信消息同步请求被拒绝。'),
266
+ providerCode === '-14' ? 'stale-token' : 'updates-rejected',
267
+ providerCode === '-14' ? t('微信登录凭据已失效,请移除账号后重新扫码。') : t('微信消息同步请求被拒绝。'),
268
+ { providerCode },
261
269
  );
262
270
  }
263
271
  consecutiveFailures = 0;
@@ -265,6 +273,7 @@ export class WeixinRuntime {
265
273
  this.#status.weixinConnectionState = 'connected';
266
274
  this.#status.lastCheckedAt = Date.now();
267
275
  this.#status.lastError = null;
276
+ this.#status.connectionError = null;
268
277
 
269
278
  for (const message of orderWeixinMessages(response?.msgs)) {
270
279
  void this.#bridge.accept(message).catch((error) => {
@@ -281,19 +290,19 @@ export class WeixinRuntime {
281
290
  } catch (error) {
282
291
  if (signal.aborted) return;
283
292
  consecutiveFailures += 1;
284
- this.#status.lastError = error?.message ?? String(error);
285
- this.#logger.warn?.(
286
- `[dsh-weixin] account ${this.#config.botId} poll failed (${consecutiveFailures}/3):`,
287
- error,
288
- );
289
- if (error instanceof WeixinApiError && error.code === 'stale-token') throw error;
290
- if (consecutiveFailures >= 3) throw error;
293
+ const failure = this.#diagnostics.report(error, {
294
+ operation: 'connection.monitor', stage: 'connection.poll', botId: this.#config.botId, automatic: true, warning: true,
295
+ });
296
+ this.#status.lastError = failure.publicError.message;
297
+ if (error instanceof WeixinApiError && error.code === 'stale-token') throw failure;
298
+ if (consecutiveFailures >= 3) throw failure;
291
299
  await delay(Math.min(2_000 * (2 ** (consecutiveFailures - 1)), 10_000), signal);
292
300
  }
293
301
  }
294
302
  }
295
303
 
296
304
  async stop() {
305
+ const warnings = [];
297
306
  const monitor = this.#monitor;
298
307
  const bridge = this.#bridge;
299
308
  const wasStarted = Boolean(this.#abortController || monitor || this.#status.ready);
@@ -312,12 +321,14 @@ export class WeixinRuntime {
312
321
  signal: AbortSignal.timeout(10_000),
313
322
  });
314
323
  } catch (error) {
315
- this.#logger.warn?.(`[dsh-weixin] account ${this.#config.botId} stop notification failed:`, error);
324
+ warnings.push(this.#diagnostics.report(error, {
325
+ operation: 'connection.close', stage: 'connection.stop', code: 'connection-stop-failed', botId: this.#config.botId, warning: true,
326
+ }).publicError);
316
327
  }
317
328
  }
318
329
  this.#status.ready = false;
319
330
  this.#status.weixinConnectionState = 'idle';
320
- return this.status;
331
+ return { ...this.status, ...(warnings.length ? { warnings } : {}) };
321
332
  }
322
333
 
323
334
  async sendConnectionTest(text) {