@soimy/dingtalk 3.3.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.
@@ -1,19 +1,29 @@
1
1
  import axios from "axios";
2
- import { normalizeAllowFrom, isSenderAllowed, isSenderGroupAllowed } from "./access-control";
2
+ import { normalizeAllowFrom, isSenderAllowed, resolveGroupAccess } from "./access-control";
3
+ import { buildAgentSessionKey, resolveSubAgentRoute, dispatchSubAgents } from "./targeting/agent-routing";
4
+ import { classifyAckReactionEmoji } from "./ack-reaction-classifier";
5
+ import { attachNativeAckReaction } from "./ack-reaction-service";
6
+ import { createDynamicAckReactionController } from "./ack-reaction/dynamic-ack-reaction-controller";
3
7
  import { extractAttachmentText } from "./attachment-text-extractor";
4
8
  import { getAccessToken } from "./auth";
5
- import {
6
- createAICard,
7
- findCardContent,
8
- finishAICard,
9
- formatContentForCard,
10
- getCardContentByProcessQueryKey,
11
- isCardInTerminalState,
12
- } from "./card-service";
13
- import { classifyAckReactionEmoji } from "./ack-reaction-classifier";
9
+ import { createAICard } from "./card-service";
14
10
  import { resolveAckReactionSetting, resolveGroupConfig } from "./config";
11
+ import {
12
+ applyManualTargetLearningRule,
13
+ applyManualTargetsLearningRule,
14
+ applyManualGlobalLearningRule,
15
+ applyManualSessionLearningNote,
16
+ applyTargetSetLearningRule,
17
+ buildLearningContextBlock,
18
+ createOrUpdateTargetSet,
19
+ deleteManualRule,
20
+ disableManualRule,
21
+ isFeedbackLearningEnabled,
22
+ listLearningTargetSets,
23
+ listScopedLearningRules,
24
+ resolveManualForcedReply,
25
+ } from "./feedback-learning-service";
15
26
  import { formatGroupMembers, noteGroupMember } from "./group-members-store";
16
- import { setCurrentLogger } from "./logger-context";
17
27
  import {
18
28
  formatLearnAppliedReply,
19
29
  formatLearnCommandHelp,
@@ -28,28 +38,30 @@ import {
28
38
  isLearningOwner,
29
39
  parseLearnCommand,
30
40
  } from "./learning-command-service";
31
- import { extractMessageContent } from "./message-utils";
41
+ import { setCurrentLogger } from "./logger-context";
32
42
  import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
43
+ import {
44
+ DEFAULT_MEDIA_CONTEXT_TTL_MS,
45
+ DEFAULT_MESSAGE_CONTEXT_TTL_DAYS,
46
+ upsertInboundMessageContext,
47
+ } from "./message-context-store";
48
+ import { extractMessageContent } from "./message-utils";
49
+ import { resolveQuotedRuntimeContext } from "./messaging/quoted-context";
50
+ import {
51
+ buildInboundQuotedRef,
52
+ createReplyQuotedRef,
53
+ resolveQuotedRecord,
54
+ } from "./messaging/quoted-ref";
33
55
  import { registerPeerId } from "./peer-id-registry";
34
56
  import {
35
57
  clearProactiveRiskObservationsForTest,
36
58
  getProactiveRiskObservationForAny,
37
59
  } from "./proactive-risk-registry";
38
- import {
39
- appendQuoteJournalEntry,
40
- DEFAULT_JOURNAL_TTL_DAYS,
41
- resolveQuotedMessageById,
42
- } from "./quote-journal";
60
+ import { downloadGroupFile, getUnionIdByStaffId, resolveQuotedFile } from "./quoted-file-service";
61
+ import { createReplyStrategy } from "./reply-strategy";
62
+ import type { DeliverPayload } from "./reply-strategy";
43
63
  import { getDingTalkRuntime } from "./runtime";
44
64
  import { sendBySession, sendMessage, sendProactiveMedia } from "./send-service";
45
- import { clearSessionPeerOverride, getSessionPeerOverride, setSessionPeerOverride } from "./session-peer-store";
46
- import { resolveDingTalkSessionPeer } from "./session-routing";
47
- import type { DingTalkConfig, HandleDingTalkMessageParams, MediaFile } from "./types";
48
- import { AICardStatus } from "./types";
49
- import { createCardDraftController } from "./card-draft-controller";
50
- import { acquireSessionLock } from "./session-lock";
51
- import { cacheInboundDownloadCode, getCachedDownloadCode } from "./quoted-msg-cache";
52
- import { downloadGroupFile, getUnionIdByStaffId, resolveQuotedFile } from "./quoted-file-service";
53
65
  import {
54
66
  formatSessionAliasBoundReply,
55
67
  formatSessionAliasClearedReply,
@@ -60,29 +72,82 @@ import {
60
72
  parseSessionCommand,
61
73
  validateSessionAlias,
62
74
  } from "./session-command-service";
75
+ import { acquireSessionLock } from "./session-lock";
63
76
  import {
64
- applyManualTargetLearningRule,
65
- applyManualTargetsLearningRule,
66
- applyManualGlobalLearningRule,
67
- applyManualSessionLearningNote,
68
- applyTargetSetLearningRule,
69
- buildLearningContextBlock,
70
- createOrUpdateTargetSet,
71
- deleteManualRule,
72
- disableManualRule,
73
- isFeedbackLearningEnabled,
74
- listLearningTargetSets,
75
- listScopedLearningRules,
76
- resolveManualForcedReply,
77
- } from "./feedback-learning-service";
78
- import { attachNativeAckReaction, recallNativeAckReactionWithRetry } from "./ack-reaction-service";
79
- import { formatDingTalkErrorPayloadLog, maskSensitiveData } from "./utils";
77
+ clearSessionPeerOverride,
78
+ getSessionPeerOverride,
79
+ setSessionPeerOverride,
80
+ } from "./session-peer-store";
81
+ import { resolveDingTalkSessionPeer } from "./session-routing";
82
+ import {
83
+ upsertObservedGroupTarget,
84
+ upsertObservedUserTarget,
85
+ } from "./targeting/target-directory-store";
86
+ import type { DingTalkConfig, HandleDingTalkMessageParams, MediaFile } from "./types";
87
+ import { formatDingTalkErrorPayloadLog, getErrorMessage, getErrorResponseData, maskSensitiveData } from "./utils";
80
88
 
81
89
  const DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS = 24;
82
90
  const MIN_THINKING_REACTION_VISIBLE_MS = 1200;
83
- const ATTACHMENT_TEXT_PREFIX = "[附件内容摘录]";
91
+ const MAX_DYNAMIC_ACK_DISPOSE_WAIT_MS = 500;
84
92
  const proactiveHintLastSentAt = new Map<string, number>();
85
93
 
94
+ function resolvePinnedMainDmOwner(params: {
95
+ dmScope?: string;
96
+ allowFrom?: string[];
97
+ }): string | null {
98
+ if ((params.dmScope ?? "main") !== "main") {
99
+ return null;
100
+ }
101
+ const allow = normalizeAllowFrom(params.allowFrom);
102
+ if (allow.hasWildcard) {
103
+ return null;
104
+ }
105
+ return allow.entries.length === 1 ? allow.entries[0] : null;
106
+ }
107
+
108
+ function ttlDaysToMs(ttlDays: number | undefined): number | undefined {
109
+ if (typeof ttlDays !== "number" || !Number.isFinite(ttlDays) || ttlDays <= 0) {
110
+ return undefined;
111
+ }
112
+ return ttlDays * 24 * 60 * 60 * 1000;
113
+ }
114
+
115
+ async function waitForDynamicAckDispose(params: {
116
+ dispose: () => Promise<void>;
117
+ log?: { debug?: (message: string) => void; warn?: (message: string) => void };
118
+ sessionKey: string;
119
+ }): Promise<void> {
120
+ let timedOut = false;
121
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
122
+ const disposePromise = params.dispose().catch((err: unknown) => {
123
+ params.log?.warn?.(
124
+ `[DingTalk] Dynamic ack reaction cleanup failed for session ${params.sessionKey}: ${getErrorMessage(err)}`,
125
+ );
126
+ });
127
+
128
+ try {
129
+ await Promise.race([
130
+ disposePromise,
131
+ new Promise<void>((resolve) => {
132
+ timeoutHandle = setTimeout(() => {
133
+ timedOut = true;
134
+ resolve();
135
+ }, MAX_DYNAMIC_ACK_DISPOSE_WAIT_MS);
136
+ }),
137
+ ]);
138
+ } finally {
139
+ if (timeoutHandle) {
140
+ clearTimeout(timeoutHandle);
141
+ }
142
+ }
143
+
144
+ if (timedOut) {
145
+ params.log?.debug?.(
146
+ `[DingTalk] Dynamic ack reaction cleanup timed out after ${MAX_DYNAMIC_ACK_DISPOSE_WAIT_MS}ms; releasing session lock for ${params.sessionKey}`,
147
+ );
148
+ }
149
+ }
150
+
86
151
  export function resetProactivePermissionHintStateForTest(): void {
87
152
  proactiveHintLastSentAt.clear();
88
153
  clearProactiveRiskObservationsForTest();
@@ -118,7 +183,11 @@ function shouldSendProactivePermissionHint(params: {
118
183
  return false;
119
184
  }
120
185
 
121
- const riskObservation = getProactiveRiskObservationForAny(params.accountId, riskTargets, params.nowMs);
186
+ const riskObservation = getProactiveRiskObservationForAny(
187
+ params.accountId,
188
+ riskTargets,
189
+ params.nowMs,
190
+ );
122
191
  if (!riskObservation || riskObservation.source !== "proactive-api") {
123
192
  return false;
124
193
  }
@@ -138,13 +207,6 @@ function shouldSendProactivePermissionHint(params: {
138
207
  return true;
139
208
  }
140
209
 
141
- function stripQuotedPrefixForJournal(value: string): string {
142
- return value
143
- .replace(/^\[引用消息: .*?\]\n\n/s, "")
144
- .replace(/^\[这是一条引用消息,原消息ID: .*?\]\n\n/s, "")
145
- .trim();
146
- }
147
-
148
210
  function sanitizeGroupPromptName(value?: string): string {
149
211
  return (value || "")
150
212
  .replace(/[\r\n,=]/g, " ")
@@ -201,7 +263,7 @@ export async function downloadMedia(
201
263
  try {
202
264
  return JSON.stringify(maskSensitiveData(value));
203
265
  } catch {
204
- return String(value);
266
+ return `[unstringifiable ${typeof value}]`;
205
267
  }
206
268
  };
207
269
 
@@ -267,7 +329,7 @@ export async function downloadMedia(
267
329
  }
268
330
 
269
331
  export async function handleDingTalkMessage(params: HandleDingTalkMessageParams): Promise<void> {
270
- const { cfg, accountId, data, sessionWebhook, log, dingtalkConfig } = params;
332
+ const { cfg, accountId, data, sessionWebhook, log, dingtalkConfig, subAgentOptions, preDownloadedMedia } = params;
271
333
  const rt = getDingTalkRuntime();
272
334
 
273
335
  // Save logger globally so shared services can log consistently without threading log everywhere.
@@ -281,12 +343,21 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
281
343
  return;
282
344
  }
283
345
 
284
- const extractedContent = extractMessageContent(data);
346
+ // Shallow copy: only .text is reassigned below; nested arrays (atMentions, mediaTypes) are read-only downstream.
347
+ const extractedContent = { ...extractMessageContent(data) };
285
348
  if (!extractedContent.text) {
286
349
  return;
287
350
  }
288
351
 
352
+ // Add context hint for sub-agent mode, stripping quoted prefix to avoid protocol noise in agent context.
353
+ if (subAgentOptions) {
354
+ const cleanText = extractedContent.text.replace(/^\[引用[^\]]*\]\s*/, "");
355
+ const contextHint = `[你被 @ 为"${subAgentOptions.matchedName}"]\n\n`;
356
+ extractedContent.text = contextHint + cleanText;
357
+ }
358
+
289
359
  const isDirect = data.conversationType === "1";
360
+ const isGroup = !isDirect;
290
361
  const senderOriginalId = (data.senderId || "").trim();
291
362
  const senderStaffId = (data.senderStaffId || "").trim();
292
363
  const senderId = senderStaffId || senderOriginalId;
@@ -320,7 +391,9 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
320
391
  } catch (err: any) {
321
392
  log?.debug?.(`[DingTalk] Failed to send proactive permission hint: ${err.message}`);
322
393
  if (err?.response?.data !== undefined) {
323
- log?.debug?.(formatDingTalkErrorPayloadLog("inbound.proactivePermissionHint", err.response.data));
394
+ log?.debug?.(
395
+ formatDingTalkErrorPayloadLog("inbound.proactivePermissionHint", err.response.data),
396
+ );
324
397
  }
325
398
  }
326
399
  }
@@ -352,7 +425,9 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
352
425
  } catch (err: any) {
353
426
  log?.debug?.(`[DingTalk] Failed to send access denied message: ${err.message}`);
354
427
  if (err?.response?.data !== undefined) {
355
- log?.debug?.(formatDingTalkErrorPayloadLog("inbound.accessDeniedReply", err.response.data));
428
+ log?.debug?.(
429
+ formatDingTalkErrorPayloadLog("inbound.accessDeniedReply", err.response.data),
430
+ );
356
431
  }
357
432
  }
358
433
 
@@ -367,46 +442,90 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
367
442
  commandAuthorized = true;
368
443
  }
