@xmanrui/dsh-im 4.9.0 → 4.10.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 (47) hide show
  1. package/PROACTIVE_DELIVERY.en.md +8 -0
  2. package/PROACTIVE_DELIVERY.md +8 -0
  3. package/lib/client.js +679 -73
  4. package/lib/index.js +276 -275
  5. package/package.json +21 -6
  6. package/plugin-src/client/channels/dingtalk/api.js +23 -6
  7. package/plugin-src/client/channels/dingtalk/index.js +33 -6
  8. package/plugin-src/client/channels/dingtalk/styles.js +2 -0
  9. package/plugin-src/client/channels/feishu/api.js +2 -0
  10. package/plugin-src/client/channels/feishu/index.js +54 -0
  11. package/plugin-src/client/global-settings.js +341 -0
  12. package/plugin-src/client/i18n.js +33 -0
  13. package/plugin-src/client/index.js +47 -6
  14. package/plugin-src/client/styles.js +49 -0
  15. package/plugin-src/host/channels/dingtalk/production.mjs +12 -0
  16. package/plugin-src/host/channels/dingtalk/rpc.mjs +24 -0
  17. package/plugin-src/host/channels/feishu/production.mjs +14 -0
  18. package/plugin-src/host/channels/feishu/rpc.mjs +16 -0
  19. package/plugin-src/host/channels/qq/production.mjs +12 -0
  20. package/plugin-src/host/channels/shared/production.mjs +12 -0
  21. package/plugin-src/host/channels/slack/production.mjs +12 -0
  22. package/plugin-src/host/channels/wecom/production.mjs +12 -0
  23. package/plugin-src/host/channels/weixin/production.mjs +12 -0
  24. package/plugin-src/host/channels/whatsapp/production.mjs +12 -0
  25. package/plugin-src/host/delivery-adapter.mjs +1 -0
  26. package/plugin-src/host/delivery-service.mjs +15 -0
  27. package/plugin-src/host/harness-session-coordinator.mjs +26 -5
  28. package/plugin-src/host/inbound-ttl-rpc.mjs +83 -0
  29. package/plugin-src/host/inbound-ttl-runtime.mjs +85 -0
  30. package/plugin-src/host/inbound-ttl-service.mjs +179 -0
  31. package/plugin-src/host/index.mjs +8 -0
  32. package/plugin-src/host/modern-harness-api.mjs +75 -10
  33. package/src/channels/dingtalk/connection-error.mjs +299 -0
  34. package/src/channels/dingtalk/dingtalk-controller.mjs +79 -34
  35. package/src/channels/dingtalk/dingtalk-runtime.mjs +9 -2
  36. package/src/channels/feishu/bridge.mjs +164 -4
  37. package/src/channels/feishu/feishu-channel.mjs +45 -10
  38. package/src/channels/feishu/feishu-runtime.mjs +9 -0
  39. package/src/channels/feishu/message-utils.mjs +13 -0
  40. package/src/channels/feishu/multi-bot-controller.mjs +16 -0
  41. package/src/channels/feishu/plugin-config-store.mjs +1 -0
  42. package/src/channels/feishu/state-store.mjs +25 -0
  43. package/src/channels/shared/bot-workspace-store.mjs +6 -0
  44. package/src/channels/shared/i18n-en/dingtalk.mjs +23 -0
  45. package/src/channels/shared/inbound-file.mjs +117 -2
  46. package/src/channels/shared/inbound-ttl-store.mjs +110 -0
  47. package/src/channels/shared/inbound-ttl.mjs +22 -0
