@soimy/dingtalk 3.1.4 → 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,21 +1,44 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { DWClient, TOPIC_ROBOT } from "dingtalk-stream";
3
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
4
- import { buildChannelConfigSchema } from "openclaw/plugin-sdk";
2
+ import { DWClient, TOPIC_CARD, TOPIC_ROBOT } from "dingtalk-stream";
3
+ import type {
4
+ ChannelMessageActionAdapter,
5
+ OpenClawConfig,
6
+ } from "openclaw/plugin-sdk";
7
+ import * as pluginSdk from "openclaw/plugin-sdk";
5
8
  import { getAccessToken } from "./auth";
6
- import { createAICard, streamAICard, finishAICard } from "./card-service";
7
- 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";
8
24
  import { DingTalkConfigSchema } from "./config-schema.js";
9
25
  import { ConnectionManager } from "./connection-manager";
10
26
  import { isMessageProcessed, markMessageProcessed } from "./dedup";
11
27
  import { handleDingTalkMessage } from "./inbound-handler";
12
28
  import { getLogger } from "./logger-context";
29
+ import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
13
30
  import { dingtalkOnboardingAdapter } from "./onboarding.js";
14
- import { resolveOriginalPeerId } from "./peer-id-registry";
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";
15
38
  import {
16
- detectMediaTypeFromExtension,
17
39
  sendMessage,
18
40
  sendProactiveMedia,
41
+ sendProactiveTextOrMarkdown,
19
42
  sendBySession,
20
43
  uploadMedia,
21
44
  } from "./send-service";
@@ -26,11 +49,87 @@ import type {
26
49
  ConnectionManagerConfig,
27
50
  DingTalkChannelPlugin,
28
51
  ResolvedAccount,
52
+ StreamClientFactory,
29
53
  } from "./types";
30
54
  import { ConnectionState } from "./types";
31
- 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
+ }
32
91
 
33
- const processingDedupKeys = new Set<string>();
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
+ }
129
+
130
+ const INFLIGHT_TTL_MS = 5 * 60 * 1000; // 5 min safety net for hung handlers
131
+ const processingDedupKeys = new Map<string, number>(); // key → timestamp when acquired
132
+ export const CHANNEL_INFLIGHT_NAMESPACE_POLICY = "memory-only" as const;
34
133
  const inboundCountersByAccount = new Map<
35
134
  string,
36
135
  {
@@ -70,6 +169,140 @@ function logInboundCounters(log: any, accountId: string, reason: string): void {
70
169
  );
71
170
  }
72
171
 