369
444
  } else {
370
- const groupPolicy = dingtalkConfig.groupPolicy || "open";
371
- const allowFrom = dingtalkConfig.allowFrom || [];
372
-
373
- if (groupPolicy === "allowlist") {
374
- const normalizedAllowFrom = normalizeAllowFrom(allowFrom);
375
- const isAllowed = isSenderGroupAllowed({ allow: normalizedAllowFrom, groupId });
376
-
377
- if (!isAllowed) {
378
- log?.debug?.(
379
- `[DingTalk] Group blocked: conversationId=${groupId} senderId=${senderId} not in allowlist (groupPolicy=allowlist)`,
380
- );
445
+ const groupAccess = resolveGroupAccess({
446
+ groupPolicy: dingtalkConfig.groupPolicy || "open",
447
+ groupId,
448
+ senderId,
449
+ groups: dingtalkConfig.groups,
450
+ groupAllowFrom: dingtalkConfig.groupAllowFrom,
451
+ allowFrom: dingtalkConfig.allowFrom,
452
+ });
381
453
 
382
- try {
383
- await sendBySession(
384
- dingtalkConfig,
385
- sessionWebhook,
386
- `⛔ 访问受限\n\n您的群聊ID:\`${groupId}\`\n\n请联系管理员将此ID添加到允许列表中。`,
387
- { log, atUserId: senderId },
388
- );
389
- } catch (err: any) {
390
- log?.debug?.(`[DingTalk] Failed to send group access denied message: ${err.message}`);
391
- if (err?.response?.data !== undefined) {
392
- log?.debug?.(
393
- formatDingTalkErrorPayloadLog("inbound.groupAccessDeniedReply", err.response.data),
394
- );
395
- }
396
- }
454
+ if (groupAccess.legacyFallback) {
455
+ log?.info?.(
456
+ `[DingTalk] DEPRECATED: groupPolicy=allowlist is using "allowFrom" for group access control. ` +
457
+ `Please migrate to "groups" (group ID allowlist) or "groupAllowFrom" (sender allowlist).`,
458
+ );
459
+ }
397
460
 
461
+ if (!groupAccess.allowed) {
462
+ if (groupAccess.reason === "disabled") {
463
+ log?.debug?.(`[DingTalk] Group disabled: all group messages dropped (groupPolicy=disabled)`);
398
464
  return;
399
465
  }
400
466
 
467
+ const denyMessage = groupAccess.reason === "sender_not_allowed"
468
+ ? `⛔ 访问受限\n\n您的用户ID:\`${senderId}\`\n\n请联系管理员将此ID添加到群聊允许列表中。`
469
+ : `⛔ 访问受限\n\n您的群聊ID:\`${groupId}\`\n\n请联系管理员将此ID添加到允许列表中。`;
470
+
401
471
  log?.debug?.(
402
- `[DingTalk] Group authorized: conversationId=${groupId} senderId=${senderId} in allowlist`,
472
+ `[DingTalk] Group blocked: conversationId=${groupId} senderId=${senderId} reason=${groupAccess.reason}`,
403
473
  );
474
+
475
+ try {
476
+ await sendBySession(
477
+ dingtalkConfig,
478
+ sessionWebhook,
479
+ denyMessage,
480
+ { log, atUserId: senderId },
481
+ );
482
+ } catch (err: any) {
483
+ log?.debug?.(`[DingTalk] Failed to send group access denied message: ${err.message}`);
484
+ if (err?.response?.data !== undefined) {
485
+ log?.debug?.(
486
+ formatDingTalkErrorPayloadLog("inbound.groupAccessDeniedReply", err.response.data),
487
+ );
488
+ }
489
+ }
490
+
491
+ return;
404
492
  }
493
+
494
+ log?.debug?.(
495
+ `[DingTalk] Group authorized: conversationId=${groupId} senderId=${senderId}`,
496
+ );
405
497
  }
406
498
 
499
+ // Calculate account store path and session peer (for session alias feature)
407
500
  const accountStorePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
408
501
  agentId: accountId,
409
502
  });
503
+ try {
504
+ if (!isDirect && groupId) {
505
+ upsertObservedGroupTarget({
506
+ storePath: accountStorePath,
507
+ accountId,
508
+ conversationId: groupId,
509
+ title: groupName,
510
+ seenAt: data.createAt,
511
+ });
512
+ }
513
+ if (senderId || senderOriginalId) {
514
+ upsertObservedUserTarget({
515
+ storePath: accountStorePath,
516
+ accountId,
517
+ senderId: senderOriginalId || senderId,
518
+ staffId: senderStaffId || undefined,
519
+ displayName: senderName,
520
+ conversationId: groupId,
521
+ seenAt: data.createAt,
522
+ });
523
+ }
524
+ } catch (err) {
525
+ log?.warn?.(
526
+ `[DingTalk] Target directory observe failed: accountId=${accountId} groupId=${groupId || "-"} senderId=${senderOriginalId || senderId || "-"} storePath=${accountStorePath || "-"} error=${String(err)}`,
527
+ );
528
+ }
410
529
  const currentSessionSourceKind = isDirect ? "direct" : "group";