@@ -0,0 +1,299 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createRequire } from 'node:module';
3
+
4
+ import { t } from '../shared/i18n.mjs';
5
+
6
+ const require = createRequire(import.meta.url);
7
+ const STAGE_CODES = new Set([
8
+ 'dingtalk-harness-connect-failed',
9
+ 'dingtalk-runtime-prepare-failed',
10
+ 'dingtalk-stream-client-load-failed',
11
+ 'dingtalk-stream-listener-failed',
12
+ 'dingtalk-stream-connect-failed',
13
+ ]);
14
+ const PROXY_VARIABLES = Object.freeze([
15
+ 'HTTPS_PROXY',
16
+ 'https_proxy',
17
+ 'HTTP_PROXY',
18
+ 'http_proxy',
19
+ 'ALL_PROXY',
20
+ 'all_proxy',
21
+ ]);
22
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/;
23
+ const PUBLIC_REFERENCE_PATTERN = /^DT-CONN-[A-F0-9]{8}$/;
24
+ let installedDependencyVersions;
25
+
26
+ function nonEmptyString(value, maxLength = 500) {
27
+ if (typeof value !== 'string') return null;
28
+ const text = value.trim();
29
+ return text ? text.slice(0, maxLength) : null;
30
+ }
31
+
32
+ function safeVersion(value) {
33
+ const version = nonEmptyString(value, 80);
34
+ return version && VERSION_PATTERN.test(version) ? version : null;
35
+ }
36
+
37
+ function resolvedPackage(name, from = require) {
38
+ try {
39
+ const packagePath = from.resolve(`${name}/package.json`);
40
+ return {
41
+ version: safeVersion(from(packagePath)?.version),
42
+ require: createRequire(packagePath),
43
+ };
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /** Returns only non-sensitive versions involved in the external DingTalk Stream dependency chain. */
50
+ export function installedDingtalkConnectionDependencies() {
51
+ if (installedDependencyVersions) return installedDependencyVersions;
52
+ const dingtalkStream = resolvedPackage('dingtalk-stream');
53
+ const axios = dingtalkStream ? resolvedPackage('axios', dingtalkStream.require) : null;
54
+ const httpsProxyAgent = axios ? resolvedPackage('https-proxy-agent', axios.require) : null;
55
+ const agentBase = httpsProxyAgent ? resolvedPackage('agent-base', httpsProxyAgent.require) : null;
56
+ installedDependencyVersions = Object.freeze({
57
+ dingtalkStream: dingtalkStream?.version ?? null,
58
+ axios: axios?.version ?? null,
59
+ httpsProxyAgent: httpsProxyAgent?.version ?? null,
60
+ agentBase: agentBase?.version ?? null,
61
+ });
62
+ return installedDependencyVersions;
63
+ }
64
+
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
+ }
76
+
77
+ function statusFrom(error) {
78
+ if (Number.isInteger(error?.status)) return error.status;
79
+ if (Number.isInteger(error?.statusCode)) return error.statusCode;
80
+ if (Number.isInteger(error?.response?.status)) return error.response.status;
81
+ return null;
82
+ }
83
+
84
+ function providerCodeFrom(error) {
85
+ const value = nonEmptyString(
86
+ error?.providerCode
87
+ ?? error?.response?.data?.code
88
+ ?? error?.response?.data?.errorCode
89
+ ?? error?.response?.data?.errcode,
90
+ 100,
91
+ );
92
+ return value && /^[A-Za-z0-9_.:-]+$/.test(value) ? value : null;
93
+ }
94
+
95
+ function redactMessage(value, sensitiveValues) {
96
+ let message = nonEmptyString(value);
97
+ if (!message) return null;
98
+ for (const sensitive of sensitiveValues) {
99
+ const text = nonEmptyString(sensitive, 2_048);
100
+ if (text && text.length >= 4) message = message.replaceAll(text, '••••');
101
+ }
102
+ return message
103
+ .replace(/(https?:\/\/)[^/\s@]+@/giu, '$1••••@')
104
+ .replace(/([?&](?:appsecret|client_secret|clientsecret|access_token|token|password)=)[^&\s]*/giu, '$1••••')
105
+ .replace(/((?:app|client|access)[_-]?secret|authorization|password|token)\s*[=:]\s*[^\s,;,。]+/giu, '$1=••••')
106
+ .replace(/\b[A-Za-z0-9_.-]*secret[A-Za-z0-9_.-]*\b/giu, '••••')
107
+ .slice(0, 500);
108
+ }
109
+
110
+ function safeDependencyVersions(value) {
111
+ const source = value && typeof value === 'object' ? value : {};
112
+ return {
113
+ dingtalkStream: safeVersion(source.dingtalkStream),
114
+ axios: safeVersion(source.axios),
115
+ httpsProxyAgent: safeVersion(source.httpsProxyAgent),
116
+ agentBase: safeVersion(source.agentBase),
117
+ };
118
+ }
119
+
120
+ function diagnosticErrors(chain, sensitiveValues) {
121
+ return chain.map((error) => {
122
+ const name = nonEmptyString(error?.name, 80);
123
+ const code = nonEmptyString(error?.code, 100);
124
+ const status = statusFrom(error);
125
+ const providerCode = providerCodeFrom(error);
126
+ const message = redactMessage(error?.message, sensitiveValues);
127
+ return {
128
+ ...(name ? { name } : {}),
129
+ ...(code ? { code } : {}),
130
+ ...(status !== null ? { status } : {}),
131
+ ...(providerCode ? { providerCode } : {}),
132
+ ...(message ? { message } : {}),
133
+ };
134
+ });
135
+ }
136
+
137
+ function publicReference(value) {
138
+ return PUBLIC_REFERENCE_PATTERN.test(value ?? '')
139
+ ? value
140
+ : `DT-CONN-${randomUUID().replaceAll('-', '').slice(0, 8).toUpperCase()}`;
141
+ }
142
+
143
+ function fixedPublicError(code, message, hint, referenceId) {
144
+ return Object.freeze({ code, message: t(message), hint: t(hint), referenceId });
145
+ }
146
+
147
+ /** Adds a stable startup stage without discarding the original exception as `cause`. */
148
+ export function dingtalkRuntimeStartError(code, cause) {
149
+ if (cause?.name === 'AbortError' || STAGE_CODES.has(cause?.code)) return cause;
150
+ const error = new Error(
151
+ nonEmptyString(cause?.message) ?? 'DingTalk runtime startup failed',
152
+ { cause },
153
+ );
154
+ error.name = 'DingtalkRuntimeStartError';
155
+ error.code = STAGE_CODES.has(code) ? code : 'dingtalk-runtime-prepare-failed';
156
+ return error;
157
+ }
158
+
159
+ /** Creates browser-safe guidance plus a redacted Host-log diagnostic for one connection failure. */
160
+ export function describeDingtalkConnectionFailure(error, {
161
+ fallbackMessage = '钉钉连接未就绪,请稍后重试。',
162
+ clientId,
163
+ clientSecret,
164
+ environment = process.env,
165
+ dependencies = installedDingtalkConnectionDependencies(),
166
+ nodeVersion = process.versions.node,
167
+ referenceId: suppliedReferenceId,
168
+ } = {}) {
169
+ const referenceId = publicReference(suppliedReferenceId);
170
+ const chain = errorChain(error);
171
+ const codes = new Set(chain
172
+ .map((entry) => nonEmptyString(entry?.code, 100)?.toUpperCase())
173
+ .filter(Boolean));
174
+ const messages = chain
175
+ .map((entry) => nonEmptyString(entry?.message)?.toLowerCase())
176
+ .filter(Boolean);
177
+ const statuses = chain.map(statusFrom).filter((status) => status !== null);
178
+ const stage = chain.map((entry) => entry?.code).find((code) => STAGE_CODES.has(code)) ?? null;
179
+ const proxyVariables = PROXY_VARIABLES.filter((name) => nonEmptyString(environment?.[name], 4_096));
180
+ const proxyConfigured = proxyVariables.length > 0;
181
+ const versions = safeDependencyVersions(dependencies);
182
+ const messageContains = (pattern) => messages.some((message) => pattern.test(message));
183
+ const codeContains = (pattern) => [...codes].some((code) => pattern.test(code));
184
+ const proxyFailureSignal = statuses.includes(407)
185
+ || codeContains(/(?:PROXY|ERR_INVALID_PROTOCOL)/u)
186
+ || messageContains(/proxy|tunneling socket/u);
187
+
188
+ let publicError;
189
+ if (stage === 'dingtalk-stream-connect-failed'
190
+ && proxyConfigured
191
+ && versions.agentBase === '6.0.0') {
192
+ publicError = fixedPublicError(
193
+ 'stream-proxy-dependency-incompatible',
194
+ '钉钉 Stream 连接失败:检测到代理依赖 agent-base 6.0.0。',
195
+ '请将 DSH profile 中的 agent-base@6 固定为 6.0.2 后重新安装依赖,或升级 pnpm 后重新解析 lockfile。',
196
+ referenceId,
197
+ );
198
+ } else if (stage === 'dingtalk-harness-connect-failed') {
199
+ publicError = fixedPublicError(
200
+ 'harness-unavailable',
201
+ '插件无法连接本机 Harness。',
202
+ '请确认 dsh web 正常运行,并查看 dsh web 日志中相同参考号对应的诊断信息。',
203
+ referenceId,
204
+ );
205
+ } else if (stage === 'dingtalk-stream-client-load-failed') {
206
+ publicError = fixedPublicError(
207
+ 'stream-sdk-load-failed',
208
+ '钉钉 Stream SDK 加载失败。',
209
+ '请重新安装当前 DSH profile 的插件依赖,并查看 dsh web 日志中相同参考号对应的诊断信息。',
210
+ referenceId,
211
+ );
212
+ } else if (statuses.some((status) => status === 401 || status === 403)) {
213
+ publicError = fixedPublicError(
214
+ 'stream-credentials-rejected',
215
+ '钉钉拒绝了当前应用凭据。',
216
+ '请核对 Client ID、Client Secret 和机器人权限后重试。',
217
+ referenceId,
218
+ );
219
+ } else if (codeContains(/(?:TIMEOUT|ETIMEDOUT)/u) || messageContains(/timed? out|timeout/u)) {
220
+ publicError = fixedPublicError(
221
+ 'stream-handshake-timeout',
222
+ '连接钉钉 Stream 超时。',
223
+ '请检查网络、代理和防火墙后重试;详细原因可在 dsh web 日志中按参考号查找。',
224
+ referenceId,
225
+ );
226
+ } else if (codeContains(/^(?:ENOTFOUND|EAI_AGAIN)$/u)) {
227
+ publicError = fixedPublicError(
228
+ 'stream-dns-failed',
229
+ '无法解析钉钉服务地址。',
230
+ '请检查 DNS、网络和代理设置;详细原因可在 dsh web 日志中按参考号查找。',
231
+ referenceId,
232
+ );
233
+ } else if (codeContains(/(?:CERT|TLS|SSL|UNABLE_TO_VERIFY)/u)) {
234
+ publicError = fixedPublicError(
235
+ 'stream-tls-failed',
236
+ '钉钉 Stream 的 TLS 连接校验失败。',
237
+ '请检查系统证书、代理证书或 HTTPS 中间代理;详细原因可在 dsh web 日志中按参考号查找。',
238
+ referenceId,
239
+ );
240
+ } else if (stage === 'dingtalk-stream-connect-failed'
241
+ && proxyFailureSignal) {
242
+ publicError = fixedPublicError(
243
+ 'stream-proxy-failed',
244
+ '钉钉 Stream 无法通过当前代理建立连接。',
245
+ '请检查 HTTP_PROXY、HTTPS_PROXY、NO_PROXY 和代理连通性;详细原因可在 dsh web 日志中按参考号查找。',
246
+ referenceId,
247
+ );
248
+ } else if (stage === 'dingtalk-stream-connect-failed') {
249
+ publicError = fixedPublicError(
250
+ 'stream-connect-failed',
251
+ '钉钉 Stream 消息连接建立失败。',
252
+ '请检查网络和机器人配置;详细原因可在 dsh web 日志中按参考号查找。',
253
+ referenceId,
254
+ );
255
+ } else if (stage === 'dingtalk-stream-listener-failed') {
256
+ publicError = fixedPublicError(
257
+ 'stream-listener-failed',
258
+ '钉钉 Stream 消息监听初始化失败。',
259
+ '请确认 dsh-im 与 dingtalk-stream 版本兼容,并按参考号查看 dsh web 日志。',
260
+ referenceId,
261
+ );
262
+ } else if (stage === 'dingtalk-runtime-prepare-failed') {
263
+ publicError = fixedPublicError(
264
+ 'runtime-prepare-failed',
265
+ '钉钉机器人运行环境初始化失败。',
266
+ '请检查 DSH 数据目录、工作区和插件依赖,并按参考号查看 dsh web 日志。',
267
+ referenceId,
268
+ );
269
+ } else {
270
+ publicError = fixedPublicError(
271
+ 'connection-failed',
272
+ fallbackMessage,
273
+ '请在 dsh web 日志中查找相同参考号,以获取已脱敏的具体错误。',
274
+ referenceId,
275
+ );
276
+ }
277
+
278
+ return Object.freeze({
279
+ publicError,
280
+ diagnostic: Object.freeze({
281
+ referenceId,
282
+ category: publicError.code,
283
+ stage,
284
+ runtime: { node: safeVersion(nodeVersion) },
285
+ proxy: { configured: proxyConfigured, variables: proxyVariables },
286
+ dependencies: versions,
287
+ errors: diagnosticErrors(chain, [clientId, clientSecret]),
288
+ }),
289
+ });
290
+ }
291
+
292
+ /** Carries only a pre-built public projection across the Host RPC boundary. */
293
+ export function dingtalkPublicConnectionError(publicError, cause) {
294
+ const error = new Error(publicError.message, { cause });
295
+ error.name = 'DingtalkPublicConnectionError';
296
+ error.code = publicError.code;
297
+ error.publicError = structuredClone(publicError);
298
+ return error;
299
+ }
@@ -12,6 +12,11 @@ import {
12
12
  } from '../shared/connection-test.mjs';
13
13
  import { t } from '../shared/i18n.mjs';
14
14
  import { publicMessageFailure } from '../shared/message-failure.mjs';
15
+ import {
16
+ describeDingtalkConnectionFailure,
17
+ dingtalkPublicConnectionError,
18
+ dingtalkRuntimeStartError,
19
+ } from './connection-error.mjs';
15
20
 
16
21
  const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
17
22
  const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']);
@@ -216,12 +221,14 @@ export class DingtalkController {
216
221
  try {
217
222
  await this.#startRuntime(latest, clientSecret);
218
223
  this.#errors.delete(latest.botId);
219
- } catch {
220
- this.#errors.set(
221
- latest.botId,
222
- safeError('connection-failed', t('钉钉连接未就绪,请稍后重试。')),
223
- );
224
- this.#logger.warn?.(`[dsh-dingtalk] bot ${latest.botId} failed to initialize`);
224
+ } catch (error) {
225
+ this.#rememberConnectionFailure({
226
+ config: latest,
227
+ clientSecret,
228
+ error,
229
+ fallbackMessage: '钉钉连接未就绪,请稍后重试。',
230
+ context: `bot ${latest.botId} failed to initialize`,
231
+ });
225
232
  }