172
+ function readBooleanLikeParam(params: Record<string, unknown>, key: string): boolean | undefined {
173
+ const value = params[key];
174
+ if (typeof value === "boolean") {
175
+ return value;
176
+ }
177
+ if (typeof value === "number") {
178
+ if (value === 1) {
179
+ return true;
180
+ }
181
+ if (value === 0) {
182
+ return false;
183
+ }
184
+ return undefined;
185
+ }
186
+ if (typeof value === "string") {
187
+ const normalized = value.trim().toLowerCase();
188
+ if (["1", "true", "yes", "y", "on"].includes(normalized)) {
189
+ return true;
190
+ }
191
+ if (["0", "false", "no", "n", "off"].includes(normalized)) {
192
+ return false;
193
+ }
194
+ }
195
+ return undefined;
196
+ }
197
+
198
+ const dingtalkMessageActions: ChannelMessageActionAdapter = {
199
+ listActions: () => ["send"],
200
+ supportsAction: ({ action }) => action === "send",
201
+ extractToolSend: ({ args }) => pluginSdk.extractToolSend(args, "sendMessage"),
202
+ handleAction: async ({ action, params, cfg, accountId, dryRun }) => {
203
+ if (action !== "send") {
204
+ throw new Error(`Action ${action} is not supported for provider dingtalk.`);
205
+ }
206
+
207
+ const to = pluginSdk.readStringParam(params, "to", { required: true });
208
+ const mediaInput =
209
+ pluginSdk.readStringParam(params, "media", { trim: false }) ??
210
+ pluginSdk.readStringParam(params, "path", { trim: false }) ??
211
+ pluginSdk.readStringParam(params, "filePath", { trim: false }) ??
212
+ pluginSdk.readStringParam(params, "mediaUrl", { trim: false });
213
+
214
+ const hasMedia = Boolean(mediaInput && mediaInput.trim());
215
+ const caption = pluginSdk.readStringParam(params, "caption", { allowEmpty: true }) ?? "";
216
+ let message =
217
+ pluginSdk.readStringParam(params, "message", {
218
+ required: !hasMedia,
219
+ allowEmpty: true,
220
+ }) ?? "";
221
+
222
+ if (!message.trim() && caption.trim()) {
223
+ message = caption;
224
+ }
225
+
226
+ const asVoice = readBooleanLikeParam(params, "asVoice") === true;
227
+ const requestedMediaType = pluginSdk.readStringParam(params, "mediaType");
228
+
229
+ const target = resolveOriginalPeerId(stripTargetPrefix(to).targetId);
230
+
231
+ if (dryRun) {
232
+ return pluginSdk.jsonResult({
233
+ ok: true,
234
+ dryRun: true,
235
+ to: target,
236
+ hasMedia,
237
+ asVoice,
238
+ });
239
+ }
240
+
241
+ const log = getLogger();
242
+ const config = getConfig(cfg, accountId ?? undefined);
243
+
244
+ if (hasMedia && mediaInput) {
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
+ });
260
+
261
+ if (!result.ok) {
262
+ throw new Error(result.error || "send media failed");
263
+ }
264
+
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
+ }
275
+ }
276
+
277
+ if (asVoice) {
278
+ throw new Error(
279
+ "DingTalk send with asVoice requires media/path/filePath/mediaUrl pointing to an audio file.",
280
+ );
281
+ }
282
+
283
+ if (!message.trim()) {
284
+ throw new Error("send requires message when media is not provided");
285
+ }
286
+
287
+ const result = await sendMessage(config, target, message, {
288
+ log,
289
+ accountId: accountId ?? undefined,
290
+ });
291
+
292
+ if (!result.ok) {
293
+ throw new Error(result.error || "send message failed");
294
+ }
295
+
296
+ const data = result.data as any;
297
+ return pluginSdk.jsonResult({
298
+ ok: true,
299
+ to: target,
300
+ messageId: data?.processQueryKey || data?.messageId || null,
301
+ result: data ?? null,
302
+ });
303
+ },
304
+ };
305
+
73
306
  // DingTalk Channel Definition (assembly layer).
74
307
  // Heavy logic is delegated to service modules for maintainability.
75
308
  export const dingtalkPlugin: DingTalkChannelPlugin = {
@@ -82,7 +315,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
82
315
  blurb: "钉钉企业内部机器人,使用 Stream 模式,无需公网 IP。",
83
316
  aliases: ["dd", "ding"],
84
317
  },
85
- configSchema: buildChannelConfigSchema(DingTalkConfigSchema),
318
+ configSchema: pluginSdk.buildChannelConfigSchema(DingTalkConfigSchema),
86
319
  onboarding: dingtalkOnboardingAdapter,
87
320
  capabilities: {
88
321
  chatTypes: ["direct", "group"] as Array<"direct" | "group">,
@@ -106,7 +339,9 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
106
339
  const config = getConfig(cfg);
107
340
  const id = accountId || "default";
108
341
  const account = config.accounts?.[id];
109
- const resolvedConfig = account || config;
342
+ const resolvedConfig = account
343
+ ? mergeAccountWithDefaults(config, account)
344
+ : config;
110
345
  const configured = Boolean(resolvedConfig.clientId && resolvedConfig.clientSecret);
111
346
  return {
112
347
  accountId: id,
@@ -153,6 +388,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
153
388
  hint: "<conversationId>",
154
389
  },
155
390
  },
391
+ actions: dingtalkMessageActions,
156
392
  outbound: {
157
393
  deliveryMode: "direct" as const,
158
394
  resolveTarget: ({ to }: any) => {
@@ -169,23 +405,35 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
169
405
  },
170
406
  sendText: async ({ cfg, to, text, accountId, log }: any) => {
171
407
  const config = getConfig(cfg, accountId);
408
+ const rt = getDingTalkRuntime();
409
+ const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
410
+ agentId: accountId,
411
+ });
172
412
  try {
173
- 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
+ });
174
419
  getLogger()?.debug?.(`[DingTalk] sendText: "${text}" result: ${JSON.stringify(result)}`);
