@soimy/dingtalk 3.2.0 → 3.4.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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +796 -42
  3. package/index.ts +62 -0
  4. package/package.json +4 -2
  5. package/src/access-control.ts +83 -0
  6. package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
  7. package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
  8. package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
  9. package/src/ack-reaction-classifier.ts +75 -0
  10. package/src/ack-reaction-service.ts +182 -0
  11. package/src/attachment-text-extractor.ts +148 -0
  12. package/src/card-callback-service.ts +119 -0
  13. package/src/card-draft-controller.ts +114 -0
  14. package/src/card-service.ts +666 -26
  15. package/src/channel.ts +455 -150
  16. package/src/config-schema.ts +64 -6
  17. package/src/config.ts +161 -5
  18. package/src/connection-manager.ts +354 -47
  19. package/src/dedup.ts +1 -0
  20. package/src/docs-service.ts +198 -0
  21. package/src/draft-stream-loop.ts +119 -0
  22. package/src/feedback-learning-service.ts +643 -0
  23. package/src/feedback-learning-store.ts +543 -0
  24. package/src/group-members-store.ts +48 -14
  25. package/src/inbound-handler.ts +1374 -259
  26. package/src/learning-command-service.ts +339 -0
  27. package/src/media-utils.ts +94 -50
  28. package/src/message-context-store.ts +787 -0
  29. package/src/message-utils.ts +487 -46
  30. package/src/messaging/quoted-context.ts +269 -0
  31. package/src/messaging/quoted-ref.ts +97 -0
  32. package/src/onboarding.ts +96 -1
  33. package/src/peer-id-registry.ts +102 -0
  34. package/src/persistence-store.ts +131 -0
  35. package/src/quoted-file-service.ts +385 -0
  36. package/src/reply-strategy-card.ts +225 -0
  37. package/src/reply-strategy-markdown.ts +55 -0
  38. package/src/reply-strategy-with-reaction.ts +190 -0
  39. package/src/reply-strategy.ts +72 -0
  40. package/src/send-service.ts +267 -45
  41. package/src/session-command-service.ts +147 -0
  42. package/src/session-lock.ts +2 -0
  43. package/src/session-peer-store.ts +77 -0
  44. package/src/session-routing.ts +33 -0
  45. package/src/targeting/agent-name-matcher.ts +148 -0
  46. package/src/targeting/agent-routing.ts +181 -0
  47. package/src/targeting/target-directory-adapter.ts +151 -0
  48. package/src/targeting/target-directory-store.ts +396 -0
  49. package/src/targeting/target-input.ts +62 -0
  50. package/src/types.ts +261 -28
  51. package/src/utils.ts +231 -12
package/src/channel.ts CHANGED
@@ -1,22 +1,51 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { DWClient, TOPIC_ROBOT } from "dingtalk-stream";
3
- import type {
4
- ChannelMessageActionAdapter,
5
- OpenClawConfig,
6
- } from "openclaw/plugin-sdk";
2
+ import { DWClient, TOPIC_CARD, TOPIC_ROBOT } from "dingtalk-stream";
3
+ import type { ChannelMessageActionAdapter, OpenClawConfig } from "openclaw/plugin-sdk";
7
4
  import * as pluginSdk from "openclaw/plugin-sdk";
8
5
  import { getAccessToken } from "./auth";
9
- import { createAICard, streamAICard, finishAICard } from "./card-service";
10
- import { getConfig, isConfigured, resolveRelativePath, stripTargetPrefix } from "./config";
6
+ import { analyzeCardCallback } from "./card-callback-service";
7
+ import {
8
+ createAICard,
9
+ streamAICard,
10
+ finishAICard,
11
+ finalizeActiveCardsForAccount,
12
+ recoverPendingCardsForAccount,
13
+ } from "./card-service";
14
+ import {
15
+ getConfig,
16
+ isConfigured,
17
+ mergeAccountWithDefaults,
18
+ resolveGroupConfig,
19
+ resolveRelativePath,
20
+ stripTargetPrefix,
21
+ } from "./config";
11
22
  import { DingTalkConfigSchema } from "./config-schema.js";
