@soimy/dingtalk 3.2.0 → 3.3.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.
package/src/channel.ts CHANGED
@@ -1,13 +1,26 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { DWClient, TOPIC_ROBOT } from "dingtalk-stream";
2
+ import { DWClient, TOPIC_CARD, TOPIC_ROBOT } from "dingtalk-stream";
3
3
  import type {
4
4
  ChannelMessageActionAdapter,
5
5
  OpenClawConfig,
6
6
  } from "openclaw/plugin-sdk";
7
7
  import * as pluginSdk from "openclaw/plugin-sdk";
8
8
  import { getAccessToken } from "./auth";
9
- import { createAICard, streamAICard, finishAICard } from "./card-service";
10
- import { getConfig, isConfigured, resolveRelativePath, stripTargetPrefix } from "./config";
9
+ import { analyzeCardCallback } from "./card-callback-service";
10
+ import {
11
+ createAICard,
12
+ streamAICard,
13
+ finishAICard,
14
+ finalizeActiveCardsForAccount,
15
+ recoverPendingCardsForAccount,
16
+ } from "./card-service";
17
+ import {
18
+ getConfig,
19
+ isConfigured,
20
+ mergeAccountWithDefaults,
21
+ resolveRelativePath,
22
+ stripTargetPrefix,
23
+ } from "./config";
11
24
  import { DingTalkConfigSchema } from "./config-schema.js";
12
25
  import { ConnectionManager } from "./connection-manager";
13
26
  import { isMessageProcessed, markMessageProcessed } from "./dedup";
@@ -15,8 +28,20 @@ import { handleDingTalkMessage } from "./inbound-handler";
15
28
  import { getLogger } from "./logger-context";
16
29
  import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
17
30
  import { dingtalkOnboardingAdapter } from "./onboarding.js";