175
- if (result.ok) {
176
- const data = result.data as any;
177
- const messageId = String(data?.processQueryKey || data?.messageId || randomUUID());
178
- return {
179
- channel: "dingtalk",
180
- messageId,
181
- meta: result.data
182
- ? { data: result.data as unknown as Record<string, unknown> }
183
- : undefined,
184
- };
420
+ if (!result.ok) {
421
+ throw new Error(result.error || "sendText failed");
185
422
  }
186
- throw new Error(
187
- typeof result.error === "string" ? result.error : JSON.stringify(result.error),
188
- );
423
+ const data = result.data as any;
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;
432
+ return {
433
+ channel: "dingtalk",
434
+ messageId,
435
+ meta,
436
+ };
189
437
  } catch (err: any) {
190
438
  if (err?.response?.data !== undefined) {
191
439
  log?.error?.(formatDingTalkErrorPayloadLog("outbound.sendText", err.response.data));
@@ -205,10 +453,15 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
205
453
  filePath,
206
454
  mediaUrl,
207
455
  mediaType: providedMediaType,
456
+ asVoice,
208
457
  accountId,
209
458
  log,
210
459
  }: any) => {
211
460
  const config = getConfig(cfg, accountId);
461
+ const rt = getDingTalkRuntime();
462
+ const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
463
+ agentId: accountId,
464
+ });
212
465
  if (!config.clientId) {
213
466
  throw new Error("DingTalk not configured");
214
467
  }
@@ -231,18 +484,49 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
231
484
  );
232
485
  }
233
486
 
234
- const actualMediaPath = resolveRelativePath(rawMediaPath);
487
+ let preparedMedia;
488
+ try {
489
+ try {
490
+ preparedMedia = await prepareMediaInput(rawMediaPath, log, config.mediaUrlAllowlist);
491
+ } catch (err: any) {
492
+ if (err?.response?.data !== undefined) {
493
+ log?.error?.(formatDingTalkErrorPayloadLog("outbound.sendMedia.prepare", err.response.data));
494
+ }
495
+ const errorCode = typeof err?.code === "string" ? `[${err.code}] ` : "";
496
+ throw new Error(`remote media preparation failed: ${errorCode}${err?.message || "unknown error"}`, {
497
+ cause: err,
498
+ });
499
+ }
500
+
501
+ const actualMediaPath = preparedMedia.cleanup
502
+ ? preparedMedia.path
503
+ : resolveRelativePath(preparedMedia.path);
235
504
 
236
- getLogger()?.debug?.(
237
- `[DingTalk] sendMedia resolved path: rawMediaPath=${rawMediaPath}, actualMediaPath=${actualMediaPath}`,
238
- );
505
+ getLogger()?.debug?.(
506
+ `[DingTalk] sendMedia resolved path: rawMediaPath=${rawMediaPath}, actualMediaPath=${actualMediaPath}`,
507
+ );
239
508
 
240
- try {
241
- const mediaType = providedMediaType || detectMediaTypeFromExtension(actualMediaPath);
242
- const result = await sendProactiveMedia(config, to, actualMediaPath, mediaType, {
243
- log,
244
- accountId,
509
+ const mediaType = resolveOutboundMediaType({
510
+ mediaType: typeof providedMediaType === "string" ? providedMediaType : undefined,
511
+ mediaPath: actualMediaPath,
512
+ asVoice: asVoice === true,
245
513
  });
514
+ let result;
515
+ try {
516
+ result = await sendProactiveMedia(config, to, actualMediaPath, mediaType, {
517
+ log,
518
+ accountId,
519
+ storePath,
520
+ conversationId: to,
521
+ });
522
+ } catch (err: any) {
523
+ if (err?.response?.data !== undefined) {
524
+ log?.error?.(formatDingTalkErrorPayloadLog("outbound.sendMedia.send", err.response.data));
525
+ }
526
+ throw new Error(`proactive media send failed: ${err?.message || "unknown error"}`, {
527
+ cause: err,
528
+ });
529
+ }
246
530
  getLogger()?.debug?.(
247
531
  `[DingTalk] sendMedia: ${mediaType} file=${actualMediaPath} result: ${JSON.stringify(result)}`,
248
532
  );
@@ -273,6 +557,8 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
273
557
  : err?.message || "sendMedia failed",
274
558
  { cause: err },
275
559
  );
560
+ } finally {
561
+ await preparedMedia?.cleanup?.();
276
562
  }
277
563
  },
278
564
  },