12
23
  import { ConnectionManager } from "./connection-manager";
13
24
  import { isMessageProcessed, markMessageProcessed } from "./dedup";
25
+ import {
26
+ isFeedbackLearningAutoApplyEnabled,
27
+ isFeedbackLearningEnabled,
28
+ recordExplicitFeedbackLearning,
29
+ } from "./feedback-learning-service";
14
30
  import { handleDingTalkMessage } from "./inbound-handler";
15
31
  import { getLogger } from "./logger-context";
16
32
  import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
17
33
  import { dingtalkOnboardingAdapter } from "./onboarding.js";
18
- import { resolveOriginalPeerId } from "./peer-id-registry";
19
- import { sendMessage, sendProactiveMedia, sendBySession, uploadMedia } from "./send-service";
34
+ import { resolveOriginalPeerId, preloadPeerIdsFromSessions } from "./peer-id-registry";
35
+ import { getDingTalkRuntime } from "./runtime";
36
+ import {
37
+ sendMessage,
38
+ sendProactiveMedia,
39
+ sendProactiveTextOrMarkdown,
40
+ sendBySession,
41
+ uploadMedia,
42
+ } from "./send-service";
43
+ import {
44
+ listDingTalkDirectoryGroups,
45
+ listDingTalkDirectoryUsers,
46
+ normalizeResolvedDingTalkTarget,
47
+ } from "./targeting/target-directory-adapter";
48
+ import { looksLikeDingTalkTargetId, normalizeDingTalkTarget } from "./targeting/target-input";
20
49
  import type {
21
50
  DingTalkInboundMessage,
22
51
  GatewayStartContext,
@@ -24,12 +53,94 @@ import type {
24
53
  ConnectionManagerConfig,
25
54
  DingTalkChannelPlugin,
26
55
  ResolvedAccount,
56
+ StreamClientFactory,
27
57
  } from "./types";
28
58
  import { ConnectionState } from "./types";
29
- import { cleanupOrphanedTempFiles, formatDingTalkErrorPayloadLog, getCurrentTimestamp } from "./utils";
59
+ import {
60
+ cleanupOrphanedTempFiles,
61
+ createResolve4FallbackLookup,
62
+ formatDingTalkConnectionErrorLog,
63
+ formatDingTalkErrorPayloadLog,
64
+ getCurrentTimestamp,
65
+ } from "./utils";
66
+
67
+ type InstrumentedDWClient = {
68
+ getEndpoint?: () => Promise<unknown>;
69
+ _connect?: () => Promise<unknown>;
70
+ config?: Record<string, unknown> & { endpoint?: { endpoint?: string } | string };
71
+ dw_url?: string;
72
+ };
73
+
74
+ function attachConnectionErrorContext(
75
+ err: unknown,
76
+ stage: "connect.open" | "connect.websocket",
77
+ endpoint?: string,
78
+ ): void {
79
+ if (!err || typeof err !== "object") {
80
+ return;
81
+ }
82
+ const target = err as Record<string, unknown>;
83
+ if (typeof target.dingtalkConnectionStage !== "string") {
84
+ target.dingtalkConnectionStage = stage;
85
+ }
86
+ if (endpoint && typeof target.dingtalkConnectionEndpoint !== "string") {
87
+ target.dingtalkConnectionEndpoint = endpoint;
88
+ }
89
+ }
90
+
91
+ function getInstrumentedEndpoint(client: InstrumentedDWClient): string | undefined {
92
+ if (typeof client.dw_url === "string" && client.dw_url.length > 0) {
93
+ return client.dw_url;
94
+ }
95
+
96
+ const endpointConfig = client.config?.endpoint;
97
+ if (typeof endpointConfig === "string") {
98
+ return endpointConfig;
99
+ }
100
+ if (
101
+ endpointConfig &&
102
+ typeof endpointConfig === "object" &&
103
+ typeof endpointConfig.endpoint === "string"
104
+ ) {
105
+ return endpointConfig.endpoint;
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ function instrumentConnectionStages(client: DWClient): void {
111
+ const instrumented = client as unknown as InstrumentedDWClient;
112
+ if (
113
+ typeof instrumented.getEndpoint !== "function" ||
114
+ typeof instrumented._connect !== "function"
115
+ ) {
116
+ return;
117
+ }
118
+
119
+ const originalGetEndpoint = instrumented.getEndpoint.bind(instrumented);
120
+ const originalSocketConnect = instrumented._connect.bind(instrumented);
121
+
122
+ instrumented.getEndpoint = async () => {
123
+ try {
124
+ return await originalGetEndpoint();
125
+ } catch (err) {
126
+ attachConnectionErrorContext(err, "connect.open");
127
+ throw err;
128
+ }
129
+ };
130
+
131
+ instrumented._connect = async () => {
132
+ try {
133
+ return await originalSocketConnect();
134
+ } catch (err) {
135
+ attachConnectionErrorContext(err, "connect.websocket", getInstrumentedEndpoint(instrumented));
136
+ throw err;
137
+ }
138
+ };
139
+ }
30
140
 
31
141
  const INFLIGHT_TTL_MS = 5 * 60 * 1000; // 5 min safety net for hung handlers
32
142
  const processingDedupKeys = new Map<string, number>(); // key → timestamp when acquired
143
+ export const CHANNEL_INFLIGHT_NAMESPACE_POLICY = "memory-only" as const;
33
144
  const inboundCountersByAccount = new Map<
34
145
  string,
35
146
  {
@@ -142,28 +253,36 @@ const dingtalkMessageActions: ChannelMessageActionAdapter = {
142
253
  const config = getConfig(cfg, accountId ?? undefined);
143
254
 
144
255
  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
- });
256
+ let preparedMedia;
257
+ try {
258
+ preparedMedia = await prepareMediaInput(mediaInput, log, config.mediaUrlAllowlist);
259
+ const mediaPath = preparedMedia.cleanup
260
+ ? preparedMedia.path
261
+ : resolveRelativePath(preparedMedia.path);
262
+ const mediaType = resolveOutboundMediaType({
263
+ mediaType: requestedMediaType ?? undefined,
264
+ mediaPath,
265
+ asVoice,
266
+ });
267
+ const result = await sendProactiveMedia(config, target, mediaPath, mediaType, {
268
+ log,
269
+ accountId: accountId ?? undefined,
270
+ });
155
271
 
156
- if (!result.ok) {
157
- throw new Error(result.error || "send media failed");
158
- }
272
+ if (!result.ok) {
273
+ throw new Error(result.error || "send media failed");
274
+ }
159
275
 
160
- return pluginSdk.jsonResult({
161
- ok: true,
162
- to: target,
163
- mediaType,
164
- messageId: result.messageId ?? null,
165
- result: result.data ?? null,
166
- });
276
+ return pluginSdk.jsonResult({
277
+ ok: true,
278
+ to: target,
279
+ mediaType,
280
+ messageId: result.messageId ?? null,
281
+ result: result.data ?? null,
282
+ });
283
+ } finally {
284
+ await preparedMedia?.cleanup?.();
285
+ }
167
286
  }
168
287
 
169
288
  if (asVoice) {
@@ -231,7 +350,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
231
350
  const config = getConfig(cfg);
232
351
  const id = accountId || "default";
233
352
  const account = config.accounts?.[id];
234
- const resolvedConfig = account || config;
353
+ const resolvedConfig = account ? mergeAccountWithDefaults(config, account) : config;
235
354
  const configured = Boolean(resolvedConfig.clientId && resolvedConfig.clientSecret);
236
355
  return {
237
356
  accountId: id,
@@ -262,7 +381,16 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
262
381
  }),
263
382
  },
264
383
  groups: {
265
- resolveRequireMention: ({ cfg }: any): boolean => getConfig(cfg).groupPolicy !== "open",
384
+ resolveRequireMention: ({ cfg, groupId }: any): boolean => {
385
+ const config = getConfig(cfg);
386
+ if (groupId) {
387
+ const groupCfg = resolveGroupConfig(config, groupId);
388
+ if (groupCfg?.requireMention !== undefined) {
389
+ return groupCfg.requireMention;
390
+ }
391
+ }
392
+ return config.groupPolicy !== "open";
393
+ },
266
394
  resolveGroupIntroHint: ({ groupId, groupChannel }: any): string | undefined => {
267
395
  const parts = [`conversationId=${groupId}`];
268
396
  if (groupChannel) {
@@ -272,12 +400,20 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
272
400
  },
273
401
  },
274
402
  messaging: {
275
- normalizeTarget: (raw: string) => (raw ? raw.replace(/^(dingtalk|dd|ding):/i, "") : undefined),
403
+ normalizeTarget: (raw: string) => (raw ? normalizeDingTalkTarget(raw) : undefined),
276
404
  targetResolver: {
277
- looksLikeId: (id: string): boolean => /^[\w+\-/=]+$/.test(id),
278
- hint: "<conversationId>",
405
+ looksLikeId: (raw: string, normalized?: string): boolean =>
406
+ looksLikeDingTalkTargetId(raw, normalized),
407
+ hint: "<displayName|conversationId|user:staffId|user:+861...>",
279
408
  },
280
409
  },
410
+ directory: {
411
+ self: async () => null,
412
+ listGroups: async (params) => listDingTalkDirectoryGroups(params),
413
+ listGroupsLive: async (params) => listDingTalkDirectoryGroups(params),
414
+ listPeers: async (params) => listDingTalkDirectoryUsers(params),
415
+ listPeersLive: async (params) => listDingTalkDirectoryUsers(params),
416
+ },
281
417
  actions: dingtalkMessageActions,
282
418
  outbound: {
283
419
  deliveryMode: "direct" as const,
@@ -289,26 +425,38 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
289
425
  error: new Error("DingTalk message requires --to <conversationId>"),
290
426
  };
291
427
  }
292
- const { targetId } = stripTargetPrefix(trimmed);
293
- const resolved = resolveOriginalPeerId(targetId);
294
- return { ok: true as const, to: resolved };
428
+ return { ok: true as const, to: normalizeResolvedDingTalkTarget(trimmed) };
295
429
  },