226
233
  this.#touch();
227
234
  });
@@ -318,12 +325,14 @@ export class DingtalkController {
318
325
  try {
319
326
  await this.#startRuntime(config, normalizedSecret);
320
327
  this.#errors.delete(identity.botId);
321
- } catch {
322
- this.#errors.set(
323
- identity.botId,
324
- safeError('connection-failed', t('钉钉已接入,但消息连接暂未就绪,请稍后重试。')),
325
- );
326
- this.#logger.warn?.('[dsh-dingtalk] credential-bound bot saved but its connection is not ready');
328
+ } catch (error) {
329
+ this.#rememberConnectionFailure({
330
+ config,
331
+ clientSecret: normalizedSecret,
332
+ error,
333
+ fallbackMessage: '钉钉已接入,但消息连接暂未就绪,请稍后重试。',
334
+ context: `credential-bound bot ${identity.botId} is not ready`,
335
+ });
327
336
  }
328
337
  this.#touch();
329
338
  });
@@ -380,11 +389,14 @@ export class DingtalkController {
380
389
  await this.#startRuntime(config, clientSecret);
381
390
  this.#errors.delete(botId);
382
391
  } catch (error) {
383
- this.#errors.set(
384
- botId,
385
- safeError('connection-failed', t('钉钉连接仍未就绪,请稍后重试。')),
386
- );
387
- throw error;
392
+ const publicError = this.#rememberConnectionFailure({
393
+ config,
394
+ clientSecret,
395
+ error,
396
+ fallbackMessage: '钉钉连接仍未就绪,请稍后重试。',
397
+ context: `bot ${botId} failed to reconnect`,
398
+ });
399
+ throw dingtalkPublicConnectionError(publicError, error);
388
400
  } finally {
389
401
  this.#touch();
390
402
  }