411
530
  const currentSessionSourceId = isDirect ? senderId : groupId;
412
531
  const peerIdOverride = getSessionPeerOverride({
@@ -422,12 +541,54 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
422
541
  peerIdOverride,
423
542
  config: dingtalkConfig,
424
543
  });
425
- const route = rt.channel.routing.resolveAgentRoute({
426
- cfg,
427
- channel: "dingtalk",
428
- accountId,
429
- peer: { kind: sessionPeer.kind, id: sessionPeer.peerId },
430
- });
544
+
545
+ const route = subAgentOptions
546
+ ? {
547
+ agentId: subAgentOptions.agentId,
548
+ sessionKey: buildAgentSessionKey({
549
+ rt,
550
+ cfg,
551
+ accountId,
552
+ agentId: subAgentOptions.agentId,
553
+ peerKind: sessionPeer.kind,
554
+ peerId: sessionPeer.peerId,
555
+ }),
556
+ mainSessionKey: "",
557
+ }
558
+ : rt.channel.routing.resolveAgentRoute({
559
+ cfg,
560
+ channel: "dingtalk",
561
+ accountId,
562
+ peer: { kind: sessionPeer.kind, id: sessionPeer.peerId },
563
+ });
564
+
565
+ // @Sub-Agent routing: resolve @mentions to agents (skip in recursive sub-agent calls)
566
+ if (!subAgentOptions) {
567
+ const subAgentRoute = await resolveSubAgentRoute({
568
+ extractedContent,
569
+ cfg,
570
+ isGroup,
571
+ dingtalkConfig,
572
+ sessionWebhook,
573
+ senderId,
574
+ log,
575
+ });
576
+ if (subAgentRoute) {
577
+ await dispatchSubAgents({
578
+ ...subAgentRoute,
579
+ cfg,
580
+ accountId,
581
+ data,
582
+ dingtalkConfig,
583
+ sessionWebhook,
584
+ extractedContent,
585
+ handleMessage: handleDingTalkMessage,
586
+ downloadMedia,
587
+ log,
588
+ });
589
+ return;
590
+ }
591
+ }
431
592
 
432
593
  // Route resolved before media download for session context and routing metadata.
433
594
  const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
@@ -488,22 +649,22 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
488
649
  return;
489
650
  }
490
651
  if (
491
- (parsedLearnCommand.scope === "global"
492
- || parsedLearnCommand.scope === "session"
493
- || parsedLearnCommand.scope === "here"
494
- || parsedLearnCommand.scope === "target"
495
- || parsedLearnCommand.scope === "targets"
496
- || parsedLearnCommand.scope === "list"
497
- || parsedLearnCommand.scope === "disable"
498
- || parsedLearnCommand.scope === "delete"
499
- || parsedLearnCommand.scope === "target-set-create"
500
- || parsedLearnCommand.scope === "target-set-apply"
501
- || parsedSessionCommand.scope === "session-alias-show"
502
- || parsedSessionCommand.scope === "session-alias-set"
503
- || parsedSessionCommand.scope === "session-alias-clear"
504
- || parsedSessionCommand.scope === "session-alias-bind"
505
- || parsedSessionCommand.scope === "session-alias-unbind")
506
- && !isOwner
652
+ (parsedLearnCommand.scope === "global" ||
653
+ parsedLearnCommand.scope === "session" ||
654
+ parsedLearnCommand.scope === "here" ||
655
+ parsedLearnCommand.scope === "target" ||
656
+ parsedLearnCommand.scope === "targets" ||
657
+ parsedLearnCommand.scope === "list" ||
658
+ parsedLearnCommand.scope === "disable" ||
659
+ parsedLearnCommand.scope === "delete" ||
660
+ parsedLearnCommand.scope === "target-set-create" ||
661
+ parsedLearnCommand.scope === "target-set-apply" ||
662
+ parsedSessionCommand.scope === "session-alias-show" ||
663
+ parsedSessionCommand.scope === "session-alias-set" ||
664
+ parsedSessionCommand.scope === "session-alias-clear" ||
665
+ parsedSessionCommand.scope === "session-alias-bind" ||
666
+ parsedSessionCommand.scope === "session-alias-unbind") &&
667
+ !isOwner
507
668
  ) {
508
669
  await sendBySession(dingtalkConfig, sessionWebhook, formatOwnerOnlyDeniedReply(), { log });
509
670
  return;
@@ -571,10 +732,12 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
571
732
  );
572
733
  return;
573
734
  }