296
430
  sendText: async ({ cfg, to, text, accountId, log }: any) => {
297
431
  const config = getConfig(cfg, accountId);
432
+ const rt = getDingTalkRuntime();
433
+ const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
434
+ agentId: accountId,
435
+ });
298
436
  try {
299
- const result = await sendMessage(config, to, text, { log, accountId });
437
+ const result = await sendMessage(config, to, text, {
438
+ log,
439
+ accountId,
440
+ storePath,
441
+ conversationId: to,
442
+ });
300
443
  getLogger()?.debug?.(`[DingTalk] sendText: "${text}" result: ${JSON.stringify(result)}`);
301
444
  if (!result.ok) {
302
445
  throw new Error(result.error || "sendText failed");
303
446
  }
304
447
  const data = result.data as any;
305
448
  const messageId = String(data?.processQueryKey || data?.messageId || randomUUID());
449
+ const meta =
450
+ result.data || result.tracking
451
+ ? {
452
+ ...(result.data ? { data: result.data as unknown as Record<string, unknown> } : {}),
453
+ ...(result.tracking ? { tracking: result.tracking } : {}),
454
+ }
455
+ : undefined;
306
456
  return {
307
457
  channel: "dingtalk",
308
458
  messageId,
309
- meta: result.data
310
- ? { data: result.data as unknown as Record<string, unknown> }
311
- : undefined,
459
+ meta,
312
460
  };
313
461
  } catch (err: any) {
314
462
  if (err?.response?.data !== undefined) {
@@ -334,6 +482,10 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
334
482
  log,
335
483
  }: any) => {
336
484
  const config = getConfig(cfg, accountId);
485
+ const rt = getDingTalkRuntime();
486
+ const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
487
+ agentId: accountId,
488
+ });
337
489
  if (!config.clientId) {
338
490
  throw new Error("DingTalk not configured");
339
491
  }