@@ -675,11 +687,13 @@ export class DingtalkController {
675
687
  await rollback();
676
688
  throw abortError();
677
689
  }
678
- this.#errors.set(
679
- identity.botId,
680
- safeError('connection-failed', t('钉钉已接入,但消息连接暂未就绪,请稍后重试。')),
681
- );
682
- this.#logger.warn?.('[dsh-dingtalk] authorized bot saved but its connection is not ready');
690
+ this.#rememberConnectionFailure({
691
+ config,
692
+ clientSecret,
693
+ error,
694
+ fallbackMessage: '钉钉已接入,但消息连接暂未就绪,请稍后重试。',
695
+ context: `authorized bot ${identity.botId} is not ready`,
696
+ });
683
697
  }
684
698
  return { botId: identity.botId, alreadyConnected: Boolean(previousConfig) };
685
699
  });
@@ -696,11 +710,14 @@ export class DingtalkController {
696
710
  } catch (error) {
697
711
  await this.#configStore.save(previousConfig).catch(() => undefined);
698
712
  await this.#startRuntime(previousConfig, clientSecret).catch(() => undefined);
699
- this.#errors.set(
700
- previousConfig.botId,
701
- safeError('connection-failed', t('钉钉连接未就绪,请稍后重试。')),
702
- );
703
- throw error;
713
+ const publicError = this.#rememberConnectionFailure({
714
+ config: previousConfig,
715
+ clientSecret,
716
+ error,
717
+ fallbackMessage: '钉钉连接未就绪,请稍后重试。',
718
+ context: `bot ${previousConfig.botId} failed to apply updated settings`,
719
+ });
720
+ throw dingtalkPublicConnectionError(publicError, error);
704
721
  } finally {
705
722
  this.#touch();
706
723
  }