574
- if (parsedSessionCommand.scope === "session-alias-bind"
575
- && parsedSessionCommand.sourceKind
576
- && parsedSessionCommand.sourceId
577
- && parsedSessionCommand.peerId) {
735
+ if (
736
+ parsedSessionCommand.scope === "session-alias-bind" &&
737
+ parsedSessionCommand.sourceKind &&
738
+ parsedSessionCommand.sourceId &&
739
+ parsedSessionCommand.peerId
740
+ ) {
578
741
  const aliasValidationError = validateSessionAlias(parsedSessionCommand.peerId);
579
742
  if (aliasValidationError) {
580
743
  await sendBySession(
@@ -604,9 +767,11 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
604
767
  );
605
768
  return;
606
769
  }
607
- if (parsedSessionCommand.scope === "session-alias-unbind"
608
- && parsedSessionCommand.sourceKind
609
- && parsedSessionCommand.sourceId) {
770
+ if (
771
+ parsedSessionCommand.scope === "session-alias-unbind" &&
772
+ parsedSessionCommand.sourceKind &&
773
+ parsedSessionCommand.sourceId
774
+ ) {
610
775
  const existed = clearSessionPeerOverride({
611
776
  storePath: accountStorePath,
612
777
  accountId,
@@ -681,7 +846,11 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
681
846
  );
682
847
  return;
683
848
  }
684
- if (parsedLearnCommand.scope === "target" && parsedLearnCommand.targetId && parsedLearnCommand.instruction) {
849
+ if (
850
+ parsedLearnCommand.scope === "target" &&
851
+ parsedLearnCommand.targetId &&
852
+ parsedLearnCommand.instruction
853
+ ) {
685
854
  const applied = applyManualTargetLearningRule({
686
855
  storePath: accountStorePath,
687
856
  accountId,
@@ -701,7 +870,11 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
701
870
  );
702
871
  return;
703
872
  }
704
- if (parsedLearnCommand.scope === "targets" && parsedLearnCommand.targetIds?.length && parsedLearnCommand.instruction) {
873
+ if (
874
+ parsedLearnCommand.scope === "targets" &&
875
+ parsedLearnCommand.targetIds?.length &&
876
+ parsedLearnCommand.instruction
877
+ ) {
705
878
  const applied = applyManualTargetsLearningRule({
706
879
  storePath: accountStorePath,
707
880
  accountId,
@@ -721,7 +894,11 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
721
894
  );
722
895
  return;
723
896
  }
724
- if (parsedLearnCommand.scope === "target-set-create" && parsedLearnCommand.setName && parsedLearnCommand.targetIds?.length) {
897
+ if (
898
+ parsedLearnCommand.scope === "target-set-create" &&
899
+ parsedLearnCommand.setName &&
900
+ parsedLearnCommand.targetIds?.length
901
+ ) {
725
902
  const saved = createOrUpdateTargetSet({
726
903
  storePath: accountStorePath,
727
904
  accountId,
@@ -733,15 +910,19 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
733
910
  sessionWebhook,
734
911
  saved
735
912
  ? formatTargetSetSavedReply({
736
- setName: parsedLearnCommand.setName,
737
- targetIds: parsedLearnCommand.targetIds,
738
- })
913
+ setName: parsedLearnCommand.setName,
914
+ targetIds: parsedLearnCommand.targetIds,
915
+ })
739
916
  : "目标组保存失败,请检查名称和目标列表。",
740
917
  { log },
741
918
  );
742
919
  return;
743
920
  }
744
- if (parsedLearnCommand.scope === "target-set-apply" && parsedLearnCommand.setName && parsedLearnCommand.instruction) {
921
+ if (
922
+ parsedLearnCommand.scope === "target-set-apply" &&
923
+ parsedLearnCommand.setName &&
924
+ parsedLearnCommand.instruction
925
+ ) {
745
926
  const applied = applyTargetSetLearningRule({
746
927
  storePath: accountStorePath,
747
928
  accountId,
@@ -753,12 +934,12 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
753
934
  sessionWebhook,
754
935
  applied.length > 0
755
936
  ? formatLearnAppliedReply({
756
- scope: "target-set",
757
- setName: parsedLearnCommand.setName,
758
- targetIds: applied.map((item) => item.targetId),
759
- instruction: parsedLearnCommand.instruction,
760
- ruleId: applied[0]?.ruleId,
761
- })
937
+ scope: "target-set",
938
+ setName: parsedLearnCommand.setName,
939
+ targetIds: applied.map((item) => item.targetId),
940
+ instruction: parsedLearnCommand.instruction,
941
+ ruleId: applied[0]?.ruleId,
942
+ })
762
943
  : `未找到目标组 \`${parsedLearnCommand.setName}\`,或该目标组为空。`,
763
944
  { log },
764
945
  );
@@ -774,7 +955,9 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
774
955
  });
775
956
  const targetSets = listLearningTargetSets({ storePath: accountStorePath, accountId })
776
957
  .slice(0, 10)
777
- .map((targetSet) => `- [target-set] ${targetSet.name} => ${targetSet.targetIds.join(", ")}`);
958
+ .map(
959
+ (targetSet) => `- [target-set] ${targetSet.name} => ${targetSet.targetIds.join(", ")}`,
960
+ );
778
961
  await sendBySession(
779
962
  dingtalkConfig,
780
963
  sessionWebhook,
@@ -846,6 +1029,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
846
1029
  const aiCard = await createAICard(dingtalkConfig, to, log, {
847
1030
  accountId,
848
1031
  storePath: accountStorePath,
1032
+ contextConversationId: groupId,
849
1033
  });
850
1034
  if (aiCard) {
851
1035
  currentAICard = aiCard;
@@ -863,56 +1047,70 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
863
1047
  }
864
1048
  }
865
1049
 
866
- const hasConcreteQuotedPayload =
867
- !!extractedContent.quoted?.mediaDownloadCode ||
868
- !!extractedContent.quoted?.isQuotedFile ||
869
- !!extractedContent.quoted?.isQuotedCard ||
870
- extractedContent.quoted?.prefix.startsWith('[引用消息: "') === true;
871
- const journalTTLDays = dingtalkConfig.journalTTLDays ?? DEFAULT_JOURNAL_TTL_DAYS;
872
- let content = extractedContent;
873
-
874
- if (data.text?.isReplyMsg && data.originalMsgId && !hasConcreteQuotedPayload) {
875
- try {
876
- const quoted = resolveQuotedMessageById({
877
- storePath,
878
- accountId,
879
- conversationId: groupId,
880
- originalMsgId: data.originalMsgId,
881
- ttlDays: journalTTLDays,
882
- });
883
- if (quoted?.text?.trim()) {
884
- const cleanedText = extractedContent.text.replace(
885
- /^\[这是一条引用消息,原消息ID: [^\]]+\]\n\n/,
886
- "",
887
- );
888
- content = {
889
- ...extractedContent,
890
- text: `[引用消息: "${quoted.text.trim()}"]\n\n${cleanedText}`,
891
- };
892
- }
893
- } catch (err) {
894
- log?.debug?.(`[DingTalk] Quote journal lookup failed: ${String(err)}`);
895
- }
1050
+ const journalTTLDays = dingtalkConfig.journalTTLDays ?? DEFAULT_MESSAGE_CONTEXT_TTL_DAYS;
1051
+ const quotedRef = buildInboundQuotedRef(data, extractedContent);
1052
+ const replyQuotedRef = createReplyQuotedRef(data.msgId);
1053
+ const content = extractedContent;
1054
+ const hasLegacyQuoteContent =
1055
+ typeof data.content?.quoteContent === "string" && data.content.quoteContent.trim().length > 0;
1056
+
1057
+ if (hasLegacyQuoteContent && !quotedRef) {
1058
+ log?.debug?.(
1059
+ `[DingTalk] Legacy quoteContent present without resolvable quotedRef: ` +
1060
+ `conversationType=${data.conversationType} conversationId=${data.conversationId} ` +
1061
+ `msgId=${data.msgId} originalMsgId=${data.originalMsgId || "(none)"}`,
1062
+ );
1063
+ }
1064
+ if (quotedRef) {
1065
+ log?.debug?.(
1066
+ `[DingTalk][QuotedRef] Built inbound quotedRef msgId=${data.msgId} scope=${groupId} ` +
1067
+ `quotedRef=${JSON.stringify(quotedRef)}`,
1068
+ );
1069
+ } else if (
1070
+ data.text?.isReplyMsg ||
1071
+ data.originalMsgId ||
1072
+ data.originalProcessQueryKey ||
1073
+ content.quoted
1074
+ ) {
1075
+ log?.debug?.(
1076
+ `[DingTalk][QuotedRef] Reply metadata present without resolvable quotedRef ` +
1077
+ `msgId=${data.msgId} scope=${groupId} originalMsgId=${data.originalMsgId || "(none)"} ` +
1078
+ `originalProcessQueryKey=${data.originalProcessQueryKey || "(none)"}`,
1079
+ );
896
1080
  }
897
1081
 
898
1082
  try {
899
- appendQuoteJournalEntry({
900
- storePath,
1083
+ upsertInboundMessageContext({
1084
+ storePath: accountStorePath,
901
1085
  accountId,
902
1086
  conversationId: groupId,
903
1087
  msgId: data.msgId,
904
1088
  messageType: content.messageType,
905
- text: stripQuotedPrefixForJournal(content.text),
1089
+ text: content.text,
1090
+ quotedRef,
906
1091
  createdAt: data.createAt,
907
- ttlDays: journalTTLDays,
1092
+ ttlMs: ttlDaysToMs(journalTTLDays),
1093
+ ttlReferenceMs: data.createAt,
1094
+ cleanupCreatedAtTtlDays: journalTTLDays,
1095
+ topic: null,
908
1096
  });
909
1097
  } catch (err) {
910
- log?.warn?.(`[DingTalk] Quote journal append failed: ${String(err)}`);
1098
+ log?.warn?.(`[DingTalk] Message context inbound append failed: ${String(err)}`);
911
1099
  }
912
1100
 
913
1101
  let mediaPath: string | undefined;
914
1102
  let mediaType: string | undefined;
915
- if (content.mediaPath && dingtalkConfig.robotCode) {
1103
+ let attachmentContextMsgId = data.msgId;
1104
+ let attachmentContextCreatedAt = data.createAt;
1105
+ let attachmentContextMessageType = content.messageType;
1106
+ let attachmentContextFileName = data.content?.fileName;
1107
+
1108
+ // Use pre-downloaded media if available (from sub-agent outer call)
1109
+ if (preDownloadedMedia?.mediaPath) {
1110
+ mediaPath = preDownloadedMedia.mediaPath;
1111
+ mediaType = preDownloadedMedia.mediaType;
1112
+ } else if (content.mediaPath && dingtalkConfig.robotCode) {
1113
+ // Download media only if not pre-downloaded
916
1114
  const media = await downloadMedia(dingtalkConfig, content.mediaPath, log);
917
1115
  if (media) {
918
1116
  mediaPath = media.path;
@@ -922,15 +1120,21 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
922
1120
 
923
1121
  // Cache downloadCode (+ spaceId/fileId) for quoted file lookups (DM + group).
924
1122
  if (content.mediaPath && data.msgId) {
925
- cacheInboundDownloadCode(
1123
+ upsertInboundMessageContext({
1124
+ storePath: accountStorePath,
926
1125
  accountId,
927
- data.conversationId,
928
- data.msgId,
929
- content.mediaPath,
930
- content.messageType,
931
- data.createAt,
932
- { spaceId: data.content?.spaceId, fileId: data.content?.fileId, storePath },
933
- );
1126
+ conversationId: data.conversationId,
1127
+ msgId: data.msgId,
1128
+ createdAt: data.createAt,
1129
+ messageType: content.messageType,
1130
+ media: {
1131
+ downloadCode: content.mediaPath,
1132
+ spaceId: data.content?.spaceId,
1133
+ fileId: data.content?.fileId,
1134
+ },
1135
+ ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
1136
+ topic: null,
1137
+ });
934
1138
  }
935
1139
 
936
1140
  // User-sent DingTalk doc / Drive file card: cache msgId -> {spaceId,fileId}
@@ -941,15 +1145,20 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
941
1145
  content.docSpaceId &&
942
1146
  content.docFileId
943
1147
  ) {
944
- cacheInboundDownloadCode(
1148
+ upsertInboundMessageContext({
1149
+ storePath: accountStorePath,
945
1150
  accountId,
946
- data.conversationId,
947
- data.msgId,
948
- undefined,
949
- content.messageType,
950
- data.createAt,
951
- { spaceId: content.docSpaceId, fileId: content.docFileId, storePath },
952
- );
1151
+ conversationId: data.conversationId,
1152
+ msgId: data.msgId,
1153
+ createdAt: data.createAt,
1154
+ messageType: content.messageType,
1155
+ media: {
1156
+ spaceId: content.docSpaceId,
1157
+ fileId: content.docFileId,
1158
+ },
1159
+ ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
1160
+ topic: null,
1161
+ });
953
1162
 
954
1163
  if (!mediaPath && isDirect && data.senderStaffId) {
955
1164
  try {
@@ -971,31 +1180,71 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
971
1180
  }
972
1181
  }
973
1182
 
1183
+ const quotedRecord = resolveQuotedRecord({
1184
+ storePath: accountStorePath,
1185
+ accountId,
1186
+ conversationId: data.conversationId,
1187
+ quotedRef,
1188
+ log,
1189
+ });
1190
+ const quotedRuntimeContext = resolveQuotedRuntimeContext({
1191
+ storePath: accountStorePath,
1192
+ accountId,
1193
+ conversationId: data.conversationId,
1194
+ quotedRef,
1195
+ firstRecord: quotedRecord,
1196
+ firstPreview:
1197
+ content.quoted?.previewText ||
1198
+ content.quoted?.previewMessageType
1199
+ ? {
1200
+ text: content.quoted.previewText,
1201
+ messageType: content.quoted.previewMessageType,
1202
+ senderId: content.quoted.previewSenderId,
1203
+ }
1204
+ : undefined,
1205
+ log,
1206
+ });
1207
+
974
1208
  // Try downloading a quoted file from cached downloadCode/spaceId+fileId.
975
- const tryDownloadFromCache = async (
976
- quotedMsgId: string | undefined,
1209
+ const tryDownloadFromRecord = async (
1210
+ record: {
1211
+ msgId?: string;
1212
+ media?: {
1213
+ downloadCode?: string;
1214
+ spaceId?: string;
1215
+ fileId?: string;
1216
+ };
1217
+ } | null,
977
1218
  ): Promise<MediaFile | null> => {
978
- if (!quotedMsgId) {
979
- return null;
980
- }
981
- const cached = getCachedDownloadCode(accountId, data.conversationId, quotedMsgId, storePath);
982
- if (!cached) {
1219
+ if (!record?.media) {
983
1220
  return null;
984
1221
  }
985
1222
  let media: MediaFile | null = null;
986
- if (cached.downloadCode) {
987
- media = await downloadMedia(dingtalkConfig, cached.downloadCode, log);
1223
+ if (record.media.downloadCode) {
1224
+ media = await downloadMedia(dingtalkConfig, record.media.downloadCode, log);
1225
+ if (media) {
1226
+ log?.debug?.(
1227
+ `[DingTalk][QuotedRef] Recovered quoted media from cached downloadCode ` +
1228
+ `recordMsgId=${record.msgId || "(none)"} scope=${data.conversationId}`,
1229
+ );
1230
+ }
988
1231
  }
989
- if (!media && cached.spaceId && cached.fileId && data.senderStaffId) {
1232
+ if (!media && record.media.spaceId && record.media.fileId && data.senderStaffId) {
990
1233
  try {
991
1234
  const unionId = await getUnionIdByStaffId(dingtalkConfig, data.senderStaffId, log);
992
1235
  media = await downloadGroupFile(
993
1236
  dingtalkConfig,
994
- cached.spaceId,
995
- cached.fileId,
1237
+ record.media.spaceId,
1238
+ record.media.fileId,
996
1239
  unionId,
997
1240
  log,
998
1241
  );
1242
+ if (media) {
1243
+ log?.debug?.(
1244
+ `[DingTalk][QuotedRef] Recovered quoted media from cached spaceId/fileId ` +
1245
+ `recordMsgId=${record.msgId || "(none)"} scope=${data.conversationId}`,
1246
+ );
1247
+ }
999
1248
  } catch (err: any) {
1000
1249
  log?.warn?.(`[DingTalk] spaceId+fileId fallback failed: ${err.message}`);
1001
1250
  }
@@ -1005,15 +1254,23 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1005
1254
 
1006
1255
  // Quoted picture: download via existing downloadMedia.
1007
1256
  if (!mediaPath && content.quoted?.mediaDownloadCode && dingtalkConfig.robotCode) {
1008
- const media = await downloadMedia(dingtalkConfig, content.quoted.mediaDownloadCode, log);
1257
+ const media =
1258
+ (await tryDownloadFromRecord(quotedRecord)) ||
1259
+ (await downloadMedia(dingtalkConfig, content.quoted.mediaDownloadCode, log));
1009
1260
  if (media) {
1261
+ if (!quotedRecord) {
1262
+ log?.debug?.(
1263
+ `[DingTalk][QuotedRef] Recovered quoted image from inbound downloadCode fallback scope=${data.conversationId}`,
1264
+ );
1265
+ }
1010
1266
  mediaPath = media.path;
1011
1267
  mediaType = media.mimeType;
1268
+ attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
1269
+ attachmentContextCreatedAt = quotedRecord?.createdAt || data.createAt;
1270
+ attachmentContextMessageType = quotedRecord?.messageType || content.quoted.previewMessageType || "picture";
1271
+ attachmentContextFileName = content.quoted.previewFileName;
1012
1272
  } else {
1013
- content.text = content.text.replace(
1014
- content.quoted.prefix,
1015
- "[引用了一张图片,但下载失败]\n\n",
1016
- );
1273
+ content.text = `[引用了一张图片,但下载失败]\n\n${content.text}`;
1017
1274
  }
1018
1275
  }
1019
1276
 
@@ -1021,11 +1278,15 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1021
1278
  if (!mediaPath && content.quoted?.isQuotedFile) {
1022
1279
  let fileResolved = false;
1023
1280
 
1024
- // Step 1: Try msgId-based cache (works for both DM and group if bot saw the original message).
1025
- const cachedMedia = await tryDownloadFromCache(content.quoted.msgId);
1281
+ // Step 1: Prefer quotedRef-backed record lookup, then msgId-based cache.
1282
+ const cachedMedia = await tryDownloadFromRecord(quotedRecord);
1026
1283
  if (cachedMedia) {
1027
1284
  mediaPath = cachedMedia.path;
1028
1285
  mediaType = cachedMedia.mimeType;
1286
+ attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
1287
+ attachmentContextCreatedAt = quotedRecord?.createdAt || content.quoted.fileCreatedAt || data.createAt;
1288
+ attachmentContextMessageType = quotedRecord?.messageType || "file";
1289
+ attachmentContextFileName = quotedRecord?.attachmentFileName || content.quoted.previewFileName;
1029
1290
  fileResolved = true;
1030
1291
  }
1031
1292
 
@@ -1043,17 +1304,31 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1043
1304
  if (resolved) {
1044
1305
  mediaPath = resolved.media.path;
1045
1306
  mediaType = resolved.media.mimeType;
1307
+ attachmentContextMsgId = content.quoted.msgId || data.msgId;
1308
+ attachmentContextCreatedAt = content.quoted.fileCreatedAt || data.createAt;
1309
+ attachmentContextMessageType = "file";
1310
+ attachmentContextFileName = resolved.name || content.quoted.previewFileName;
1046
1311
  fileResolved = true;
1312
+ log?.debug?.(
1313
+ `[DingTalk][QuotedRef] Recovered quoted file from group file fallback ` +
1314
+ `scope=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
1315
+ );
1047
1316
  if (content.quoted.msgId) {
1048
- cacheInboundDownloadCode(
1317
+ upsertInboundMessageContext({
1318
+ storePath: accountStorePath,
1049
1319
  accountId,
1050
- data.conversationId,
1051
- content.quoted.msgId,
1052
- undefined,
1053
- "file",
1054
- content.quoted.fileCreatedAt || Date.now(),
1055
- { storePath, spaceId: resolved.spaceId, fileId: resolved.fileId },
1056
- );
1320
+ conversationId: data.conversationId,
1321
+ msgId: content.quoted.msgId,
1322
+ createdAt: content.quoted.fileCreatedAt || Date.now(),
1323
+ messageType: "file",
1324
+ media: {
1325
+ spaceId: resolved.spaceId,
1326
+ fileId: resolved.fileId,
1327
+ },
1328
+ attachmentFileName: resolved.name,
1329
+ ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
1330
+ topic: null,
1331
+ });
1057
1332
  }
1058
1333
  }
1059
1334
  }
@@ -1065,7 +1340,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1065
1340
  const hint = isDirect
1066
1341
  ? "[引用了一个文件,内容无法自动获取,请直接发送该文件]\n\n"
1067
1342
  : "[引用了一个文件,但无法获取内容]\n\n";
1068
- content.text = content.text.replace(content.quoted.prefix, hint);
1343
+ content.text = `${hint}${content.text}`;
1069
1344
  }
1070
1345
  }
1071
1346
 
@@ -1077,12 +1352,16 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1077
1352
  if (!mediaPath && content.quoted?.isQuotedDocCard) {
1078
1353
  let docResolved = false;
1079
1354
 
1080
- const cachedDocMedia = await tryDownloadFromCache(content.quoted.msgId);
1355
+ const cachedDocMedia = await tryDownloadFromRecord(quotedRecord);
1081
1356
  if (cachedDocMedia) {
1082
1357
  mediaPath = cachedDocMedia.path;
1083
1358
  mediaType = cachedDocMedia.mimeType;
1359
+ attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
1360
+ attachmentContextCreatedAt = quotedRecord?.createdAt || content.quoted.fileCreatedAt || data.createAt;
1361
+ attachmentContextMessageType =
1362
+ quotedRecord?.messageType || content.quoted.previewMessageType || "interactiveCardFile";
1363
+ attachmentContextFileName = quotedRecord?.attachmentFileName || content.quoted.previewFileName;
1084
1364
  docResolved = true;
1085
- content.text = content.text.replace(content.quoted.prefix, "[引用了钉钉文档]\n\n");
1086
1365
  }
1087
1366
 
1088
1367
  if (!docResolved && !isDirect && content.quoted.fileCreatedAt) {
@@ -1098,18 +1377,31 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1098
1377
  if (resolved) {
1099
1378
  mediaPath = resolved.media.path;
1100
1379
  mediaType = resolved.media.mimeType;
1380
+ attachmentContextMsgId = content.quoted.msgId || data.msgId;
1381
+ attachmentContextCreatedAt = content.quoted.fileCreatedAt || data.createAt;
1382
+ attachmentContextMessageType = "interactiveCardFile";
1383
+ attachmentContextFileName = resolved.name || content.quoted.previewFileName;
1101
1384
  docResolved = true;
1102
- content.text = content.text.replace(content.quoted.prefix, "[引用了钉钉文档]\n\n");
1385
+ log?.debug?.(
1386
+ `[DingTalk][QuotedRef] Recovered quoted doc card from group file fallback ` +
1387
+ `scope=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
1388
+ );
1103
1389
  if (content.quoted.msgId) {
1104
- cacheInboundDownloadCode(
1390
+ upsertInboundMessageContext({
1391
+ storePath: accountStorePath,
1105
1392
  accountId,
1106
- data.conversationId,
1107
- content.quoted.msgId,
1108
- undefined,
1109
- "interactiveCardFile",
1110
- content.quoted.fileCreatedAt || Date.now(),
1111
- { storePath, spaceId: resolved.spaceId, fileId: resolved.fileId },
1112
- );
1393
+ conversationId: data.conversationId,
1394
+ msgId: content.quoted.msgId,
1395
+ createdAt: content.quoted.fileCreatedAt || Date.now(),
1396
+ messageType: "interactiveCardFile",
1397
+ media: {
1398
+ spaceId: resolved.spaceId,
1399
+ fileId: resolved.fileId,
1400
+ },
1401
+ attachmentFileName: resolved.name,
1402
+ ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
1403
+ topic: null,
1404
+ });
1113
1405
  }
1114
1406
  }
1115
1407
  }
@@ -1121,43 +1413,32 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1121
1413
  const hint = isDirect
1122
1414
  ? "[引用了钉钉文档,内容无法自动获取,请直接发送该文档]\n\n"
1123
1415
  : "[引用了钉钉文档,但无法获取内容]\n\n";
1124
- content.text = content.text.replace(content.quoted.prefix, hint);
1125
- }
1126
- }
1127
-
1128
- // Quoted AI card: prefer deterministic processQueryKey lookup, and only
1129
- // fall back to the legacy createdAt matcher when the callback omits that key.
1130
- if (content.quoted?.isQuotedCard) {
1131
- const cardContent = content.quoted.processQueryKey
1132
- ? getCardContentByProcessQueryKey(
1133
- accountId,
1134
- to,
1135
- content.quoted.processQueryKey,
1136
- accountStorePath,
1137
- )
1138
- : content.quoted.cardCreatedAt
1139
- ? findCardContent(accountId, to, content.quoted.cardCreatedAt, accountStorePath)
1140
- : null;
1141
- if (cardContent) {
1142
- const preview = cardContent.length > 50 ? cardContent.slice(0, 50) + "..." : cardContent;
1143
- content.text = content.text.replace(
1144
- content.quoted.prefix,
1145
- `[引用机器人回复: "${preview}"]\n\n`,
1146
- );
1416
+ content.text = `${hint}${content.text}`;
1147
1417
  }
1148
- // Card cache miss: prefix already contains "[引用了机器人的回复]", keep as-is.
1149
1418
  }
1150
1419
 
1151
- let attachmentExtractedText: string | undefined;
1152
1420
  if (mediaPath) {
1153
1421
  try {
1154
1422
  const extracted = await extractAttachmentText({
1155
1423
  path: mediaPath,
1156
1424
  mimeType: mediaType,
1157
- fileName: data.content?.fileName,
1425
+ fileName: attachmentContextFileName || data.content?.fileName,
1158
1426
  });
1159
1427
  if (extracted?.text) {
1160
- attachmentExtractedText = `${ATTACHMENT_TEXT_PREFIX}\n${extracted.text}`;
1428
+ upsertInboundMessageContext({
1429
+ storePath: accountStorePath,
1430
+ accountId,
1431
+ conversationId: data.conversationId,
1432
+ msgId: attachmentContextMsgId,
1433
+ createdAt: attachmentContextCreatedAt,
1434
+ messageType: attachmentContextMessageType,
1435
+ attachmentText: extracted.text,
1436
+ attachmentTextSource: extracted.sourceType,
1437
+ attachmentTextTruncated: extracted.truncated,
1438
+ attachmentFileName: attachmentContextFileName,
1439
+ ttlMs: ttlDaysToMs(journalTTLDays),
1440
+ topic: null,
1441
+ });
1161
1442
  }
1162
1443
  } catch (err: any) {
1163
1444
  log?.warn?.(`[DingTalk] Failed to extract attachment text: ${err.message}`);
@@ -1168,7 +1449,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1168
1449
  mediaPath && /<media:[^>]+>/.test(content.text)
1169
1450
  ? `${content.text}\n[media_path: ${mediaPath}]\n[media_type: ${mediaType || "unknown"}]`
1170
1451
  : content.text;
1171
- const inboundText = attachmentExtractedText ? `${inboundBody}\n\n${attachmentExtractedText}` : inboundBody;
1452
+ const inboundText = inboundBody;
1172
1453
  const learningEnabled = isFeedbackLearningEnabled(dingtalkConfig);
1173
1454
  const learningContextBlock = buildLearningContextBlock({
1174
1455
  enabled: learningEnabled,
@@ -1219,6 +1500,15 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1219
1500
  Body: body,
1220
1501
  RawBody: inboundText,
1221
1502
  CommandBody: inboundText,
1503
+ QuotedRef: quotedRef,
1504
+ QuotedRefJson: quotedRef ? JSON.stringify(quotedRef) : undefined,
1505
+ ReplyToId: quotedRuntimeContext?.replyToId,
1506
+ ReplyToBody: quotedRuntimeContext?.replyToBody,
1507
+ ReplyToSender: quotedRuntimeContext?.replyToSender,
1508
+ ReplyToIsQuote: quotedRuntimeContext?.replyToIsQuote,
1509
+ UntrustedContext: quotedRuntimeContext?.untrustedContext
1510
+ ? [quotedRuntimeContext.untrustedContext]
1511
+ : undefined,
1222
1512
  From: to,
1223
1513
  To: to,
1224
1514
  SessionKey: route.sessionKey,
@@ -1247,7 +1537,27 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1247
1537
  storePath,
1248
1538
  sessionKey: ctx.SessionKey || route.sessionKey,
1249
1539
  ctx,
1250
- updateLastRoute: { sessionKey: route.mainSessionKey, channel: "dingtalk", to, accountId },
1540
+ updateLastRoute: (() => {
1541
+ if (!isDirect) {
1542
+ return undefined;
1543
+ }
1544
+ const pinnedMainDmOwner = resolvePinnedMainDmOwner({
1545
+ dmScope: cfg.session?.dmScope,
1546
+ allowFrom: dingtalkConfig.allowFrom,
1547
+ });
1548
+ const senderRecipient = (senderOriginalId || senderId || "").trim().toLowerCase();
1549
+ if (
1550
+ pinnedMainDmOwner
1551
+ && senderRecipient
1552
+ && pinnedMainDmOwner.trim().toLowerCase() !== senderRecipient
1553
+ ) {
1554
+ log?.debug?.(
1555
+ `[DingTalk] Skipping main-session last route update for ${senderRecipient} (pinned owner ${pinnedMainDmOwner})`,
1556
+ );
1557
+ return undefined;
1558
+ }
1559
+ return { sessionKey: route.mainSessionKey, channel: "dingtalk", to, accountId };
1560
+ })(),
1251
1561
  onRecordError: (err: unknown) => {
1252
1562
  log?.error?.(`[DingTalk] Failed to record inbound session: ${String(err)}`);
1253
1563
  },
@@ -1263,8 +1573,13 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1263
1573
  accountId,
1264
1574
  agentId: route.agentId,
1265
1575
  });
1576
+ const normalizedAckReaction = ackReaction === "off" ? "" : ackReaction;
1266
1577
  const resolvedAckReaction =
1267
- ackReaction === "emoji" ? classifyAckReactionEmoji(content.text).emoji : ackReaction;
1578
+ normalizedAckReaction === "kaomoji"
1579
+ ? classifyAckReactionEmoji(content.text).emoji
1580
+ : normalizedAckReaction === "emoji"
1581
+ ? "🤔思考中"
1582
+ : normalizedAckReaction;
1268
1583
  const shouldAttachAckReaction = Boolean(resolvedAckReaction);
1269
1584
  let ackReactionAttached = false;
1270
1585
  let ackReactionAttachedAt = 0;
@@ -1281,310 +1596,169 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1281
1596
  );
1282
1597
  if (ackReactionAttached) {
1283
1598
  ackReactionAttachedAt = Date.now();
1599
+ log?.debug?.(
1600
+ `[DingTalk] Initial ack reaction attached mode=${normalizedAckReaction || "off"} reaction=${resolvedAckReaction}`,
1601
+ );
1602
+ }
1603
+ }
1604
+
1605
+ // ---- Shared media delivery helper ----
1606
+ async function deliverMediaAttachments(urls: string[]) {
1607
+ for (const rawMediaUrl of urls) {
1608
+ const preparedMedia = await prepareMediaInput(
1609
+ rawMediaUrl,
1610
+ log,
1611
+ dingtalkConfig.mediaUrlAllowlist,
1612
+ );
1613
+ try {
1614
+ const actualMediaPath = preparedMedia.path;
1615
+ const outMediaType = resolveOutboundMediaType({
1616
+ mediaPath: actualMediaPath,
1617
+ asVoice: false,
1618
+ });
1619
+ if (sessionWebhook) {
1620
+ const sendResult = await sendMessage(dingtalkConfig, to, "", {
1621
+ sessionWebhook,
1622
+ mediaPath: actualMediaPath,
1623
+ mediaType: outMediaType,
1624
+ log,
1625
+ accountId,
1626
+ storePath: accountStorePath,
1627
+ conversationId: groupId,
1628
+ quotedRef: replyQuotedRef,
1629
+ });
1630
+ if (!sendResult.ok) {
1631
+ throw new Error(sendResult.error || "Media reply send failed");
1632
+ }
1633
+ } else {
1634
+ const sendResult = await sendProactiveMedia(
1635
+ dingtalkConfig,
1636
+ to,
1637
+ actualMediaPath,
1638
+ outMediaType,
1639
+ {
1640
+ accountId,
1641
+ log,
1642
+ storePath: accountStorePath,
1643
+ conversationId: groupId,
1644
+ quotedRef: replyQuotedRef,
1645
+ },
1646
+ );
1647
+ if (!sendResult.ok) {
1648
+ throw new Error(sendResult.error || "Media reply send failed");
1649
+ }
1650
+ }
1651
+ } finally {
1652
+ await preparedMedia.cleanup?.();
1653
+ }
1284
1654
  }
1285
1655
  }
1286
1656
 
1657
+ // ---- Extract mediaUrls from runtime payload ----
1658
+ function extractMediaUrls(payload: ReplyStreamPayload): string[] {
1659
+ const richPayload = payload as typeof payload & {
1660
+ mediaUrl?: string;
1661
+ mediaUrls?: string[];
1662
+ };
1663
+ return Array.isArray(richPayload.mediaUrls)
1664
+ ? richPayload.mediaUrls.filter((entry: unknown) => typeof entry === "string" && entry.trim())
1665
+ : richPayload.mediaUrl &&
1666
+ typeof richPayload.mediaUrl === "string" &&
1667
+ richPayload.mediaUrl.trim()
1668
+ ? [richPayload.mediaUrl]
1669
+ : [];
1670
+ }
1671
+
1287
1672
  // Serialize dispatchReply + card finalize per session to prevent the runtime
1288
1673
  // from receiving concurrent dispatch calls on the same session key, which
1289
1674
  // causes empty replies for all but the first caller.
1675
+ // Each sub-agent call acquires its own lock since sub-agent sessions have
1676
+ // different session keys (different agentId), so no deadlock risk.
1677
+ const shouldTrackDynamicAckReaction =
1678
+ (normalizedAckReaction === "emoji" || normalizedAckReaction === "kaomoji")
1679
+ && shouldAttachAckReaction;
1680
+ const runtimeEvents = (rt as typeof rt & {
1681
+ events?: {
1682
+ onAgentEvent?: (listener: (event: unknown) => void) => (() => void);
1683
+ };
1684
+ }).events;
1290
1685
  const releaseSessionLock = await acquireSessionLock(route.sessionKey);
1686
+ const dynamicAckReactionController = createDynamicAckReactionController({
1687
+ enabled: shouldTrackDynamicAckReaction,
1688
+ initialReaction: resolvedAckReaction || "",
1689
+ initialAttached: ackReactionAttached,
1690
+ initialAttachedAt: ackReactionAttachedAt,
1691
+ dingtalkConfig,
1692
+ msgId: data.msgId,
1693
+ conversationId: groupId,
1694
+ sessionKey: route.sessionKey,
1695
+ log,
1696
+ runtimeEvents,
1697
+ onReactionDisposed: () => {
1698
+ ackReactionAttached = false;
1699
+ },
1700
+ });
1291
1701
  try {
1292
1702
  if (!ackReactionAttached && shouldAttachAckReaction) {
1293
1703
  log?.debug?.("[DingTalk] Native ack reaction unavailable; skipping fallback.");
1294
1704
  }
1295
1705
 
1296
- const controller = useCardMode && currentAICard
1297
- ? createCardDraftController({ card: currentAICard, log })
1298
- : undefined;
1299
- let cardFinalized = false;
1300
- let finalTextForFallback: string | undefined;
1706
+ // ---- Create reply strategy (card or markdown) ----
1707
+ const strategy = createReplyStrategy({
1708
+ config: dingtalkConfig,
1709
+ card: currentAICard,
1710
+ useCardMode: useCardMode && !!currentAICard,
1711
+ to,
1712
+ sessionWebhook,
1713
+ senderId,
1714
+ isDirect,
1715
+ accountId,
1716
+ storePath: accountStorePath,
1717
+ groupId,
1718
+ log,
1719
+ replyQuotedRef,
1720
+ deliverMedia: deliverMediaAttachments,
1721
+ });
1301
1722
 
1302
1723
  try {
1303
1724
  await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1304
1725
  ctx,
1305
1726
  cfg,
1306
1727
  dispatcherOptions: {
1307
- responsePrefix: "",
1728
+ responsePrefix: subAgentOptions?.responsePrefix || "",
1308
1729
  deliver: async (payload: ReplyStreamPayload, info?: ReplyChunkInfo) => {
1309
- async function deliverMediaAttachments(urls: string[]) {
1310
- for (const rawMediaUrl of urls) {
1311
- const preparedMedia = await prepareMediaInput(
1312
- rawMediaUrl,
1313
- log,
1314
- dingtalkConfig.mediaUrlAllowlist,
1315
- );
1316
- try {
1317
- const actualMediaPath = preparedMedia.path;
1318
- const mediaType = resolveOutboundMediaType({
1319
- mediaPath: actualMediaPath,
1320
- asVoice: false,
1321
- });
1322
- if (sessionWebhook) {
1323
- await sendBySession(dingtalkConfig, sessionWebhook, "", {
1324
- mediaPath: actualMediaPath,
1325
- mediaType,
1326
- log,
1327
- });
1328
- } else {
1329
- const sendResult = await sendProactiveMedia(
1330
- dingtalkConfig,
1331
- to,
1332
- actualMediaPath,
1333
- mediaType,
1334
- {
1335
- accountId,
1336
- log,
1337
- },
1338
- );
1339
- if (!sendResult.ok) {
1340
- throw new Error(sendResult.error || "Media reply send failed");
1341
- }
1342
- }
1343
- } finally {
1344
- await preparedMedia.cleanup?.();
1345
- }
1346
- }
1347
- }
1348
-
1349
1730
  try {
1350
- const richPayload = payload as typeof payload & {
1351
- mediaUrl?: string;
1352
- mediaUrls?: string[];
1353
- };
1354
- const textToSend = payload.text;
1355
- const mediaUrls = Array.isArray(richPayload.mediaUrls)
1356
- ? richPayload.mediaUrls.filter((entry: unknown) => typeof entry === "string" && entry.trim())
1357
- : richPayload.mediaUrl && typeof richPayload.mediaUrl === "string" && richPayload.mediaUrl.trim()
1358
- ? [richPayload.mediaUrl]
1359
- : [];
1360
-
1361
- // In card mode, deliver(final) must always reach finalize even with empty text
1362
- // (e.g. bot sent a file via tool with no accompanying text).
1363
- if ((typeof textToSend !== "string" || textToSend.length === 0) && mediaUrls.length === 0) {
1364
- if (useCardMode && currentAICard && info?.kind === "final") {
1365
- // fall through to card finalize below
1366
- } else {
1367
- return;
1368
- }
1369
- }
1370
-
1371
- // ---- card mode: final ----
1372
- // Do NOT finalize or stop the controller here — runtime calls
1373
- // deliver(final) once per assistant turn, so there may be more
1374
- // turns coming after tool calls. Finalization is deferred to
1375
- // step 5 (post-dispatch) where the full accumulated content
1376
- // is available.
1377
- if (useCardMode && currentAICard && info?.kind === "final") {
1378
- log?.info?.(
1379
- `[DingTalk][Finalize] deliver(final) received — cardState=${currentAICard.state} ` +
1380
- `textLen=${typeof textToSend === "string" ? textToSend.length : "null"} ` +
1381
- `mediaUrls=${mediaUrls.length} ` +
1382
- `lastAnswer="${(controller?.getLastAnswerContent() ?? "").slice(0, 80)}" ` +
1383
- `lastContent="${(controller?.getLastContent() ?? "").slice(0, 80)}"`,
1384
- );
1385
- if (mediaUrls.length > 0) {
1386
- await deliverMediaAttachments(mediaUrls);
1387
- }
1388
- const rawFinalText = typeof textToSend === "string" ? textToSend : "";
1389
- if (rawFinalText) {
1390
- finalTextForFallback = rawFinalText;
1391
- }
1392
- return;
1393
- }
1394
-
1395
- // ---- card mode: tool ----
1396
- if (useCardMode && currentAICard && info?.kind === "tool") {
1397
- if (controller!.isFailed() || isCardInTerminalState(currentAICard.state)) {
1398
- log?.debug?.("[DingTalk] Card failed, skipping tool result (will send full reply on final)");
1399
- return;
1400
- }
1401
- await controller!.flush();
1402
- await controller!.waitForInFlight();
1403
- log?.info?.(
1404
- `[DingTalk] Tool result received, streaming to AI Card: ${(textToSend ?? "").slice(0, 100)}`,
1405
- );
1406
- const toolText = typeof textToSend === "string" ? formatContentForCard(textToSend, "tool") : "";
1407
- if (toolText) {
1408
- const sendResult = await sendMessage(dingtalkConfig, to, toolText, {
1409
- sessionWebhook,
1410
- atUserId: !isDirect ? senderId : null,
1411
- log,
1412
- card: currentAICard,
1413
- accountId,
1414
- storePath,
1415
- conversationId: groupId,
1416
- cardUpdateMode: "append",
1417
- });
1418
- if (!sendResult.ok) {
1419
- throw new Error(sendResult.error || "Tool stream send failed");
1420
- }
1421
- }
1422
- return;
1423
- }
1424
-
1425
- // ---- media delivery (all modes) ----
1426
- if (mediaUrls.length > 0) {
1427
- await deliverMediaAttachments(mediaUrls);
1428
- }
1429
-
1430
- // ---- non-card mode (markdown/text) ----
1431
- if (!useCardMode || !currentAICard) {
1432
- if (typeof textToSend !== "string" || textToSend.length === 0) {
1433
- return;
1434
- }
1435
- const sendResult = await sendMessage(dingtalkConfig, to, textToSend, {
1436
- sessionWebhook,
1437
- atUserId: !isDirect ? senderId : null,
1438
- log,
1439
- accountId,
1440
- storePath,
1441
- conversationId: groupId,
1442
- });
1443
- if (!sendResult.ok) {
1444
- throw new Error(sendResult.error || "Reply send failed");
1445
- }
1446
- }
1447
- } catch (err: any) {
1448
- log?.error?.(`[DingTalk] Reply failed: ${err.message}`);
1449
- if (err?.response?.data !== undefined) {
1450
- log?.error?.(formatDingTalkErrorPayloadLog("inbound.replyDeliver", err.response.data));
1731
+ const mediaUrls = extractMediaUrls(payload);
1732
+ await strategy.deliver({
1733
+ text: payload.text,
1734
+ mediaUrls,
1735
+ kind: (info?.kind as DeliverPayload["kind"]) || "block",
1736
+ });
1737
+ } catch (err: unknown) {
1738
+ log?.error?.(`[DingTalk] Reply failed: ${getErrorMessage(err)}`);
1739
+ const responseData = getErrorResponseData(err);
1740
+ if (responseData !== undefined) {
1741
+ log?.error?.(formatDingTalkErrorPayloadLog("inbound.replyDeliver", responseData));
1451
1742
  }
1452
1743
  throw err;
1453
1744
  }
1454
1745
  },
1455
1746
  },
1456
- replyOptions: {
1457
- disableBlockStreaming: dingtalkConfig.cardRealTimeStream && controller ? true : undefined,
1458
-
1459
- onAssistantMessageStart: controller
1460
- ? () => { controller.notifyNewAssistantTurn(); }
1461
- : undefined,
1462
-
1463
- onPartialReply: dingtalkConfig.cardRealTimeStream && controller
1464
- ? (payload: ReplyStreamPayload) => {
1465
- if (payload.text) {
1466
- controller.updateAnswer(payload.text);
1467
- }
1468
- }
1469
- : undefined,
1470
-
1471
- onReasoningStream: controller
1472
- ? (payload: ReplyStreamPayload) => {
1473
- if (payload.text) {
1474
- controller.updateReasoning(payload.text);
1475
- }
1476
- }
1477
- : undefined,
1478
- },
1747
+ replyOptions: strategy.getReplyOptions(),
1479
1748
  });
1480
- } catch (dispatchErr: any) {
1481
- if (useCardMode && currentAICard && !isCardInTerminalState(currentAICard.state)) {
1482
- controller!.stop();
1483
- await controller!.waitForInFlight();
1484
- if (!cardFinalized) {
1485
- try {
1486
- await finishAICard(currentAICard, "❌ 处理失败", log);
1487
- } catch (cardCloseErr: any) {
1488
- log?.debug?.(`[DingTalk] Failed to finalize card after dispatch error: ${cardCloseErr.message}`);
1489
- currentAICard.state = AICardStatus.FAILED;
1490
- currentAICard.lastUpdated = Date.now();
1491
- }
1492
- }
1493
- }
1749
+ } catch (dispatchErr: unknown) {
1750
+ const error = dispatchErr instanceof Error ? dispatchErr : new Error(getErrorMessage(dispatchErr));
1751
+ await strategy.abort(error);
1494
1752
  throw dispatchErr;
1495
1753
  }
1496
1754
 
1497
- // 5) Post-dispatch card finalization.
1498
- // This is the sole finalize path — deliver(final) defers here because
1499
- // runtime may call deliver(final) multiple times (once per assistant turn).
1500
- log?.info?.(
1501
- `[DingTalk][Finalize] Step 5 entry — useCardMode=${useCardMode} ` +
1502
- `hasCard=${!!currentAICard} cardFinalized=${cardFinalized} ` +
1503
- `cardState=${currentAICard?.state ?? "N/A"} ` +
1504
- `controllerFailed=${controller?.isFailed() ?? "N/A"} ` +
1505
- `finalTextForFallback="${(finalTextForFallback ?? "").slice(0, 80)}" ` +
1506
- `lastAnswer="${(controller?.getLastAnswerContent() ?? "").slice(0, 80)}" ` +
1507
- `lastContent="${(controller?.getLastContent() ?? "").slice(0, 80)}"`,
1508
- );
1509
- if (useCardMode && currentAICard && !cardFinalized) {
1510
- try {
1511
- if (currentAICard.state === AICardStatus.FINISHED) {
1512
- log?.info?.(
1513
- `[DingTalk][Finalize] Skipping — card already FINISHED`,
1514
- );
1515
- return;
1516
- }
1517
-
1518
- if (currentAICard.state === AICardStatus.FAILED || controller!.isFailed()) {
1519
- const fallbackText = finalTextForFallback
1520
- || controller!.getLastAnswerContent()
1521
- || controller!.getLastContent()
1522
- || currentAICard.lastStreamedContent;
1523
- if (fallbackText) {
1524
- log?.debug?.("[DingTalk] Card failed during streaming, sending markdown fallback");
1525
- const sendResult = await sendMessage(dingtalkConfig, to, fallbackText, {
1526
- sessionWebhook,
1527
- atUserId: !isDirect ? senderId : null,
1528
- log,
1529
- accountId,
1530
- storePath,
1531
- conversationId: groupId,
1532
- });
1533
- if (!sendResult.ok) {
1534
- throw new Error(sendResult.error || "Markdown fallback send failed after card failure — user received no reply");
1535
- }
1536
- } else {
1537
- log?.debug?.("[DingTalk] Card failed but no content to fallback with");
1538
- }
1539
- return;
1540
- }
1541
-
1542
- await controller!.flush();
1543
- await controller!.waitForInFlight();
1544
- controller!.stop();
1545
- const finalText = controller!.getLastAnswerContent()
1546
- || finalTextForFallback
1547
- || "✅ Done";
1548
- log?.info?.(
1549
- `[DingTalk][Finalize] Calling finishAICard — finalTextLen=${finalText.length} ` +
1550
- `source=${controller!.getLastAnswerContent() ? "lastAnswerContent" : finalTextForFallback ? "finalTextForFallback" : "fallbackDone"} ` +
1551
- `preview="${finalText.slice(0, 120)}"`,
1552
- );
1553
- await finishAICard(currentAICard, finalText, log);
1554
- } catch (err: any) {
1555
- log?.debug?.(`[DingTalk] AI Card finalization failed: ${err.message}`);
1556
- if (err?.response?.data !== undefined) {
1557
- log?.debug?.(formatDingTalkErrorPayloadLog("inbound.cardFinalize", err.response.data));
1558
- }
1559
- try {
1560
- if (currentAICard.state !== AICardStatus.FINISHED) {
1561
- currentAICard.state = AICardStatus.FAILED;
1562
- currentAICard.lastUpdated = Date.now();
1563
- }
1564
- } catch (stateErr: any) {
1565
- log?.debug?.(`[DingTalk] Failed to update card state to FAILED: ${stateErr.message}`);
1566
- }
1567
- }
1568
- }
1755
+ await strategy.finalize();
1569
1756
  } finally {
1757
+ await waitForDynamicAckDispose({
1758
+ dispose: () => dynamicAckReactionController.dispose(MIN_THINKING_REACTION_VISIBLE_MS),
1759
+ log,
1760
+ sessionKey: route.sessionKey,
1761
+ });
1570
1762
  releaseSessionLock();
1571
- if (ackReactionAttached) {
1572
- void (async () => {
1573
- const elapsedMs = ackReactionAttachedAt > 0 ? Date.now() - ackReactionAttachedAt : 0;
1574
- const remainingVisibleMs = MIN_THINKING_REACTION_VISIBLE_MS - elapsedMs;
1575
- if (remainingVisibleMs > 0) {
1576
- await new Promise(resolve => setTimeout(resolve, remainingVisibleMs));
1577
- }
1578
- await recallNativeAckReactionWithRetry(
1579
- dingtalkConfig,
1580
- {
1581
- msgId: data.msgId,
1582
- conversationId: groupId,
1583
- reactionName: resolvedAckReaction,
1584
- },
1585
- log,
1586
- );
1587
- })();
1588
- }
1589
1763
  }
1590
1764
  }