@@ -362,12 +514,17 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
362
514
  preparedMedia = await prepareMediaInput(rawMediaPath, log, config.mediaUrlAllowlist);
363
515
  } catch (err: any) {
364
516
  if (err?.response?.data !== undefined) {
365
- log?.error?.(formatDingTalkErrorPayloadLog("outbound.sendMedia.prepare", err.response.data));
517
+ log?.error?.(
518
+ formatDingTalkErrorPayloadLog("outbound.sendMedia.prepare", err.response.data),
519
+ );
366
520
  }
367
521
  const errorCode = typeof err?.code === "string" ? `[${err.code}] ` : "";
368
- throw new Error(`remote media preparation failed: ${errorCode}${err?.message || "unknown error"}`, {
369
- cause: err,
370
- });
522
+ throw new Error(
523
+ `remote media preparation failed: ${errorCode}${err?.message || "unknown error"}`,
524
+ {
525
+ cause: err,
526
+ },
527
+ );
371
528
  }
372
529
 
373
530
  const actualMediaPath = preparedMedia.cleanup
@@ -388,10 +545,14 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
388
545
  result = await sendProactiveMedia(config, to, actualMediaPath, mediaType, {
389
546
  log,
390
547
  accountId,
548
+ storePath,
549
+ conversationId: to,
391
550
  });