18
- import { resolveOriginalPeerId } from "./peer-id-registry";
19
- import { sendMessage, sendProactiveMedia, sendBySession, uploadMedia } from "./send-service";
31
+ import { resolveOriginalPeerId, preloadPeerIdsFromSessions } from "./peer-id-registry";
32
+ import { getDingTalkRuntime } from "./runtime";
33
+ import {
34
+ isFeedbackLearningAutoApplyEnabled,
35
+ isFeedbackLearningEnabled,
36
+ recordExplicitFeedbackLearning,
37
+ } from "./feedback-learning-service";
38
+ import {
39
+ sendMessage,
40
+ sendProactiveMedia,
41
+ sendProactiveTextOrMarkdown,
42
+ sendBySession,
43
+ uploadMedia,
44
+ } from "./send-service";
20
45
  import type {
21
46
  DingTalkInboundMessage,
22
47
  GatewayStartContext,
@@ -24,12 +49,87 @@ import type {
24
49
  ConnectionManagerConfig,
25
50
  DingTalkChannelPlugin,
26
51
  ResolvedAccount,
52
+ StreamClientFactory,
27
53
  } from "./types";
28
54
  import { ConnectionState } from "./types";
29
- import { cleanupOrphanedTempFiles, formatDingTalkErrorPayloadLog, getCurrentTimestamp } from "./utils";
55
+ import {
56
+ cleanupOrphanedTempFiles,
57
+ createResolve4FallbackLookup,
58
+ formatDingTalkConnectionErrorLog,
59
+ formatDingTalkErrorPayloadLog,
60
+ getCurrentTimestamp,
61
+ } from "./utils";
62
+
63
+ type InstrumentedDWClient = {
64
+ getEndpoint?: () => Promise<unknown>;
65
+ _connect?: () => Promise<unknown>;
66
+ config?: Record<string, unknown> & { endpoint?: { endpoint?: string } | string };
67
+ dw_url?: string;
68
+ };
69
+
70
+ function attachConnectionErrorContext(
71
+ err: unknown,
72
+ stage: "connect.open" | "connect.websocket",
73
+ endpoint?: string,
74
+ ): void {
75
+ if (!err || typeof err !== "object") {
76
+ return;
77
+ }
78
+ const target = err as Record<string, unknown>;
79
+ if (typeof target.dingtalkConnectionStage !== "string") {
80
+ target.dingtalkConnectionStage = stage;
81
+ }
82
+ if (endpoint && typeof target.dingtalkConnectionEndpoint !== "string") {
83
+ target.dingtalkConnectionEndpoint = endpoint;
84
+ }
85
+ }
86
+
87
+ function getInstrumentedEndpoint(client: InstrumentedDWClient): string | undefined {
88
+ if (typeof client.dw_url === "string" && client.dw_url.length > 0) {
89
+ return client.dw_url;
90
+ }
91
+
92
+ const endpointConfig = client.config?.endpoint;
93
+ if (typeof endpointConfig === "string") {
94
+ return endpointConfig;
95
+ }
96
+ if (endpointConfig && typeof endpointConfig === "object" && typeof endpointConfig.endpoint === "string") {
97
+ return endpointConfig.endpoint;
98
+ }
99
+ return undefined;
100
+ }
101
+
102
+ function instrumentConnectionStages(client: DWClient): void {
103
+ const instrumented = client as unknown as InstrumentedDWClient;
104
+ if (typeof instrumented.getEndpoint !== "function" || typeof instrumented._connect !== "function") {
105
+ return;
106
+ }
107
+
108
+ const originalGetEndpoint = instrumented.getEndpoint.bind(instrumented);
109
+ const originalSocketConnect = instrumented._connect.bind(instrumented);
110
+
111
+ instrumented.getEndpoint = async () => {
112
+ try {
113
+ return await originalGetEndpoint();
114
+ } catch (err) {
115
+ attachConnectionErrorContext(err, "connect.open");
116
+ throw err;
117
+ }
118
+ };
119
+
120
+ instrumented._connect = async () => {
121
+ try {
122
+ return await originalSocketConnect();
123
+ } catch (err) {
124
+ attachConnectionErrorContext(err, "connect.websocket", getInstrumentedEndpoint(instrumented));
125
+ throw err;
126
+ }
127
+ };
128
+ }
30
129
 
31
130
  const INFLIGHT_TTL_MS = 5 * 60 * 1000; // 5 min safety net for hung handlers
32
131
  const processingDedupKeys = new Map<string, number>(); // key → timestamp when acquired
132
+ export const CHANNEL_INFLIGHT_NAMESPACE_POLICY = "memory-only" as const;
33
133
  const inboundCountersByAccount = new Map<
34
134
  string,
35
135
  {
@@ -142,28 +242,36 @@ const dingtalkMessageActions: ChannelMessageActionAdapter = {
142
242
  const config = getConfig(cfg, accountId ?? undefined);
143
243
 
144
244
  if (hasMedia && mediaInput) {
145
- const mediaPath = resolveRelativePath(mediaInput);
146
- const mediaType = resolveOutboundMediaType({
147
- mediaType: requestedMediaType ?? undefined,
148
- mediaPath,
149
- asVoice,
150
- });
151
- const result = await sendProactiveMedia(config, target, mediaPath, mediaType, {
152
- log,
153
- accountId: accountId ?? undefined,
154
- });
245
+ let preparedMedia;
246
+ try {
247
+ preparedMedia = await prepareMediaInput(mediaInput, log, config.mediaUrlAllowlist);
248
+ const mediaPath = preparedMedia.cleanup
249
+ ? preparedMedia.path
250
+ : resolveRelativePath(preparedMedia.path);
251
+ const mediaType = resolveOutboundMediaType({
252
+ mediaType: requestedMediaType ?? undefined,
253
+ mediaPath,
254
+ asVoice,
255
+ });
256
+ const result = await sendProactiveMedia(config, target, mediaPath, mediaType, {
257
+ log,
258
+ accountId: accountId ?? undefined,
259
+ });
155
260
 
156
- if (!result.ok) {
157
- throw new Error(result.error || "send media failed");
158
- }
261
+ if (!result.ok) {
262
+ throw new Error(result.error || "send media failed");
263
+ }
159
264
 
160
- return pluginSdk.jsonResult({
161
- ok: true,
162
- to: target,
163
- mediaType,
164
- messageId: result.messageId ?? null,
165
- result: result.data ?? null,
166
- });
265
+ return pluginSdk.jsonResult({
266
+ ok: true,
267
+ to: target,
268
+ mediaType,
269
+ messageId: result.messageId ?? null,
270
+ result: result.data ?? null,
271
+ });
272
+ } finally {
273
+ await preparedMedia?.cleanup?.();
274
+ }
167
275
  }
168
276
 
169
277
  if (asVoice) {
@@ -231,7 +339,9 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
231
339
  const config = getConfig(cfg);
232
340
  const id = accountId || "default";
233
341
  const account = config.accounts?.[id];
234
- const resolvedConfig = account || config;
342
+ const resolvedConfig = account
343
+ ? mergeAccountWithDefaults(config, account)
344
+ : config;
235
345
  const configured = Boolean(resolvedConfig.clientId && resolvedConfig.clientSecret);
236
346
  return {
237
347
  accountId: id,
@@ -295,20 +405,34 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
295
405
  },
296
406
  sendText: async ({ cfg, to, text, accountId, log }: any) => {
297
407
  const config = getConfig(cfg, accountId);
408
+ const rt = getDingTalkRuntime();
409
+ const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
410
+ agentId: accountId,
411
+ });
298
412
  try {
299
- const result = await sendMessage(config, to, text, { log, accountId });
413
+ const result = await sendMessage(config, to, text, {
414
+ log,
415
+ accountId,
416
+ storePath,
417
+ conversationId: to,
418
+ });
300
419
  getLogger()?.debug?.(`[DingTalk] sendText: "${text}" result: ${JSON.stringify(result)}`);
301
420
  if (!result.ok) {
302
421
  throw new Error(result.error || "sendText failed");
303
422
  }
304
423
  const data = result.data as any;
305
424
  const messageId = String(data?.processQueryKey || data?.messageId || randomUUID());
425
+ const meta =
426
+ result.data || result.tracking
427
+ ? {
428
+ ...(result.data ? { data: result.data as unknown as Record<string, unknown> } : {}),
429
+ ...(result.tracking ? { tracking: result.tracking } : {}),
430
+ }
431
+ : undefined;
306
432
  return {
307
433
  channel: "dingtalk",
308
434
  messageId,
309
- meta: result.data
310
- ? { data: result.data as unknown as Record<string, unknown> }
311
- : undefined,
435
+ meta,
312
436
  };
313
437
  } catch (err: any) {
314
438
  if (err?.response?.data !== undefined) {
@@ -334,6 +458,10 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
334
458
  log,
335
459
  }: any) => {
336
460
  const config = getConfig(cfg, accountId);
461
+ const rt = getDingTalkRuntime();
462
+ const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
463
+ agentId: accountId,
464
+ });
337
465
  if (!config.clientId) {
338
466
  throw new Error("DingTalk not configured");
339
467
  }
@@ -388,6 +516,8 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
388
516
  result = await sendProactiveMedia(config, to, actualMediaPath, mediaType, {
389
517
  log,
390
518
  accountId,
519
+ storePath,
520
+ conversationId: to,
391
521
  });
392
522
  } catch (err: any) {
393
523
  if (err?.response?.data !== undefined) {
@@ -439,118 +569,227 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
439
569
  if (!config.clientId || !config.clientSecret) {
440
570
  throw new Error("DingTalk clientId and clientSecret are required");
441
571
  }
572
+ let accountStorePath: string | undefined;
573
+ try {
574
+ const rt = getDingTalkRuntime();
575
+ accountStorePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
576
+ agentId: account.accountId,
577
+ });
578
+ } catch {
579
+ accountStorePath = undefined;
580
+ }
442
581
 
443
582
  ctx.log?.info?.(`[${account.accountId}] Initializing DingTalk Stream client...`);
444
583
 
584
+ // Preload known peer IDs from sessions so outbound delivery (e.g. cron
585
+ // jobs that fire immediately after startup) can resolve the original
586
+ // case-sensitive conversationId before any inbound message has arrived.
587
+ preloadPeerIdsFromSessions();
588
+ ctx.log?.debug?.(`[${account.accountId}] Peer ID registry preloaded from sessions`);
589
+
445
590
  cleanupOrphanedTempFiles(ctx.log);
591
+ try {
592
+ const recovered = await recoverPendingCardsForAccount(
593
+ config,
594
+ account.accountId,
595
+ accountStorePath,
596
+ ctx.log,
597
+ );
598
+ if (recovered > 0) {
599
+ ctx.log?.info?.(
600
+ `[${account.accountId}] Recovered and finalized ${recovered} unfinished card(s) from previous runtime`,
601
+ );
602
+ }
603
+ } catch (err: any) {
604
+ ctx.log?.warn?.(
605
+ `[${account.accountId}] Failed to recover unfinished cards: ${err.message}`,
606
+ );
607
+ }
446
608
 
447
609
  const useConnectionManager = config.useConnectionManager ?? true;
448
610
 
449
- const client = new DWClient({
450
- clientId: config.clientId,
451
- clientSecret: config.clientSecret,
452
- debug: config.debug || false,
453
- keepAlive: !useConnectionManager,
454
- });
611
+ // Factory that creates a fresh DWClient with the TOPIC_ROBOT callback
612
+ // already registered. Each client captures its own reference for
613
+ // socketCallBackResponse so acks are sent on the correct socket.
614
+ // ConnectionManager uses this to create new clients during warm
615
+ // reconnection, minimizing the message-loss window when the DingTalk
616
+ // server initiates a disconnect for load balancing.
617
+ const createStreamClient: StreamClientFactory = () => {
618
+ const c = new DWClient({
619
+ clientId: config.clientId,
620
+ clientSecret: config.clientSecret,
621
+ debug: config.debug || false,
622
+ keepAlive: config.keepAlive ?? !useConnectionManager,
623
+ });
624
+ (c as any).sslopts = {
625
+ ...(c as any).sslopts,
626
+ lookup: createResolve4FallbackLookup(ctx.log, account.accountId),
627
+ };
455
628
 
456
- (client as any).config.autoReconnect = !useConnectionManager;
629
+ instrumentConnectionStages(c);
457
630
 
458
- client.registerCallbackListener(TOPIC_ROBOT, async (res: any) => {
459
- const messageId = res.headers?.messageId;
460
- const stats = getInboundCounters(account.accountId);
461
- stats.received += 1;
462
- const acknowledge = () => {
463
- if (!messageId) {
464
- return;
465
- }
631
+ (c as any).config.autoReconnect = !useConnectionManager;
632
+
633
+ c.registerCallbackListener(TOPIC_ROBOT, async (res: any) => {
634
+ const messageId = res.headers?.messageId;
635
+ const stats = getInboundCounters(account.accountId);
636
+ stats.received += 1;
637
+ const acknowledge = () => {
638
+ if (!messageId) {
639
+ return;
640
+ }
641
+ try {
642
+ c.socketCallBackResponse(messageId, { success: true });
643
+ stats.acked += 1;
644
+ } catch (ackError: any) {
645
+ ctx.log?.warn?.(
646
+ `[${account.accountId}] Failed to acknowledge callback ${messageId}: ${ackError.message}`,
647
+ );
648
+ }
649
+ };
466
650
  try {
467
- client.socketCallBackResponse(messageId, { success: true });
468
- stats.acked += 1;
469
- } catch (ackError: any) {
470
- ctx.log?.warn?.(
471
- `[${account.accountId}] Failed to acknowledge callback ${messageId}: ${ackError.message}`,
472
- );
473
- }
474
- };
475
- try {
476
- const data = JSON.parse(res.data) as DingTalkInboundMessage;
477
-
478
- // Message deduplication key is bot-scoped to avoid cross-account conflicts.
479
- const robotKey = config.robotCode || config.clientId || account.accountId;
480
- const msgId = data.msgId || messageId;
481
- const dedupKey = msgId ? `${robotKey}:${msgId}` : undefined;
482
-
483
- if (!dedupKey) {
484
- ctx.log?.warn?.(`[${account.accountId}] No message ID available for deduplication`);
485
- stats.noMessageId += 1;
486
- await handleDingTalkMessage({
487
- cfg,
488
- accountId: account.accountId,
489
- data,
490
- sessionWebhook: data.sessionWebhook,
491
- log: ctx.log,
492
- dingtalkConfig: config,
493
- });
494
- stats.processed += 1;
495
- acknowledge();
496
- if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
497
- logInboundCounters(ctx.log, account.accountId, "periodic");
651
+ const data = JSON.parse(res.data) as DingTalkInboundMessage;
652
+
653
+ const robotKey = config.robotCode || config.clientId || account.accountId;
654
+ const msgId = data.msgId || messageId;
655
+ const dedupKey = msgId ? `${robotKey}:${msgId}` : undefined;
656
+
657
+ if (!dedupKey) {
658
+ ctx.log?.warn?.(`[${account.accountId}] No message ID available for deduplication`);
659
+ stats.noMessageId += 1;
660
+ await handleDingTalkMessage({
661
+ cfg,
662
+ accountId: account.accountId,
663
+ data,
664
+ sessionWebhook: data.sessionWebhook,
665
+ log: ctx.log,
666
+ dingtalkConfig: config,
667
+ });
668
+ stats.processed += 1;
669
+ acknowledge();
670
+ if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
671
+ logInboundCounters(ctx.log, account.accountId, "periodic");
672
+ }
673
+ return;
498
674
  }
499
- return;
500
- }
501
675
 
502
- if (isMessageProcessed(dedupKey)) {
503
- ctx.log?.debug?.(`[${account.accountId}] Skipping duplicate message: ${dedupKey}`);
504
- stats.dedupSkipped += 1;
505
- acknowledge();
506
- logInboundCounters(ctx.log, account.accountId, "dedup-skipped");
507
- return;
676
+ if (isMessageProcessed(dedupKey)) {
677
+ ctx.log?.debug?.(`[${account.accountId}] Skipping duplicate message: ${dedupKey}`);
678
+ stats.dedupSkipped += 1;
679
+ acknowledge();
680
+ logInboundCounters(ctx.log, account.accountId, "dedup-skipped");
681
+ return;
682
+ }
683
+
684
+ const inflightSince = processingDedupKeys.get(dedupKey);
685
+ if (inflightSince !== undefined) {
686
+ if (Date.now() - inflightSince > INFLIGHT_TTL_MS) {
687
+ ctx.log?.warn?.(
688
+ `[${account.accountId}] Releasing stale in-flight lock for ${dedupKey} (held ${Date.now() - inflightSince}ms > TTL ${INFLIGHT_TTL_MS}ms)`,
689
+ );
690
+ processingDedupKeys.delete(dedupKey);
691
+ } else {
692
+ ctx.log?.debug?.(
693
+ `[${account.accountId}] Skipping in-flight duplicate message: ${dedupKey}`,
694
+ );
695
+ stats.inflightSkipped += 1;
696
+ logInboundCounters(ctx.log, account.accountId, "inflight-skipped");
697
+ return;
698
+ }
699
+ }
700
+
701
+ processingDedupKeys.set(dedupKey, Date.now());
702
+ try {
703
+ await handleDingTalkMessage({
704
+ cfg,
705
+ accountId: account.accountId,
706
+ data,
707
+ sessionWebhook: data.sessionWebhook,
708
+ log: ctx.log,
709
+ dingtalkConfig: config,
710
+ });
711
+ stats.processed += 1;
712
+ markMessageProcessed(dedupKey);
713
+ acknowledge();
714
+ if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
715
+ logInboundCounters(ctx.log, account.accountId, "periodic");
716
+ }
717
+ } finally {
718
+ processingDedupKeys.delete(dedupKey);
719
+ }
720
+ } catch (error: any) {
721
+ stats.failed += 1;
722
+ logInboundCounters(ctx.log, account.accountId, "failed");
723
+ ctx.log?.error?.(`[${account.accountId}] Error processing message: ${error.message}`);
508
724
  }
725
+ });
509
726
 
510
- const inflightSince = processingDedupKeys.get(dedupKey);
511
- if (inflightSince !== undefined) {
512
- if (Date.now() - inflightSince > INFLIGHT_TTL_MS) {
727
+ c.registerCallbackListener(TOPIC_CARD, async (res: any) => {
728
+ const messageId = res.headers?.messageId;
729
+ const acknowledge = () => {
730
+ if (!messageId) {
731
+ return;
732
+ }
733
+ try {
734
+ c.socketCallBackResponse(messageId, { success: true });
735
+ } catch (ackError: any) {
513
736
  ctx.log?.warn?.(
514
- `[${account.accountId}] Releasing stale in-flight lock for ${dedupKey} (held ${Date.now() - inflightSince}ms > TTL ${INFLIGHT_TTL_MS}ms)`,
515
- );
516
- processingDedupKeys.delete(dedupKey);
517
- } else {
518
- ctx.log?.debug?.(
519
- `[${account.accountId}] Skipping in-flight duplicate message: ${dedupKey}`,
737
+ `[${account.accountId}] Failed to acknowledge card callback ${messageId}: ${ackError.message}`,
520
738
  );
521
- stats.inflightSkipped += 1;
522
- // Do not acknowledge in-flight duplicates before the original handler succeeds.
523
- // If the original later fails, early-acking the duplicate can suppress server redelivery.
524
- logInboundCounters(ctx.log, account.accountId, "inflight-skipped");
525
- return;
526
739
  }
527
- }
740
+ };
528
741
 
529
- processingDedupKeys.set(dedupKey, Date.now());
530
742
  try {
531
- await handleDingTalkMessage({
532
- cfg,
533
- accountId: account.accountId,
534
- data,
535
- sessionWebhook: data.sessionWebhook,
536
- log: ctx.log,
537
- dingtalkConfig: config,
538
- });
539
- stats.processed += 1;
540
- markMessageProcessed(dedupKey);
541
- acknowledge();
542
- if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
543
- logInboundCounters(ctx.log, account.accountId, "periodic");
743
+ const payload = JSON.parse(res.data);
744
+ const analysis = analyzeCardCallback(payload);
745
+ ctx.log?.info?.(
746
+ `[${account.accountId}] [DingTalk][CardCallback] action=${analysis.summary} raw=${JSON.stringify(payload)}`,
747
+ );
748
+
749
+ if (analysis.feedbackTarget && analysis.feedbackAckText) {
750
+ recordExplicitFeedbackLearning({
751
+ enabled: isFeedbackLearningEnabled(config),
752
+ autoApply: isFeedbackLearningAutoApplyEnabled(config),
753
+ storePath: accountStorePath,
754
+ accountId: account.accountId,
755
+ targetId: analysis.feedbackTarget,
756
+ feedbackType: analysis.actionId === "feedback_up" ? "feedback_up" : "feedback_down",
757
+ userId: analysis.userId,
758
+ processQueryKey: analysis.processQueryKey,
759
+ noteTtlMs: config.learningNoteTtlMs ?? config.feedbackLearningNoteTtlMs,
760
+ });
761
+ try {
762
+ await sendProactiveTextOrMarkdown(
763
+ config,
764
+ analysis.feedbackTarget,
765
+ analysis.feedbackAckText,
766
+ {
767
+ accountId: account.accountId,
768
+ log: ctx.log,
769
+ },
770
+ );
771
+ ctx.log?.info?.(
772
+ `[${account.accountId}] [DingTalk][CardCallback] feedback ack sent to ${analysis.feedbackTarget}`,
773
+ );
774
+ } catch (sendErr: any) {
775
+ ctx.log?.warn?.(
776
+ `[${account.accountId}] [DingTalk][CardCallback] Failed to send feedback ack: ${sendErr?.message || String(sendErr)}`,
777
+ );
778
+ }
544
779
  }
780
+ } catch (error: any) {
781
+ ctx.log?.error?.(
782
+ `[${account.accountId}] [DingTalk][CardCallback] Failed to parse callback: ${error.message}`,
783
+ );
545
784
  } finally {
546
- processingDedupKeys.delete(dedupKey);
785
+ acknowledge();
547
786
  }
548
- } catch (error: any) {
549
- stats.failed += 1;
550
- logInboundCounters(ctx.log, account.accountId, "failed");
551
- ctx.log?.error?.(`[${account.accountId}] Error processing message: ${error.message}`);
552
- }
553
- });
787
+ });
788
+
789
+ return c;
790
+ };
791
+
792
+ const client = createStreamClient();
554
793
 