@@ -283,110 +569,336 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
283
569
  if (!config.clientId || !config.clientSecret) {
284
570
  throw new Error("DingTalk clientId and clientSecret are required");
285
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
+ }
286
581
 
287
582
  ctx.log?.info?.(`[${account.accountId}] Initializing DingTalk Stream client...`);
288
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
+
289
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
+ }
290
608
 
291
- const client = new DWClient({
292
- clientId: config.clientId,
293
- clientSecret: config.clientSecret,
294
- debug: config.debug || false,
295
- keepAlive: false,
296
- });
609
+ const useConnectionManager = config.useConnectionManager ?? true;
297
610
 
298
- // Disable built-in reconnect so ConnectionManager owns all retry/backoff behavior.
299
- (client as any).config.autoReconnect = false;
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
+ };
300
628
 
301
- client.registerCallbackListener(TOPIC_ROBOT, async (res: any) => {
302
- const messageId = res.headers?.messageId;
303
- const stats = getInboundCounters(account.accountId);
304
- stats.received += 1;
305
- const acknowledge = () => {
306
- if (!messageId) {
307
- return;
629
+ instrumentConnectionStages(c);
630
+
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
+ };
650
+ try {
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;
674
+ }
675
+
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}`);
308
724
  }
725
+ });
726
+
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) {
736
+ ctx.log?.warn?.(
737
+ `[${account.accountId}] Failed to acknowledge card callback ${messageId}: ${ackError.message}`,
738
+ );
739
+ }
740
+ };
741
+
309
742
  try {
310
- client.socketCallBackResponse(messageId, { success: true });
311
- stats.acked += 1;
312
- } catch (ackError: any) {
313
- ctx.log?.warn?.(
314
- `[${account.accountId}] Failed to acknowledge callback ${messageId}: ${ackError.message}`,
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)}`,
315
747
  );
316
- }
317
- };
318
- try {
319
- const data = JSON.parse(res.data) as DingTalkInboundMessage;
320
-
321
- // Message deduplication key is bot-scoped to avoid cross-account conflicts.
322
- const robotKey = config.robotCode || config.clientId || account.accountId;
323
- const msgId = data.msgId || messageId;
324
- const dedupKey = msgId ? `${robotKey}:${msgId}` : undefined;
325
-
326
- if (!dedupKey) {
327
- ctx.log?.warn?.(`[${account.accountId}] No message ID available for deduplication`);
328
- stats.noMessageId += 1;
329
- await handleDingTalkMessage({
330
- cfg,
331
- accountId: account.accountId,
332
- data,
333
- sessionWebhook: data.sessionWebhook,
334
- log: ctx.log,
335
- dingtalkConfig: config,
336
- });
337
- stats.processed += 1;
338
- acknowledge();
339
- if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
340
- logInboundCounters(ctx.log, account.accountId, "periodic");
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
+ }
341
779
  }
342
- return;
780
+ } catch (error: any) {
781
+ ctx.log?.error?.(
782
+ `[${account.accountId}] [DingTalk][CardCallback] Failed to parse callback: ${error.message}`,
783
+ );
784
+ } finally {
785
+ acknowledge();
343
786
  }
787
+ });
344
788
 
345
- if (isMessageProcessed(dedupKey)) {
346
- ctx.log?.debug?.(`[${account.accountId}] Skipping duplicate message: ${dedupKey}`);
347
- stats.dedupSkipped += 1;
348
- acknowledge();
349
- logInboundCounters(ctx.log, account.accountId, "dedup-skipped");
350
- return;
789
+ return c;
790
+ };
791
+
792
+ const client = createStreamClient();
793
+
794
+ // Guard against duplicate stop paths (abort signal + explicit stop).
795
+ let stopped = false;
796
+ let nativeStopResolve: (() => void) | undefined;
797
+ const nativeStopPromise = new Promise<void>((resolve) => {
798
+ nativeStopResolve = resolve;
799
+ });
800
+ let connectionManager: ConnectionManager | undefined;
801
+
802
+ const stopClient = () => {
803
+ if (stopped) {
804
+ return;
805
+ }
806
+ stopped = true;
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
+ });
819
+ if (useConnectionManager) {
820
+ connectionManager?.stop();
821
+ } else {
822
+ try {
823
+ client.disconnect();
824
+ } catch (err: any) {
825
+ ctx.log?.warn?.(`[${account.accountId}] Error during disconnect: ${err.message}`);
351
826
  }
827
+ nativeStopResolve?.();
828
+ }
352
829
 