392
551
  } catch (err: any) {
393
552
  if (err?.response?.data !== undefined) {
394
- log?.error?.(formatDingTalkErrorPayloadLog("outbound.sendMedia.send", err.response.data));
553
+ log?.error?.(
554
+ formatDingTalkErrorPayloadLog("outbound.sendMedia.send", err.response.data),
555
+ );
395
556
  }
396
557
  throw new Error(`proactive media send failed: ${err?.message || "unknown error"}`, {
397
558
  cause: err,
@@ -439,118 +600,228 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
439
600
  if (!config.clientId || !config.clientSecret) {
440
601
  throw new Error("DingTalk clientId and clientSecret are required");
441
602
  }
603
+ let accountStorePath: string | undefined;
604
+ try {
605
+ const rt = getDingTalkRuntime();
606
+ accountStorePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
607
+ agentId: account.accountId,
608
+ });
609
+ } catch {
610
+ accountStorePath = undefined;
611
+ }
442
612
 
443
613
  ctx.log?.info?.(`[${account.accountId}] Initializing DingTalk Stream client...`);
444
614
 
615
+ // Preload known peer IDs from sessions so outbound delivery (e.g. cron
616
+ // jobs that fire immediately after startup) can resolve the original
617
+ // case-sensitive conversationId before any inbound message has arrived.
618
+ preloadPeerIdsFromSessions();
619
+ ctx.log?.debug?.(`[${account.accountId}] Peer ID registry preloaded from sessions`);
620
+
445
621
  cleanupOrphanedTempFiles(ctx.log);
622
+ try {
623
+ const recovered = await recoverPendingCardsForAccount(
624
+ config,
625
+ account.accountId,
626
+ accountStorePath,
627
+ ctx.log,
628
+ );
629
+ if (recovered > 0) {
630
+ ctx.log?.info?.(
631
+ `[${account.accountId}] Recovered and finalized ${recovered} unfinished card(s) from previous runtime`,
632
+ );
633
+ }
634
+ } catch (err: any) {
635
+ ctx.log?.warn?.(
636
+ `[${account.accountId}] Failed to recover unfinished cards: ${err.message}`,
637
+ );
638
+ }
446
639
 
447
640
  const useConnectionManager = config.useConnectionManager ?? true;
448
641
 
449
- const client = new DWClient({
450
- clientId: config.clientId,
451
- clientSecret: config.clientSecret,
452
- debug: config.debug || false,
453
- keepAlive: !useConnectionManager,
454
- });
642
+ // Factory that creates a fresh DWClient with the TOPIC_ROBOT callback
643
+ // already registered. Each client captures its own reference for
644
+ // socketCallBackResponse so acks are sent on the correct socket.
645
+ // ConnectionManager uses this to create new clients during warm
646
+ // reconnection, minimizing the message-loss window when the DingTalk
647
+ // server initiates a disconnect for load balancing.
648
+ const createStreamClient: StreamClientFactory = () => {
649
+ const c = new DWClient({
650
+ clientId: config.clientId,
651
+ clientSecret: config.clientSecret,
652
+ debug: config.debug || false,
653
+ keepAlive: config.keepAlive ?? !useConnectionManager,
654
+ });
655
+ (c as any).sslopts = {
656
+ ...(c as any).sslopts,
657
+ lookup: createResolve4FallbackLookup(ctx.log, account.accountId),
658
+ };
455
659
 
456
- (client as any).config.autoReconnect = !useConnectionManager;
660
+ instrumentConnectionStages(c);
457
661
 
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
- }
662
+ (c as any).config.autoReconnect = !useConnectionManager;
663
+
664
+ c.registerCallbackListener(TOPIC_ROBOT, async (res: any) => {
665
+ const messageId = res.headers?.messageId;
666
+ const stats = getInboundCounters(account.accountId);
667
+ stats.received += 1;
668
+ const acknowledge = () => {
669
+ if (!messageId) {
670
+ return;
671
+ }
672
+ try {
673
+ c.socketCallBackResponse(messageId, { success: true });
674
+ stats.acked += 1;
675
+ } catch (ackError: any) {
676
+ ctx.log?.warn?.(
677
+ `[${account.accountId}] Failed to acknowledge callback ${messageId}: ${ackError.message}`,
678
+ );
679
+ }
680
+ };
466
681
  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");
682
+ const data = JSON.parse(res.data) as DingTalkInboundMessage;
683
+
684
+ const robotKey = config.robotCode || config.clientId || account.accountId;
685
+ const msgId = data.msgId || messageId;
686
+ const dedupKey = msgId ? `${robotKey}:${msgId}` : undefined;
687
+
688
+ if (!dedupKey) {
689
+ ctx.log?.warn?.(`[${account.accountId}] No message ID available for deduplication`);
690
+ stats.noMessageId += 1;
691
+ acknowledge();
692
+ await handleDingTalkMessage({
693
+ cfg,
694
+ accountId: account.accountId,
695
+ data,
696
+ sessionWebhook: data.sessionWebhook,
697
+ log: ctx.log,
698
+ dingtalkConfig: config,
699
+ });
700
+ stats.processed += 1;
701
+ if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
702
+ logInboundCounters(ctx.log, account.accountId, "periodic");
703
+ }
704
+ return;
705
+ }
706
+
707
+ if (isMessageProcessed(dedupKey)) {
708
+ ctx.log?.debug?.(`[${account.accountId}] Skipping duplicate message: ${dedupKey}`);
709
+ stats.dedupSkipped += 1;
710
+ acknowledge();
711
+ logInboundCounters(ctx.log, account.accountId, "dedup-skipped");
712
+ return;
713
+ }
714
+
715
+ const inflightSince = processingDedupKeys.get(dedupKey);
716
+ if (inflightSince !== undefined) {
717
+ if (Date.now() - inflightSince > INFLIGHT_TTL_MS) {
718
+ ctx.log?.warn?.(
719
+ `[${account.accountId}] Releasing stale in-flight lock for ${dedupKey} (held ${Date.now() - inflightSince}ms > TTL ${INFLIGHT_TTL_MS}ms)`,
720
+ );
721
+ processingDedupKeys.delete(dedupKey);
722
+ } else {
723
+ ctx.log?.debug?.(
724
+ `[${account.accountId}] Skipping in-flight duplicate message: ${dedupKey}`,
725
+ );
726
+ stats.inflightSkipped += 1;
727
+ acknowledge();
728
+ logInboundCounters(ctx.log, account.accountId, "inflight-skipped");
729
+ return;
730
+ }
498
731
  }
499
- return;
500
- }
501
732
 
502
- if (isMessageProcessed(dedupKey)) {
503
- ctx.log?.debug?.(`[${account.accountId}] Skipping duplicate message: ${dedupKey}`);
504
- stats.dedupSkipped += 1;
505
733
  acknowledge();
506
- logInboundCounters(ctx.log, account.accountId, "dedup-skipped");
507
- return;
734
+ processingDedupKeys.set(dedupKey, Date.now());
735
+ try {
736
+ await handleDingTalkMessage({
737
+ cfg,
738
+ accountId: account.accountId,
739
+ data,
740
+ sessionWebhook: data.sessionWebhook,
741
+ log: ctx.log,
742
+ dingtalkConfig: config,
743
+ });
744
+ stats.processed += 1;
745
+ markMessageProcessed(dedupKey);
746
+ if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
747
+ logInboundCounters(ctx.log, account.accountId, "periodic");
748
+ }
749
+ } finally {
750
+ processingDedupKeys.delete(dedupKey);
751
+ }
752
+ } catch (error: any) {
753
+ stats.failed += 1;
754
+ logInboundCounters(ctx.log, account.accountId, "failed");
755
+ ctx.log?.error?.(`[${account.accountId}] Error processing message: ${error.message}`);
508
756
  }