@@ -711,13 +728,21 @@ export class DingtalkController {
711
728
  if (this.#closed) throw abortError();
712
729
  await this.#stopRuntime(config.botId);
713
730
  if (this.#closed) throw abortError();
714
- const runtime = await this.#createRuntime({
715
- botId: config.botId,
716
- config: structuredClone(config),
717
- clientSecret,
718
- });
731
+ let runtime;
732
+ try {
733
+ runtime = await this.#createRuntime({
734
+ botId: config.botId,
735
+ config: structuredClone(config),
736
+ clientSecret,
737
+ });
738
+ } catch (error) {
739
+ throw dingtalkRuntimeStartError('dingtalk-runtime-prepare-failed', error);
740
+ }
719
741
  if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
720
- throw new TypeError('createRuntime returned an invalid DingTalk runtime');
742
+ throw dingtalkRuntimeStartError(
743
+ 'dingtalk-runtime-prepare-failed',
744
+ new TypeError('createRuntime returned an invalid DingTalk runtime'),
745
+ );
721
746
  }
722
747
  if (this.#closed) {
723
748
  await runtime.stop().catch(() => undefined);
@@ -733,10 +758,30 @@ export class DingtalkController {
733
758
  } catch (error) {
734
759
  if (this.#runtimes.get(config.botId) === runtime) this.#runtimes.delete(config.botId);
735
760
  await runtime.stop().catch(() => undefined);
736
- throw error;
761
+ throw dingtalkRuntimeStartError('dingtalk-stream-connect-failed', error);
737
762
  }
738
763
  }
739
764
 
765
+ #rememberConnectionFailure({
766
+ config,
767
+ clientSecret,
768
+ error,
769
+ fallbackMessage,
770
+ context,
771
+ }) {
772
+ const failure = describeDingtalkConnectionFailure(error, {
773
+ fallbackMessage,
774
+ clientId: config.clientId,
775
+ clientSecret,
776
+ });
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;
783
+ }
784
+
740
785
  async #stopRuntime(botId) {