353
- if (processingDedupKeys.has(dedupKey)) {
354
- ctx.log?.debug?.(
355
- `[${account.accountId}] Skipping in-flight duplicate message: ${dedupKey}`,
356
- );
357
- stats.inflightSkipped += 1;
358
- logInboundCounters(ctx.log, account.accountId, "inflight-skipped");
830
+ ctx.setStatus({
831
+ ...ctx.getStatus(),
832
+ running: false,
833
+ lastStopAt: getCurrentTimestamp(),
834
+ });
835
+
836
+ ctx.log?.info?.(`[${account.accountId}] DingTalk Stream client stopped`);
837
+ };
838
+
839
+ if (abortSignal) {
840
+ if (abortSignal.aborted) {
841
+ ctx.log?.warn?.(
842
+ `[${account.accountId}] Abort signal already active, skipping connection`,
843
+ );
844
+
845
+ ctx.setStatus({
846
+ ...ctx.getStatus(),
847
+ running: false,
848
+ lastStopAt: getCurrentTimestamp(),
849
+ lastError: "Connection aborted before start",
850
+ });
851
+
852
+ throw new Error("Connection aborted before start");
853
+ }
854
+
855
+ abortSignal.addEventListener("abort", () => {
856
+ if (stopped) {
359
857
  return;
360
858
  }
859
+ ctx.log?.info?.(
860
+ `[${account.accountId}] Abort signal received, stopping DingTalk Stream client...`,
861
+ );
862
+ stopClient();
863
+ });
864
+ }
361
865
 
362
- processingDedupKeys.add(dedupKey);
363
- try {
364
- await handleDingTalkMessage({
365
- cfg,
366
- accountId: account.accountId,
367
- data,
368
- sessionWebhook: data.sessionWebhook,
369
- log: ctx.log,
370
- dingtalkConfig: config,
866
+ if (!useConnectionManager) {
867
+ try {
868
+ await client.connect();
869
+ if (!stopped) {
870
+ ctx.setStatus({
871
+ ...ctx.getStatus(),
872
+ running: true,
873
+ lastStartAt: getCurrentTimestamp(),
874
+ lastError: null,
371
875
  });
372
- stats.processed += 1;
373
- markMessageProcessed(dedupKey);
374
- acknowledge();
375
- if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
376
- logInboundCounters(ctx.log, account.accountId, "periodic");
377
- }
378
- } finally {
379
- processingDedupKeys.delete(dedupKey);
876
+ ctx.log?.info?.(`[${account.accountId}] DingTalk Stream client connected successfully`);
877
+ await nativeStopPromise;
380
878
  }
381
- } catch (error: any) {
382
- stats.failed += 1;
383
- logInboundCounters(ctx.log, account.accountId, "failed");
384
- ctx.log?.error?.(`[${account.accountId}] Error processing message: ${error.message}`);
879
+ } catch (err: any) {
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
+ );
888
+ ctx.setStatus({
889
+ ...ctx.getStatus(),
890
+ running: false,
891
+ lastError: err.message || "Connection failed",
892
+ });
893
+ throw err;
385
894
  }
386
- });
387
895
 
388
- // Guard against duplicate stop paths (abort signal + explicit stop).
389
- let stopped = false;
896
+ return {
897
+ stop: () => {
898
+ stopClient();
899
+ },
900
+ };
901
+ }
390
902
 
