@soimy/dingtalk 3.5.2 → 3.6.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 (40) hide show
  1. package/README.md +6 -23
  2. package/index.ts +7 -0
  3. package/openclaw.plugin.json +799 -0
  4. package/package.json +5 -5
  5. package/src/card/card-markdown-image-reroute.ts +106 -0
  6. package/src/card/card-run-registry.ts +54 -1
  7. package/src/card/card-stop-handler.ts +10 -20
  8. package/src/card/card-streaming-mode.ts +30 -0
  9. package/src/card/card-template.ts +14 -3
  10. package/src/card/reasoning-answer-split.ts +162 -0
  11. package/src/card/statusline-renderer.ts +94 -0
  12. package/src/card-draft-controller.ts +326 -54
  13. package/src/card-service.ts +479 -8
  14. package/src/channel.ts +19 -1062
  15. package/src/config-schema.ts +81 -38
  16. package/src/config.ts +142 -4
  17. package/src/device-registration.ts +245 -0
  18. package/src/gateway/channel-gateway.ts +636 -0
  19. package/src/inbound-handler.ts +489 -49
  20. package/src/media-utils.ts +169 -7
  21. package/src/message-utils.ts +153 -17
  22. package/src/messaging/btw-deliver.ts +85 -0
  23. package/src/messaging/channel-actions.ts +173 -0
  24. package/src/messaging/channel-outbound.ts +158 -0
  25. package/src/messaging/quoted-file-service.ts +9 -4
  26. package/src/onboarding.ts +323 -205
  27. package/src/platform/channel-status.ts +81 -0
  28. package/src/plugin-sdk-channel-actions-augment.ts +11 -0
  29. package/src/reply-strategy-card.ts +568 -44
  30. package/src/reply-strategy-markdown.ts +2 -2
  31. package/src/reply-strategy-types.ts +93 -0
  32. package/src/reply-strategy-with-reaction.ts +1 -1
  33. package/src/reply-strategy.ts +14 -56
  34. package/src/run-usage-store.ts +59 -0
  35. package/src/send-service.ts +225 -7
  36. package/src/session-state.ts +62 -0
  37. package/src/targeting/agent-name-matcher.ts +28 -0
  38. package/src/targeting/agent-routing.ts +44 -28
  39. package/src/types.ts +49 -117
  40. package/src/utils.ts +25 -0