555
794
  // Guard against duplicate stop paths (abort signal + explicit stop).
556
795
  let stopped = false;
@@ -566,6 +805,17 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
566
805
  }
567
806
  stopped = true;
568
807
  ctx.log?.info?.(`[${account.accountId}] Stopping DingTalk Stream client...`);
808
+ void finalizeActiveCardsForAccount(
809
+ config,
810
+ account.accountId,
811
+ "⚠️ 服务正在重启,当前回复已中断。请重新发送你的问题。",
812
+ accountStorePath,
813
+ ctx.log,
814
+ ).catch((err: any) => {
815
+ ctx.log?.debug?.(
816
+ `[${account.accountId}] Failed to finalize active cards during stop: ${err.message}`,
817
+ );
818
+ });
569
819
  if (useConnectionManager) {
570
820
  connectionManager?.stop();
571
821
  } else {
@@ -627,7 +877,14 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
627
877
  await nativeStopPromise;
628
878
  }
629
879
  } catch (err: any) {
630
- ctx.log?.error?.(`[${account.accountId}] Failed to establish connection: ${err.message}`);
880
+ ctx.log?.error?.(
881
+ formatDingTalkConnectionErrorLog(
882
+ // Use connect.open as base scope; instrumentation can override to connect.websocket
883
+ "connect.open",
884
+ err,
885
+ `[${account.accountId}] Failed to establish connection: ${err.message}`,
886
+ ) ?? `[${account.accountId}] Failed to establish connection: ${err.message}`,
887
+ );
631
888
  ctx.setStatus({
632
889
  ...ctx.getStatus(),
633
890
  running: false,
@@ -649,6 +906,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
649
906
  maxDelay: config.maxReconnectDelay ?? 60000,
650
907
  jitter: config.reconnectJitter ?? 0.3,
651
908
  maxReconnectCycles: config.maxReconnectCycles,
909
+ reconnectDeadlineMs: config.reconnectDeadlineMs,
652
910
  onStateChange: (state: ConnectionState, error?: string) => {
653
911
  if (stopped) {
654
912
  return;
@@ -700,6 +958,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
700
958
  account.accountId,
701
959
  connectionConfig,
702
960
  ctx.log,
961
+ createStreamClient,
703
962
  );
704
963
 
705
964
  try {
@@ -722,7 +981,14 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
722
981
  );
723
982
  }
724
983
  } catch (err: any) {
725
- ctx.log?.error?.(`[${account.accountId}] Failed to establish connection: ${err.message}`);
984
+ ctx.log?.error?.(
985
+ formatDingTalkConnectionErrorLog(
986
+ // Use connect.open as base scope; instrumentation can override to connect.websocket
987
+ "connect.open",
988
+ err,
989
+ `[${account.accountId}] Failed to establish connection: ${err.message}`,
990
+ ) ?? `[${account.accountId}] Failed to establish connection: ${err.message}`,
991
+ );
726
992
 
727
993
  ctx.setStatus({
728
994
  ...ctx.getStatus(),
@@ -743,6 +1009,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
743
1009
  defaultRuntime: {
744
1010
  accountId: "default",
745
1011
  running: false,
1012
+ lastEventAt: null,
746
1013
  lastStartAt: null,
747
1014
  lastStopAt: null,
748
1015
  lastError: null,
@@ -788,18 +1055,24 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
788
1055
  return { ok: false, error: error.message };
789
1056
  }
790
1057
  },
791
- buildAccountSnapshot: ({ account, runtime, snapshot, probe }: any) => ({
792
- accountId: account.accountId,
793
- name: account.name,
794
- enabled: account.enabled,
795
- configured: account.configured,
796
- clientId: account.config?.clientId ?? null,
797
- running: runtime?.running ?? snapshot?.running ?? false,
798
- lastStartAt: runtime?.lastStartAt ?? snapshot?.lastStartAt ?? null,
799
- lastStopAt: runtime?.lastStopAt ?? snapshot?.lastStopAt ?? null,
800
- lastError: runtime?.lastError ?? snapshot?.lastError ?? null,
801
- probe,
802
- }),
1058
+ buildAccountSnapshot: ({ account, runtime, snapshot, probe }: any) => {
1059
+ const running = runtime?.running ?? snapshot?.running ?? false;
1060
+ const persistedLastEventAt = runtime?.lastEventAt ?? snapshot?.lastEventAt ?? null;
1061
+
1062
+ return {
1063
+ accountId: account.accountId,
1064
+ name: account.name,
1065
+ enabled: account.enabled,
1066
+ configured: account.configured,
1067
+ clientId: account.config?.clientId ?? null,
1068
+ running,
1069
+ lastEventAt: running ? getCurrentTimestamp() : persistedLastEventAt,
1070
+ lastStartAt: runtime?.lastStartAt ?? snapshot?.lastStartAt ?? null,
1071
+ lastStopAt: runtime?.lastStopAt ?? snapshot?.lastStopAt ?? null,
1072
+ lastError: runtime?.lastError ?? snapshot?.lastError ?? null,
1073
+ probe,
1074
+ };
1075
+ },
803
1076
  },
804
1077
  };
805
1078