757
+ });
509
758
 
510
- const inflightSince = processingDedupKeys.get(dedupKey);
511
- if (inflightSince !== undefined) {
512
- if (Date.now() - inflightSince > INFLIGHT_TTL_MS) {
759
+ c.registerCallbackListener(TOPIC_CARD, async (res: any) => {
760
+ const messageId = res.headers?.messageId;
761
+ const acknowledge = () => {
762
+ if (!messageId) {
763
+ return;
764
+ }
765
+ try {
766
+ c.socketCallBackResponse(messageId, { success: true });
767
+ } catch (ackError: any) {
513
768
  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}`,
769
+ `[${account.accountId}] Failed to acknowledge card callback ${messageId}: ${ackError.message}`,
520
770
  );
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
771
  }
527
- }
772
+ };
528
773
 
529
- processingDedupKeys.set(dedupKey, Date.now());
530
774
  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");
775
+ const payload = JSON.parse(res.data);
776
+ const analysis = analyzeCardCallback(payload);
777
+ ctx.log?.info?.(
778
+ `[${account.accountId}] [DingTalk][CardCallback] action=${analysis.summary} raw=${JSON.stringify(payload)}`,
779
+ );
780
+
781
+ if (analysis.feedbackTarget && analysis.feedbackAckText) {
782
+ recordExplicitFeedbackLearning({
783
+ enabled: isFeedbackLearningEnabled(config),
784
+ autoApply: isFeedbackLearningAutoApplyEnabled(config),
785
+ storePath: accountStorePath,
786
+ accountId: account.accountId,
787
+ targetId: analysis.feedbackTarget,
788
+ feedbackType: analysis.actionId === "feedback_up" ? "feedback_up" : "feedback_down",
789
+ userId: analysis.userId,
790
+ processQueryKey: analysis.processQueryKey,
791
+ noteTtlMs: config.learningNoteTtlMs ?? config.feedbackLearningNoteTtlMs,
792
+ });
793
+ try {
794
+ await sendProactiveTextOrMarkdown(
795
+ config,
796
+ analysis.feedbackTarget,
797
+ analysis.feedbackAckText,
798
+ {
799
+ accountId: account.accountId,
800
+ log: ctx.log,
801
+ },
802
+ );
803
+ ctx.log?.info?.(
804
+ `[${account.accountId}] [DingTalk][CardCallback] feedback ack sent to ${analysis.feedbackTarget}`,
805
+ );
806
+ } catch (sendErr: any) {
807
+ ctx.log?.warn?.(
808
+ `[${account.accountId}] [DingTalk][CardCallback] Failed to send feedback ack: ${sendErr?.message || String(sendErr)}`,
809
+ );
810
+ }
544
811
  }
812
+ } catch (error: any) {
813
+ ctx.log?.error?.(
814
+ `[${account.accountId}] [DingTalk][CardCallback] Failed to parse callback: ${error.message}`,
815
+ );
545
816
  } finally {
546
- processingDedupKeys.delete(dedupKey);
817
+ acknowledge();
547
818
  }
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
- });
819
+ });
820
+
821
+ return c;
822
+ };
823
+
824
+ const client = createStreamClient();
554
825
 
555
826
  // Guard against duplicate stop paths (abort signal + explicit stop).
556
827
  let stopped = false;
@@ -566,6 +837,17 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
566
837
  }
567
838
  stopped = true;
568
839
  ctx.log?.info?.(`[${account.accountId}] Stopping DingTalk Stream client...`);
840
+ void finalizeActiveCardsForAccount(
841
+ config,
842
+ account.accountId,
843
+ "⚠️ 服务正在重启,当前回复已中断。请重新发送你的问题。",
844
+ accountStorePath,
845
+ ctx.log,
846
+ ).catch((err: any) => {
847
+ ctx.log?.debug?.(
848
+ `[${account.accountId}] Failed to finalize active cards during stop: ${err.message}`,
849
+ );
850
+ });
569
851
  if (useConnectionManager) {
570
852
  connectionManager?.stop();
571
853
  } else {
@@ -627,7 +909,14 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
627
909
  await nativeStopPromise;
628
910
  }
629
911
  } catch (err: any) {
630
- ctx.log?.error?.(`[${account.accountId}] Failed to establish connection: ${err.message}`);
912
+ ctx.log?.error?.(
913
+ formatDingTalkConnectionErrorLog(
914
+ // Use connect.open as base scope; instrumentation can override to connect.websocket
915
+ "connect.open",
916
+ err,
917
+ `[${account.accountId}] Failed to establish connection: ${err.message}`,
918
+ ) ?? `[${account.accountId}] Failed to establish connection: ${err.message}`,
919
+ );
631
920
  ctx.setStatus({
632
921
  ...ctx.getStatus(),
633
922
  running: false,
@@ -649,6 +938,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
649
938
  maxDelay: config.maxReconnectDelay ?? 60000,
650
939
  jitter: config.reconnectJitter ?? 0.3,
651
940
  maxReconnectCycles: config.maxReconnectCycles,
941
+ reconnectDeadlineMs: config.reconnectDeadlineMs,
652
942
  onStateChange: (state: ConnectionState, error?: string) => {
653
943
  if (stopped) {
654
944
  return;
@@ -700,6 +990,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
700
990
  account.accountId,
701
991
  connectionConfig,
702
992
  ctx.log,
993
+ createStreamClient,
703
994
  );
704
995
 
705
996
  try {
@@ -722,7 +1013,14 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
722
1013
  );
723
1014
  }
724
1015
  } catch (err: any) {
725
- ctx.log?.error?.(`[${account.accountId}] Failed to establish connection: ${err.message}`);
1016
+ ctx.log?.error?.(
1017
+ formatDingTalkConnectionErrorLog(
1018
+ // Use connect.open as base scope; instrumentation can override to connect.websocket
1019
+ "connect.open",
1020
+ err,
1021
+ `[${account.accountId}] Failed to establish connection: ${err.message}`,
1022
+ ) ?? `[${account.accountId}] Failed to establish connection: ${err.message}`,
1023
+ );
726
1024
 
727
1025
  ctx.setStatus({
728
1026
  ...ctx.getStatus(),
@@ -743,6 +1041,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
743
1041
  defaultRuntime: {
744
1042
  accountId: "default",
745
1043
  running: false,
1044
+ lastEventAt: null,
746
1045
  lastStartAt: null,
747
1046
  lastStopAt: null,
748
1047
  lastError: null,
@@ -788,18 +1087,24 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
788
1087
  return { ok: false, error: error.message };
789
1088
  }
790
1089
  },
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
- }),
1090
+ buildAccountSnapshot: ({ account, runtime, snapshot, probe }: any) => {
1091
+ const running = runtime?.running ?? snapshot?.running ?? false;
1092
+ const persistedLastEventAt = runtime?.lastEventAt ?? snapshot?.lastEventAt ?? null;
1093
+
1094
+ return {
1095
+ accountId: account.accountId,
1096
+ name: account.name,
1097
+ enabled: account.enabled,
1098
+ configured: account.configured,
1099
+ clientId: account.config?.clientId ?? null,
1100
+ running,
1101
+ lastEventAt: running ? getCurrentTimestamp() : persistedLastEventAt,
1102
+ lastStartAt: runtime?.lastStartAt ?? snapshot?.lastStartAt ?? null,
1103
+ lastStopAt: runtime?.lastStopAt ?? snapshot?.lastStopAt ?? null,
1104
+ lastError: runtime?.lastError ?? snapshot?.lastError ?? null,
1105
+ probe,
1106
+ };
1107
+ },
803
1108
  },
804
1109
  };
805
1110