741
786
  const runtime = this.#runtimes.get(botId);
742
787
  this.#runtimes.delete(botId);
@@ -6,6 +6,7 @@ import {
6
6
  import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
7
7
  import { t } from '../shared/i18n.mjs';
8
8
  import { captureContextEnhancement } from '../shared/context-enhancement.mjs';
9
+ import { dingtalkRuntimeStartError } from './connection-error.mjs';
9
10
 
10
11
  function nonEmptyString(value) {
11
12
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -223,10 +224,12 @@ export class DingtalkRuntime {
223
224
  this.#status.startedAt = new Date().toISOString();
224
225
  this.#status.dingtalkStreamState = 'connecting';
225
226
  this.#status.lastError = null;
227
+ let startStage = 'dingtalk-harness-connect-failed';
226
228
 
227
229
  try {
228
230
  await this.#harness.ensureRunning({ signal });
229
231
  this.#status.harnessReachable = true;
232
+ startStage = 'dingtalk-runtime-prepare-failed';
230
233
  if (typeof this.#state.removePendingSenderByStaffId === 'function') {
231
234
  for (const staffId of approvedSenderIds(this.#config)) {
232
235
  await this.#state.removePendingSenderByStaffId(staffId);
@@ -249,6 +252,7 @@ export class DingtalkRuntime {
249
252
  signal,
250
253
  });
251
254
 
255
+ startStage = 'dingtalk-stream-client-load-failed';
252
256
  const created = await this.#streamFactory({
253
257
  clientId: this.#config.clientId,
254
258
  clientSecret: this.#clientSecret,
@@ -266,6 +270,7 @@ export class DingtalkRuntime {
266
270
 
267
271
  const client = this.#client;
268
272
  const bridge = this.#bridge;
273
+ startStage = 'dingtalk-stream-listener-failed';
269
274
  client.registerCallbackListener(this.#topic, (response) => {
270
275
  if (this.#client !== client || this.#bridge !== bridge) return;
271
276
  const callbackMessageId = nonEmptyString(response?.headers?.messageId);
@@ -314,6 +319,7 @@ export class DingtalkRuntime {
314
319
  this.#callbackTasks.add(task);
315
320
  });
316
321
 
322
+ startStage = 'dingtalk-stream-connect-failed';
317
323
  await connectStream(
318
324
  client,
319
325
  this.#connectTimeoutMs,
@@ -336,11 +342,12 @@ export class DingtalkRuntime {
336
342
  return this.status;
337
343
  } catch (error) {
338
344
  const aborted = signal.aborted;
345
+ const failure = aborted ? error : dingtalkRuntimeStartError(startStage, error);
339
346
  this.#status.ready = false;
340
347
  this.#status.dingtalkStreamState = aborted ? 'idle' : 'failed';
341
- this.#status.lastError = aborted ? null : (error?.message ?? String(error));
348
+ this.#status.lastError = aborted ? null : (failure?.message ?? String(failure));
342
349
  await this.stop({ preserveError: !aborted });
343
- throw error;
350
+ throw failure;
344
351
  }
345
352
  }
346
353