391
903
  const connectionConfig: ConnectionManagerConfig = {
392
904
  maxAttempts: config.maxConnectionAttempts ?? 10,
@@ -394,6 +906,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
394
906
  maxDelay: config.maxReconnectDelay ?? 60000,
395
907
  jitter: config.reconnectJitter ?? 0.3,
396
908
  maxReconnectCycles: config.maxReconnectCycles,
909
+ reconnectDeadlineMs: config.reconnectDeadlineMs,
397
910
  onStateChange: (state: ConnectionState, error?: string) => {
398
911
  if (stopped) {
399
912
  return;
@@ -409,6 +922,22 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
409
922
  lastError: null,
410
923
  });
411
924
  } else if (state === ConnectionState.FAILED || state === ConnectionState.DISCONNECTED) {
925
+ // Clear stale in-flight locks for this account on disconnect.
926
+ // DingTalk will redeliver unacknowledged messages on reconnect; without
927
+ // this cleanup the redelivered messages would be silently skipped forever.
928
+ const robotKey = config.robotCode || config.clientId || account.accountId;
929
+ let cleared = 0;
930
+ for (const key of processingDedupKeys.keys()) {
931
+ if (key.startsWith(`${robotKey}:`)) {
932
+ processingDedupKeys.delete(key);
933
+ cleared++;
934
+ }
935
+ }
936
+ if (cleared > 0) {
937
+ ctx.log?.info?.(
938
+ `[${account.accountId}] Cleared ${cleared} stale in-flight lock(s) on disconnect`,
939
+ );
940
+ }
412
941
  ctx.setStatus({
413
942
  ...ctx.getStatus(),
414
943
  running: false,
@@ -424,48 +953,14 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
424
953
  `jitter=${connectionConfig.jitter}`,
425
954
  );
426
955
 
427
- const connectionManager = new ConnectionManager(
956
+ connectionManager = new ConnectionManager(
428
957
  client,
429
958
  account.accountId,
430
959
  connectionConfig,
431
960
  ctx.log,
961
+ createStreamClient,
432
962
  );
433
963
 
434
- // Register abort listener before connect() so startup can be cancelled safely.
435
- if (abortSignal) {
436
- if (abortSignal.aborted) {
437
- ctx.log?.warn?.(
438
- `[${account.accountId}] Abort signal already active, skipping connection`,
439
- );
440
-
441
- ctx.setStatus({
442
- ...ctx.getStatus(),
443
- running: false,
444
- lastStopAt: getCurrentTimestamp(),
445
- lastError: "Connection aborted before start",
446
- });
447
-
448
- throw new Error("Connection aborted before start");
449
- }
450
-
451
- abortSignal.addEventListener("abort", () => {
452
- if (stopped) {
453
- return;
454
- }
455
- stopped = true;
456
- ctx.log?.info?.(
457
- `[${account.accountId}] Abort signal received, stopping DingTalk Stream client...`,
458
- );
459
- connectionManager.stop();
460
-
461
- ctx.setStatus({
462
- ...ctx.getStatus(),
463
- running: false,
464
- lastStopAt: getCurrentTimestamp(),
465
- });
466
- });
467
- }
468
-
469
964
  try {
470
965
  await connectionManager.connect();
471
966
 
@@ -486,7 +981,14 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
486
981
  );
487
982
  }
488
983
  } catch (err: any) {
489
- 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
+ );
490
992
 
491
993
  ctx.setStatus({
492
994
  ...ctx.getStatus(),
@@ -498,20 +1000,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
498
1000
 
499
1001
  return {
500
1002
  stop: () => {
501
- if (stopped) {
502
- return;
503
- }
504
- stopped = true;
505
- ctx.log?.info?.(`[${account.accountId}] Stopping DingTalk Stream client...`);
506
- connectionManager.stop();
507
-
508
- ctx.setStatus({
509
- ...ctx.getStatus(),
510
- running: false,
511
- lastStopAt: getCurrentTimestamp(),
512
- });
513
-
514
- ctx.log?.info?.(`[${account.accountId}] DingTalk Stream client stopped`);
1003
+ stopClient();
515
1004
  },
516
1005
  };
517
1006
  },
@@ -520,6 +1009,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
520
1009
  defaultRuntime: {
521
1010
  accountId: "default",
522
1011
  running: false,
1012
+ lastEventAt: null,
523
1013
  lastStartAt: null,
524
1014
  lastStopAt: null,
525
1015
  lastError: null,
@@ -565,18 +1055,24 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
565
1055
  return { ok: false, error: error.message };
566
1056
  }
567
1057
  },
568
- buildAccountSnapshot: ({ account, runtime, snapshot, probe }: any) => ({
569
- accountId: account.accountId,
570
- name: account.name,
571
- enabled: account.enabled,
572
- configured: account.configured,
573
- clientId: account.config?.clientId ?? null,
574
- running: runtime?.running ?? snapshot?.running ?? false,
575
- lastStartAt: runtime?.lastStartAt ?? snapshot?.lastStartAt ?? null,
576
- lastStopAt: runtime?.lastStopAt ?? snapshot?.lastStopAt ?? null,
577
- lastError: runtime?.lastError ?? snapshot?.lastError ?? null,
578
- probe,
579
- }),
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
+ },
580
1076
  },
581
1077
  };
582
1078