@@ -0,0 +1,636 @@
1
+ import { DWClient, TOPIC_CARD, TOPIC_ROBOT } from "dingtalk-stream";
2
+ import { analyzeCardCallback } from "../card-callback-service";
3
+ import { handleCardAction } from "../card/card-action-handler";
4
+ import {
5
+ finalizeActiveCardsForAccount,
6
+ recoverPendingCardsForAccount,
7
+ } from "../card-service";
8
+ import { resolveRobotCode } from "../config";
9
+ import { ConnectionManager } from "../connection-manager";
10
+ import { isMessageProcessed, markMessageProcessed } from "../dedup";
11
+ import {
12
+ isLearningAutoApplyEnabled,
13
+ isLearningEnabled,
14
+ recordExplicitFeedbackLearning,
15
+ } from "../feedback-learning-service";
16
+ import { handleDingTalkMessage } from "../inbound-handler";
17
+ import { setCurrentLogger } from "../logger-context";
18
+ import { preloadPeerIdsFromSessions } from "../peer-id-registry";
19
+ import { getDingTalkRuntime } from "../runtime";
20
+ import { sendProactiveTextOrMarkdown } from "../send-service";
21
+ import type {
22
+ ConnectionManagerConfig,
23
+ DingTalkChannelPlugin,
24
+ DingTalkInboundMessage,
25
+ GatewayStartContext,
26
+ GatewayStopResult,
27
+ StreamClientFactory,
28
+ } from "../types";
29
+ import { ConnectionState } from "../types";
30
+ import {
31
+ closePluginDebugLog,
32
+ cleanupOrphanedTempFiles,
33
+ createResolve4FallbackLookup,
34
+ formatDingTalkConnectionErrorLog,
35
+ getCurrentTimestamp,
36
+ resolvePluginDebugLog,
37
+ } from "../utils";
38
+
39
+ type InstrumentedDWClient = {
40
+ getEndpoint?: () => Promise<unknown>;
41
+ _connect?: () => Promise<unknown>;
42
+ config?: Record<string, unknown> & { endpoint?: { endpoint?: string } | string };
43
+ dw_url?: string;
44
+ };
45
+
46
+ function attachConnectionErrorContext(
47
+ err: unknown,
48
+ stage: "connect.open" | "connect.websocket",
49
+ endpoint?: string,
50
+ ): void {
51
+ if (!err || typeof err !== "object") {
52
+ return;
53
+ }
54
+ const target = err as Record<string, unknown>;
55
+ if (typeof target.dingtalkConnectionStage !== "string") {
56
+ target.dingtalkConnectionStage = stage;
57
+ }
58
+ if (endpoint && typeof target.dingtalkConnectionEndpoint !== "string") {
59
+ target.dingtalkConnectionEndpoint = endpoint;
60
+ }
61
+ }
62
+
63
+ function getInstrumentedEndpoint(client: InstrumentedDWClient): string | undefined {
64
+ if (typeof client.dw_url === "string" && client.dw_url.length > 0) {
65
+ return client.dw_url;
66
+ }
67
+
68
+ const endpointConfig = client.config?.endpoint;
69
+ if (typeof endpointConfig === "string") {
70
+ return endpointConfig;
71
+ }
72
+ if (
73
+ endpointConfig &&
74
+ typeof endpointConfig === "object" &&
75
+ typeof endpointConfig.endpoint === "string"
76
+ ) {
77
+ return endpointConfig.endpoint;
78
+ }
79
+ return undefined;
80
+ }
81
+
82
+ function instrumentConnectionStages(client: DWClient): void {
83
+ const instrumented = client as unknown as InstrumentedDWClient;
84
+ if (
85
+ typeof instrumented.getEndpoint !== "function" ||
86
+ typeof instrumented._connect !== "function"
87
+ ) {
88
+ return;
89
+ }
90
+
91
+ const originalGetEndpoint = instrumented.getEndpoint.bind(instrumented);
92
+ const originalSocketConnect = instrumented._connect.bind(instrumented);
93
+
94
+ instrumented.getEndpoint = async () => {
95
+ try {
96
+ return await originalGetEndpoint();
97
+ } catch (err) {
98
+ attachConnectionErrorContext(err, "connect.open");
99
+ throw err;
100
+ }
101
+ };
102
+
103
+ instrumented._connect = async () => {
104
+ try {
105
+ return await originalSocketConnect();
106
+ } catch (err) {
107
+ attachConnectionErrorContext(err, "connect.websocket", getInstrumentedEndpoint(instrumented));
108
+ throw err;
109
+ }
110
+ };
111
+ }
112
+
113
+ const INFLIGHT_TTL_MS = 5 * 60 * 1000;
114
+ const processingDedupKeys = new Map<string, number>();
115
+ export const CHANNEL_INFLIGHT_NAMESPACE_POLICY = "memory-only" as const;
116
+ const inboundCountersByAccount = new Map<
117
+ string,
118
+ {
119
+ received: number;
120
+ acked: number;
121
+ dedupSkipped: number;
122
+ inflightSkipped: number;
123
+ processed: number;
124
+ failed: number;
125
+ noMessageId: number;
126
+ }
127
+ >();
128
+ const INBOUND_COUNTER_LOG_EVERY = 10;
129
+
130
+ function getInboundCounters(accountId: string) {
131
+ const existing = inboundCountersByAccount.get(accountId);
132
+ if (existing) {
133
+ return existing;
134
+ }
135
+ const created = {
136
+ received: 0,
137
+ acked: 0,
138
+ dedupSkipped: 0,
139
+ inflightSkipped: 0,
140
+ processed: 0,
141
+ failed: 0,
142
+ noMessageId: 0,
143
+ };
144
+ inboundCountersByAccount.set(accountId, created);
145
+ return created;
146
+ }
147
+
148
+ function logInboundCounters(log: any, accountId: string, reason: string): void {
149
+ const stats = getInboundCounters(accountId);
150
+ log?.info?.(
151
+ `[${accountId}] Inbound counters (${reason}): received=${stats.received}, acked=${stats.acked}, processed=${stats.processed}, dedupSkipped=${stats.dedupSkipped}, inflightSkipped=${stats.inflightSkipped}, failed=${stats.failed}, noMessageId=${stats.noMessageId}`,
152
+ );
153
+ }
154
+
155
+ export function createDingTalkGateway(): NonNullable<DingTalkChannelPlugin["gateway"]> {
156
+ return {
157
+ startAccount: async (ctx: GatewayStartContext): Promise<GatewayStopResult> => {
158
+ const { account, cfg, abortSignal } = ctx;
159
+ const config = account.config;
160
+ if (!config.clientId || !config.clientSecret) {
161
+ throw new Error("DingTalk clientId and clientSecret are required");
162
+ }
163
+ let accountStorePath: string | undefined;
164
+ try {
165
+ const runtime = getDingTalkRuntime();
166
+ accountStorePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
167
+ agentId: account.accountId,
168
+ });
169
+ } catch {
170
+ accountStorePath = undefined;
171
+ }
172
+
173
+ const pluginLog = resolvePluginDebugLog({
174
+ accountId: account.accountId,
175
+ storePath: accountStorePath,
176
+ debug: config.debug,
177
+ baseLog: ctx.log,
178
+ });
179
+ setCurrentLogger(pluginLog, account.accountId);
180
+
181
+ pluginLog?.info?.(`[${account.accountId}] Initializing DingTalk Stream client...`);
182
+
183
+ preloadPeerIdsFromSessions();
184
+ pluginLog?.debug?.(`[${account.accountId}] Peer ID registry preloaded from sessions`);
185
+
186
+ cleanupOrphanedTempFiles(pluginLog);
187
+ try {
188
+ const recovered = await recoverPendingCardsForAccount(
189
+ config,
190
+ account.accountId,
191
+ accountStorePath,
192
+ pluginLog,
193
+ );
194
+ if (recovered > 0) {
195
+ pluginLog?.info?.(
196
+ `[${account.accountId}] Recovered and finalized ${recovered} unfinished card(s) from previous runtime`,
197
+ );
198
+ }
199
+ } catch (err: any) {
200
+ pluginLog?.warn?.(
201
+ `[${account.accountId}] Failed to recover unfinished cards: ${err.message}`,
202
+ );
203
+ }
204
+
205
+ const useConnectionManager = config.useConnectionManager ?? true;
206
+ const applyStatusPatch = (patch: Record<string, unknown>) => {
207
+ ctx.setStatus({
208
+ ...ctx.getStatus(),
209
+ ...patch,
210
+ });
211
+ };
212
+
213
+ const createStreamClient: StreamClientFactory = () => {
214
+ const client = new DWClient({
215
+ clientId: config.clientId,
216
+ clientSecret: config.clientSecret,
217
+ debug: config.debug || false,
218
+ keepAlive: config.keepAlive ?? !useConnectionManager,
219
+ });
220
+ (client as any).sslopts = {
221
+ ...(client as any).sslopts,
222
+ lookup: createResolve4FallbackLookup(pluginLog, account.accountId),
223
+ };
224
+
225
+ instrumentConnectionStages(client);
226
+
227
+ (client as any).config.autoReconnect = !useConnectionManager;
228
+
229
+ client.registerCallbackListener(TOPIC_ROBOT, async (res: any) => {
230
+ const messageId = res.headers?.messageId;
231
+ const stats = getInboundCounters(account.accountId);
232
+ stats.received += 1;
233
+ const acknowledge = () => {
234
+ if (!messageId) {
235
+ return;
236
+ }
237
+ try {
238
+ client.socketCallBackResponse(messageId, { success: true });
239
+ stats.acked += 1;
240
+ } catch (ackError: any) {
241
+ pluginLog?.warn?.(
242
+ `[${account.accountId}] Failed to acknowledge callback ${messageId}: ${ackError.message}`,
243
+ );
244
+ }
245
+ };
246
+ try {
247
+ const data = JSON.parse(res.data) as DingTalkInboundMessage;
248
+ applyStatusPatch({
249
+ connected: true,
250
+ lastInboundAt: getCurrentTimestamp(),
251
+ lastEventAt: getCurrentTimestamp(),
252
+ });
253
+
254
+ const robotKey = resolveRobotCode(config) || account.accountId;
255
+ const msgId = data.msgId || messageId;
256
+ const dedupKey = msgId ? `${robotKey}:${msgId}` : undefined;
257
+
258
+ if (!dedupKey) {
259
+ ctx.log?.warn?.(`[${account.accountId}] No message ID available for deduplication`);
260
+ stats.noMessageId += 1;
261
+ acknowledge();
262
+ await handleDingTalkMessage({
263
+ cfg,
264
+ accountId: account.accountId,
265
+ data,
266
+ sessionWebhook: data.sessionWebhook,
267
+ log: pluginLog,
268
+ dingtalkConfig: config,
269
+ });
270
+ stats.processed += 1;
271
+ if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
272
+ logInboundCounters(pluginLog, account.accountId, "periodic");
273
+ }
274
+ return;
275
+ }
276
+
277
+ if (isMessageProcessed(dedupKey)) {
278
+ pluginLog?.debug?.(`[${account.accountId}] Skipping duplicate message: ${dedupKey}`);
279
+ stats.dedupSkipped += 1;
280
+ acknowledge();
281
+ logInboundCounters(pluginLog, account.accountId, "dedup-skipped");
282
+ return;
283
+ }
284
+
285
+ const inflightSince = processingDedupKeys.get(dedupKey);
286
+ if (inflightSince !== undefined) {
287
+ if (Date.now() - inflightSince > INFLIGHT_TTL_MS) {
288
+ pluginLog?.warn?.(
289
+ `[${account.accountId}] Releasing stale in-flight lock for ${dedupKey} (held ${Date.now() - inflightSince}ms > TTL ${INFLIGHT_TTL_MS}ms)`,
290
+ );
291
+ processingDedupKeys.delete(dedupKey);
292
+ } else {
293
+ pluginLog?.debug?.(
294
+ `[${account.accountId}] Skipping in-flight duplicate message: ${dedupKey}`,
295
+ );
296
+ stats.inflightSkipped += 1;
297
+ acknowledge();
298
+ logInboundCounters(pluginLog, account.accountId, "inflight-skipped");
299
+ return;
300
+ }
301
+ }
302
+
303
+ acknowledge();
304
+ processingDedupKeys.set(dedupKey, Date.now());
305
+ try {
306
+ await handleDingTalkMessage({
307
+ cfg,
308
+ accountId: account.accountId,
309
+ data,
310
+ sessionWebhook: data.sessionWebhook,
311
+ log: pluginLog,
312
+ dingtalkConfig: config,
313
+ });
314
+ stats.processed += 1;
315
+ markMessageProcessed(dedupKey);
316
+ if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
317
+ logInboundCounters(pluginLog, account.accountId, "periodic");
318
+ }
319
+ } finally {
320
+ processingDedupKeys.delete(dedupKey);
321
+ }
322
+ } catch (error: any) {
323
+ stats.failed += 1;
324
+ logInboundCounters(pluginLog, account.accountId, "failed");
325
+ pluginLog?.error?.(`[${account.accountId}] Error processing message: ${error.message}`);
326
+ }
327
+ });
328
+
329
+ client.registerCallbackListener(TOPIC_CARD, async (res: any) => {
330
+ const messageId = res.headers?.messageId;
331
+ const acknowledge = () => {
332
+ if (!messageId) {
333
+ return;
334
+ }
335
+ try {
336
+ client.socketCallBackResponse(messageId, { success: true });
337
+ } catch (ackError: any) {
338
+ pluginLog?.warn?.(
339
+ `[${account.accountId}] Failed to acknowledge card callback ${messageId}: ${ackError.message}`,
340
+ );
341
+ }
342
+ };
343
+
344
+ try {
345
+ const payload = JSON.parse(res.data);
346
+ const analysis = analyzeCardCallback(payload);
347
+ pluginLog?.info?.(
348
+ `[${account.accountId}] [DingTalk][CardCallback] action=${analysis.summary} raw=${JSON.stringify(payload)}`,
349
+ );
350
+
351
+ if (analysis.feedbackTarget && analysis.feedbackAckText) {
352
+ recordExplicitFeedbackLearning({
353
+ enabled: isLearningEnabled(config),
354
+ autoApply: isLearningAutoApplyEnabled(config),
355
+ storePath: accountStorePath,
356
+ accountId: account.accountId,
357
+ targetId: analysis.feedbackTarget,
358
+ feedbackType: analysis.actionId === "feedback_up" ? "feedback_up" : "feedback_down",
359
+ userId: analysis.userId,
360
+ processQueryKey: analysis.processQueryKey,
361
+ noteTtlMs: config.learningNoteTtlMs,
362
+ });
363
+ try {
364
+ await sendProactiveTextOrMarkdown(
365
+ config,
366
+ analysis.feedbackTarget,
367
+ analysis.feedbackAckText,
368
+ {
369
+ accountId: account.accountId,
370
+ log: pluginLog,
371
+ },
372
+ );
373
+ pluginLog?.info?.(
374
+ `[${account.accountId}] [DingTalk][CardCallback] feedback ack sent to ${analysis.feedbackTarget}`,
375
+ );
376
+ } catch (sendErr: any) {
377
+ pluginLog?.warn?.(
378
+ `[${account.accountId}] [DingTalk][CardCallback] Failed to send feedback ack: ${sendErr?.message || String(sendErr)}`,
379
+ );
380
+ }
381
+ }
382
+ const actionResult = await handleCardAction({
383
+ analysis,
384
+ cfg,
385
+ accountId: account.accountId,
386
+ config,
387
+ log: pluginLog,
388
+ });
389
+ if (
390
+ !actionResult.handled &&
391
+ analysis.actionId &&
392
+ analysis.actionId !== "feedback_up" &&
393
+ analysis.actionId !== "feedback_down"
394
+ ) {
395
+ pluginLog?.debug?.(
396
+ `[${account.accountId}] [DingTalk][CardCallback] Unhandled actionId=${analysis.actionId}`,
397
+ );
398
+ }
399
+ } catch (error: any) {
400
+ pluginLog?.error?.(
401
+ `[${account.accountId}] [DingTalk][CardCallback] Failed to parse callback: ${error.message}`,
402
+ );
403
+ } finally {
404
+ acknowledge();
405
+ }
406
+ });
407
+
408
+ return client;
409
+ };
410
+
411
+ const client = createStreamClient();
412
+
413
+ let stopped = false;
414
+ let nativeStopResolve: (() => void) | undefined;
415
+ const nativeStopPromise = new Promise<void>((resolve) => {
416
+ nativeStopResolve = resolve;
417
+ });
418
+ let connectionManager: ConnectionManager | undefined;
419
+
420
+ const stopClient = () => {
421
+ if (stopped) {
422
+ return;
423
+ }
424
+ stopped = true;
425
+ pluginLog?.info?.(`[${account.accountId}] Stopping DingTalk Stream client...`);
426
+ void finalizeActiveCardsForAccount(
427
+ config,
428
+ account.accountId,
429
+ "⚠️ 服务正在重启,当前回复已中断。请重新发送你的问题。",
430
+ accountStorePath,
431
+ pluginLog,
432
+ ).catch((err: any) => {
433
+ pluginLog?.debug?.(
434
+ `[${account.accountId}] Failed to finalize active cards during stop: ${err.message}`,
435
+ );
436
+ });
437
+ if (useConnectionManager) {
438
+ connectionManager?.stop();
439
+ } else {
440
+ try {
441
+ client.disconnect();
442
+ } catch (err: any) {
443
+ pluginLog?.warn?.(`[${account.accountId}] Error during disconnect: ${err.message}`);
444
+ }
445
+ nativeStopResolve?.();
446
+ }
447
+
448
+ applyStatusPatch({
449
+ running: false,
450
+ connected: false,
451
+ lastEventAt: getCurrentTimestamp(),
452
+ lastStopAt: getCurrentTimestamp(),
453
+ });
454
+
455
+ pluginLog?.info?.(`[${account.accountId}] DingTalk Stream client stopped`);
456
+ closePluginDebugLog({
457
+ accountId: account.accountId,
458
+ storePath: accountStorePath,
459
+ });
460
+ };
461
+
462
+ if (abortSignal) {
463
+ if (abortSignal.aborted) {
464
+ pluginLog?.warn?.(
465
+ `[${account.accountId}] Abort signal already active, skipping connection`,
466
+ );
467
+
468
+ applyStatusPatch({
469
+ running: false,
470
+ connected: false,
471
+ lastEventAt: getCurrentTimestamp(),
472
+ lastStopAt: getCurrentTimestamp(),
473
+ lastError: "Connection aborted before start",
474
+ });
475
+
476
+ throw new Error("Connection aborted before start");
477
+ }
478
+
479
+ abortSignal.addEventListener("abort", () => {
480
+ if (stopped) {
481
+ return;
482
+ }
483
+ pluginLog?.info?.(
484
+ `[${account.accountId}] Abort signal received, stopping DingTalk Stream client...`,
485
+ );
486
+ stopClient();
487
+ });
488
+ }
489
+
490
+ if (!useConnectionManager) {
491
+ try {
492
+ await client.connect();
493
+ if (!stopped) {
494
+ applyStatusPatch({
495
+ running: true,
496
+ connected: true,
497
+ lastConnectedAt: getCurrentTimestamp(),
498
+ lastEventAt: getCurrentTimestamp(),
499
+ lastStartAt: getCurrentTimestamp(),
500
+ lastError: null,
501
+ });
502
+ pluginLog?.info?.(`[${account.accountId}] DingTalk Stream client connected successfully`);
503
+ await nativeStopPromise;
504
+ }
505
+ } catch (err: any) {
506
+ pluginLog?.error?.(
507
+ formatDingTalkConnectionErrorLog(
508
+ "connect.open",
509
+ err,
510
+ `[${account.accountId}] Failed to establish connection: ${err.message}`,
511
+ ) ?? `[${account.accountId}] Failed to establish connection: ${err.message}`,
512
+ );
513
+ applyStatusPatch({
514
+ running: false,
515
+ connected: false,
516
+ lastEventAt: getCurrentTimestamp(),
517
+ lastError: err.message || "Connection failed",
518
+ });
519
+ throw err;
520
+ }
521
+
522
+ return {
523
+ stop: () => {
524
+ stopClient();
525
+ },
526
+ };
527
+ }
528
+
529
+ const connectionConfig: ConnectionManagerConfig = {
530
+ maxAttempts: config.maxConnectionAttempts ?? 10,
531
+ initialDelay: config.initialReconnectDelay ?? 1000,
532
+ maxDelay: config.maxReconnectDelay ?? 60000,
533
+ jitter: config.reconnectJitter ?? 0.3,
534
+ maxReconnectCycles: config.maxReconnectCycles,
535
+ reconnectDeadlineMs: config.reconnectDeadlineMs,
536
+ onStateChange: (state: ConnectionState, error?: string) => {
537
+ if (stopped) {
538
+ return;
539
+ }
540
+ pluginLog?.debug?.(
541
+ `[${account.accountId}] Connection state changed to: ${state}${error ? ` (${error})` : ""}`,
542
+ );
543
+ if (state === ConnectionState.CONNECTED) {
544
+ applyStatusPatch({
545
+ running: true,
546
+ connected: true,
547
+ lastConnectedAt: getCurrentTimestamp(),
548
+ lastEventAt: getCurrentTimestamp(),
549
+ lastStartAt: getCurrentTimestamp(),
550
+ lastError: null,
551
+ });
552
+ } else if (state === ConnectionState.FAILED || state === ConnectionState.DISCONNECTED) {
553
+ const robotKey = resolveRobotCode(config) || account.accountId;
554
+ let cleared = 0;
555
+ for (const key of processingDedupKeys.keys()) {
556
+ if (key.startsWith(`${robotKey}:`)) {
557
+ processingDedupKeys.delete(key);
558
+ cleared++;
559
+ }
560
+ }
561
+ if (cleared > 0) {
562
+ pluginLog?.info?.(
563
+ `[${account.accountId}] Cleared ${cleared} stale in-flight lock(s) on disconnect`,
564
+ );
565
+ }
566
+ applyStatusPatch({
567
+ running: false,
568
+ connected: false,
569
+ lastEventAt: getCurrentTimestamp(),
570
+ lastError: error || `Connection ${state.toLowerCase()}`,
571
+ });
572
+ }
573
+ },
574
+ };
575
+
576
+ pluginLog?.debug?.(
577
+ `[${account.accountId}] Connection config: maxAttempts=${connectionConfig.maxAttempts}, ` +
578
+ `initialDelay=${connectionConfig.initialDelay}ms, maxDelay=${connectionConfig.maxDelay}ms, ` +
579
+ `jitter=${connectionConfig.jitter}`,
580
+ );
581
+
582
+ connectionManager = new ConnectionManager(
583
+ client,
584
+ account.accountId,
585
+ connectionConfig,
586
+ pluginLog,
587
+ createStreamClient,
588
+ );
589
+
590
+ try {
591
+ await connectionManager.connect();
592
+
593
+ if (!stopped && connectionManager.isConnected()) {
594
+ applyStatusPatch({
595
+ running: true,
596
+ connected: true,
597
+ lastConnectedAt: getCurrentTimestamp(),
598
+ lastEventAt: getCurrentTimestamp(),
599
+ lastStartAt: getCurrentTimestamp(),
600
+ lastError: null,
601
+ });
602
+ pluginLog?.info?.(`[${account.accountId}] DingTalk Stream client connected successfully`);
603
+
604
+ await connectionManager.waitForStop();
605
+ } else {
606
+ pluginLog?.info?.(
607
+ `[${account.accountId}] DingTalk Stream client connect() completed but channel is ` +
608
+ `not running (stopped=${stopped}, connected=${connectionManager.isConnected()})`,
609
+ );
610
+ }
611
+ } catch (err: any) {
612
+ pluginLog?.error?.(
613
+ formatDingTalkConnectionErrorLog(
614
+ "connect.open",
615
+ err,
616
+ `[${account.accountId}] Failed to establish connection: ${err.message}`,
617
+ ) ?? `[${account.accountId}] Failed to establish connection: ${err.message}`,
618
+ );
619
+
620
+ applyStatusPatch({
621
+ running: false,
622
+ connected: false,
623
+ lastEventAt: getCurrentTimestamp(),
624
+ lastError: err.message || "Connection failed",
625
+ });
626
+ throw err;
627
+ }
628
+
629
+ return {
630
+ stop: () => {
631
+ stopClient();
632
+ },
633
+ };
634
+ },
635
+ };
636
+ }