@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.
@@ -1,32 +1,86 @@
1
1
  import axios from "axios";
2
2
  import { normalizeAllowFrom, isSenderAllowed, isSenderGroupAllowed } from "./access-control";
3
+ import { extractAttachmentText } from "./attachment-text-extractor";
3
4
  import { getAccessToken } from "./auth";
4
5
  import {
5
- cleanupCardCache,
6
6
  createAICard,
7
+ findCardContent,
7
8
  finishAICard,
8
9
  formatContentForCard,
9
- getActiveCardIdByTarget,
10
- getCardById,
10
+ getCardContentByProcessQueryKey,
11
11
  isCardInTerminalState,
12
- streamAICard,
13
12
  } from "./card-service";
14
- import { resolveGroupConfig } from "./config";
13
+ import { classifyAckReactionEmoji } from "./ack-reaction-classifier";
14
+ import { resolveAckReactionSetting, resolveGroupConfig } from "./config";
15
15
  import { formatGroupMembers, noteGroupMember } from "./group-members-store";
16
16
  import { setCurrentLogger } from "./logger-context";
17
+ import {
18
+ formatLearnAppliedReply,
19
+ formatLearnCommandHelp,
20
+ formatLearnDeletedReply,
21
+ formatLearnDisabledReply,
22
+ formatLearnListReply,
23
+ formatOwnerOnlyDeniedReply,
24
+ formatOwnerStatusReply,
25
+ formatTargetSetSavedReply,
26
+ formatWhereAmIReply,
27
+ formatWhoAmIReply,
28
+ isLearningOwner,
29
+ parseLearnCommand,
30
+ } from "./learning-command-service";
17
31
  import { extractMessageContent } from "./message-utils";
32
+ import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
18
33
  import { registerPeerId } from "./peer-id-registry";
19
34
  import {
20
35
  clearProactiveRiskObservationsForTest,
21
36
  getProactiveRiskObservationForAny,
22
37
  } from "./proactive-risk-registry";
38
+ import {
39
+ appendQuoteJournalEntry,
40
+ DEFAULT_JOURNAL_TTL_DAYS,
41
+ resolveQuotedMessageById,
42
+ } from "./quote-journal";
23
43
  import { getDingTalkRuntime } from "./runtime";
24
- import { sendBySession, sendMessage } from "./send-service";
44
+ import { sendBySession, sendMessage, sendProactiveMedia } from "./send-service";
45
+ import { clearSessionPeerOverride, getSessionPeerOverride, setSessionPeerOverride } from "./session-peer-store";
46
+ import { resolveDingTalkSessionPeer } from "./session-routing";
25
47
  import type { DingTalkConfig, HandleDingTalkMessageParams, MediaFile } from "./types";
26
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
+ import {
54
+ formatSessionAliasBoundReply,
55
+ formatSessionAliasClearedReply,
56
+ formatSessionAliasReply,
57
+ formatSessionAliasSetReply,
58
+ formatSessionAliasUnboundReply,
59
+ formatSessionAliasValidationErrorReply,
60
+ parseSessionCommand,
61
+ validateSessionAlias,
62
+ } from "./session-command-service";
63
+ 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";
27
79
  import { formatDingTalkErrorPayloadLog, maskSensitiveData } from "./utils";
28
80
 
29
81
  const DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS = 24;
82
+ const MIN_THINKING_REACTION_VISIBLE_MS = 1200;
83
+ const ATTACHMENT_TEXT_PREFIX = "[附件内容摘录]";
30
84
  const proactiveHintLastSentAt = new Map<string, number>();
31
85
 
32
86
  export function resetProactivePermissionHintStateForTest(): void {
@@ -38,6 +92,7 @@ function shouldSendProactivePermissionHint(params: {
38
92
  isDirect: boolean;
39
93
  accountId: string;
40
94
  senderId: string;
95
+ senderOriginalId?: string;
41
96
  senderStaffId?: string;
42
97
  config: DingTalkConfig;
43
98
  nowMs: number;
@@ -56,11 +111,14 @@ function shouldSendProactivePermissionHint(params: {
56
111
  return false;
57
112
  }
58
113
 
59
- const riskObservation = getProactiveRiskObservationForAny(
60
- params.accountId,
61
- [params.senderId, params.senderStaffId],
62
- params.nowMs,
63
- );
114
+ const riskTargets = [params.senderId, params.senderOriginalId, params.senderStaffId]
115
+ .map((id) => (id || "").trim())
116
+ .filter((id, index, arr) => Boolean(id) && arr.indexOf(id) === index);
117
+ if (riskTargets.length === 0) {
118
+ return false;
119
+ }
120
+
121
+ const riskObservation = getProactiveRiskObservationForAny(params.accountId, riskTargets, params.nowMs);
64
122
  if (!riskObservation || riskObservation.source !== "proactive-api") {
65
123
  return false;
66
124
  }
@@ -80,14 +138,43 @@ function shouldSendProactivePermissionHint(params: {
80
138
  return true;
81
139
  }
82
140
 
83
- function isUnhandledStopReasonText(value: string): boolean {
84
- const normalized = value.trim();
85
- if (!normalized) {
86
- return false;
87
- }
88
- return /^Unhandled stop reason:\s*[A-Za-z0-9_-]+/i.test(normalized);
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
+ function sanitizeGroupPromptName(value?: string): string {
149
+ return (value || "")
150
+ .replace(/[\r\n,=]/g, " ")
151
+ .replace(/\s+/g, " ")
152
+ .trim();
89
153
  }
90
154
 
155
+ function buildGroupTurnContextPrompt(params: {
156
+ conversationId: string;
157
+ senderDingtalkId: string;
158
+ senderName?: string;
159
+ }): string {
160
+ const sanitizedSenderName = sanitizeGroupPromptName(params.senderName) || "Unknown";
161
+ return [
162
+ "Current DingTalk group turn context:",
163
+ `- conversationId: ${params.conversationId}`,
164
+ `- senderDingtalkId: ${params.senderDingtalkId}`,
165
+ `- senderName: ${sanitizedSenderName}`,
166
+ "Treat senderDingtalkId and senderName as the authoritative sender for this turn. Do not guess the current sender from GroupMembers.",
167
+ ].join("\n");
168
+ }
169
+
170
+ type ReplyStreamPayload = {
171
+ text?: string;
172
+ };
173
+
174
+ type ReplyChunkInfo = {
175
+ kind?: string;
176
+ };
177
+
91
178
  /**
92
179
  * Download DingTalk media file via runtime media service (sandbox-compatible).
93
180
  * Files are stored in the global media inbound directory.
@@ -148,8 +235,11 @@ export async function downloadMedia(
148
235
  const contentType = mediaResponse.headers["content-type"] || "application/octet-stream";
149
236
  const buffer = Buffer.from(mediaResponse.data as ArrayBuffer);
150
237
 
151
- // Keep inbound media handling consistent with other channels.
152
- const saved = await rt.channel.media.saveMediaBuffer(buffer, contentType, "inbound");
238
+ const maxBytes =
239
+ config.mediaMaxMb && config.mediaMaxMb > 0 ? config.mediaMaxMb * 1024 * 1024 : undefined;
240
+ const saved = maxBytes
241
+ ? await rt.channel.media.saveMediaBuffer(buffer, contentType, "inbound", maxBytes)
242
+ : await rt.channel.media.saveMediaBuffer(buffer, contentType, "inbound");
153
243
  log?.debug?.(`[DingTalk] Media saved: ${saved.path}`);
154
244
  return { path: saved.path, mimeType: saved.contentType ?? contentType };
155
245
  } catch (err: any) {
@@ -183,10 +273,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
183
273
  // Save logger globally so shared services can log consistently without threading log everywhere.
184
274
  setCurrentLogger(log);
185
275
 
186
- log?.debug?.("[DingTalk] Full Inbound Data:", JSON.stringify(maskSensitiveData(data)));
187
-
188
- // Clean up old terminal cards opportunistically on inbound traffic.
189
- cleanupCardCache();
276
+ log?.debug?.("[DingTalk] Full Inbound Data: " + JSON.stringify(maskSensitiveData(data)));
190
277
 
191
278
  // 1) Ignore self messages from bot.
192
279
  if (data.senderId === data.chatbotUserId || data.senderStaffId === data.chatbotUserId) {
@@ -194,13 +281,15 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
194
281
  return;
195
282
  }
196
283
 
197
- const content = extractMessageContent(data);
198
- if (!content.text) {
284
+ const extractedContent = extractMessageContent(data);
285
+ if (!extractedContent.text) {
199
286
  return;
200
287
  }
201
288
 
202
289
  const isDirect = data.conversationType === "1";
203
- const senderId = data.senderStaffId || data.senderId;
290
+ const senderOriginalId = (data.senderId || "").trim();
291
+ const senderStaffId = (data.senderStaffId || "").trim();
292
+ const senderId = senderStaffId || senderOriginalId;
204
293
  const senderName = data.senderNick || "Unknown";
205
294
  const groupId = data.conversationId;
206
295
  const groupName = data.conversationTitle || "Group";
@@ -215,7 +304,8 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
215
304
  isDirect,
216
305
  accountId,
217
306
  senderId,
218
- senderStaffId: data.senderStaffId,
307
+ senderOriginalId,
308
+ senderStaffId,
219
309
  config: dingtalkConfig,
220
310
  nowMs: Date.now(),
221
311
  })
@@ -314,11 +404,29 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
314
404
  }
315
405
  }
316
406
 
407
+ const accountStorePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
408
+ agentId: accountId,
409
+ });
410
+ const currentSessionSourceKind = isDirect ? "direct" : "group";
411
+ const currentSessionSourceId = isDirect ? senderId : groupId;
412
+ const peerIdOverride = getSessionPeerOverride({
413
+ storePath: accountStorePath,
414
+ accountId,
415
+ sourceKind: currentSessionSourceKind,
416
+ sourceId: currentSessionSourceId,
417
+ });
418
+ const sessionPeer = resolveDingTalkSessionPeer({
419
+ isDirect,
420
+ senderId,
421
+ conversationId: groupId,
422
+ peerIdOverride,
423
+ config: dingtalkConfig,
424
+ });
317
425
  const route = rt.channel.routing.resolveAgentRoute({
318
426
  cfg,
319
427
  channel: "dingtalk",
320
428
  accountId,
321
- peer: { kind: isDirect ? "direct" : "group", id: isDirect ? senderId : groupId },
429
+ peer: { kind: sessionPeer.kind, id: sessionPeer.peerId },
322
430
  });
323
431
 
324
432
  // Route resolved before media download for session context and routing metadata.
@@ -326,6 +434,482 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
326
434
  agentId: route.agentId,
327
435
  });
328
436
 
437
+ const to = isDirect ? senderId : groupId;
438
+ const parsedLearnCommand = parseLearnCommand(extractedContent.text);
439
+ const parsedSessionCommand = parseSessionCommand(extractedContent.text);
440
+ const isOwner = isLearningOwner({
441
+ cfg,
442
+ config: dingtalkConfig,
443
+ senderId,
444
+ rawSenderId: data.senderId,
445
+ });
446
+ if (isDirect && parsedLearnCommand.scope === "whoami") {
447
+ await sendBySession(
448
+ dingtalkConfig,
449
+ sessionWebhook,
450
+ formatWhoAmIReply({
451
+ senderId,
452
+ rawSenderId: data.senderId,
453
+ senderStaffId: data.senderStaffId,
454
+ isOwner,
455
+ }),
456
+ { log },
457
+ );
458
+ return;
459
+ }
460
+ if (parsedLearnCommand.scope === "whereami") {
461
+ await sendBySession(
462
+ dingtalkConfig,
463
+ sessionWebhook,
464
+ formatWhereAmIReply({
465
+ conversationId: data.conversationId,
466
+ conversationType: isDirect ? "dm" : "group",
467
+ peerId: sessionPeer.peerId,
468
+ }),
469
+ { log },
470
+ );
471
+ return;
472
+ }
473
+ if (isDirect && parsedLearnCommand.scope === "owner-status") {
474
+ await sendBySession(
475
+ dingtalkConfig,
476
+ sessionWebhook,
477
+ formatOwnerStatusReply({
478
+ senderId,
479
+ rawSenderId: data.senderId,
480
+ isOwner,
481
+ }),
482
+ { log },
483
+ );
484
+ return;
485
+ }
486
+ if (parsedLearnCommand.scope === "help") {
487
+ await sendBySession(dingtalkConfig, sessionWebhook, formatLearnCommandHelp(), { log });
488
+ return;
489
+ }
490
+ 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
507
+ ) {
508
+ await sendBySession(dingtalkConfig, sessionWebhook, formatOwnerOnlyDeniedReply(), { log });
509
+ return;
510
+ }
511
+ if (isOwner) {
512
+ if (parsedSessionCommand.scope === "session-alias-show") {
513
+ await sendBySession(
514
+ dingtalkConfig,
515
+ sessionWebhook,
516
+ formatSessionAliasReply({
517
+ sourceKind: currentSessionSourceKind,
518
+ sourceId: currentSessionSourceId,
519
+ peerId: sessionPeer.peerId,
520
+ aliasSource: peerIdOverride ? "override" : "default",
521
+ }),
522
+ { log },
523
+ );
524
+ return;
525
+ }
526
+ if (parsedSessionCommand.scope === "session-alias-set" && parsedSessionCommand.peerId) {
527
+ const aliasValidationError = validateSessionAlias(parsedSessionCommand.peerId);
528
+ if (aliasValidationError) {
529
+ await sendBySession(
530
+ dingtalkConfig,
531
+ sessionWebhook,
532
+ formatSessionAliasValidationErrorReply(aliasValidationError),
533
+ { log },
534
+ );
535
+ return;
536
+ }
537
+ setSessionPeerOverride({
538
+ storePath: accountStorePath,
539
+ accountId,
540
+ sourceKind: currentSessionSourceKind,
541
+ sourceId: currentSessionSourceId,
542
+ peerId: parsedSessionCommand.peerId,
543
+ });
544
+ await sendBySession(
545
+ dingtalkConfig,
546
+ sessionWebhook,
547
+ formatSessionAliasSetReply({
548
+ sourceKind: currentSessionSourceKind,
549
+ sourceId: currentSessionSourceId,
550
+ peerId: parsedSessionCommand.peerId,
551
+ }),
552
+ { log },
553
+ );
554
+ return;
555
+ }
556
+ if (parsedSessionCommand.scope === "session-alias-clear") {
557
+ clearSessionPeerOverride({
558
+ storePath: accountStorePath,
559
+ accountId,
560
+ sourceKind: currentSessionSourceKind,
561
+ sourceId: currentSessionSourceId,
562
+ });
563
+ await sendBySession(
564
+ dingtalkConfig,
565
+ sessionWebhook,
566
+ formatSessionAliasClearedReply({
567
+ sourceKind: currentSessionSourceKind,
568
+ sourceId: currentSessionSourceId,
569
+ }),
570
+ { log },
571
+ );
572
+ return;
573
+ }
574
+ if (parsedSessionCommand.scope === "session-alias-bind"
575
+ && parsedSessionCommand.sourceKind
576
+ && parsedSessionCommand.sourceId
577
+ && parsedSessionCommand.peerId) {
578
+ const aliasValidationError = validateSessionAlias(parsedSessionCommand.peerId);
579
+ if (aliasValidationError) {
580
+ await sendBySession(
581
+ dingtalkConfig,
582
+ sessionWebhook,
583
+ formatSessionAliasValidationErrorReply(aliasValidationError),
584
+ { log },
585
+ );
586
+ return;
587
+ }
588
+ setSessionPeerOverride({
589
+ storePath: accountStorePath,
590
+ accountId,
591
+ sourceKind: parsedSessionCommand.sourceKind,
592
+ sourceId: parsedSessionCommand.sourceId,
593
+ peerId: parsedSessionCommand.peerId,
594
+ });
595
+ await sendBySession(
596
+ dingtalkConfig,
597
+ sessionWebhook,
598
+ formatSessionAliasBoundReply({
599
+ sourceKind: parsedSessionCommand.sourceKind,
600
+ sourceId: parsedSessionCommand.sourceId,
601
+ peerId: parsedSessionCommand.peerId,
602
+ }),
603
+ { log },
604
+ );
605
+ return;
606
+ }
607
+ if (parsedSessionCommand.scope === "session-alias-unbind"
608
+ && parsedSessionCommand.sourceKind
609
+ && parsedSessionCommand.sourceId) {
610
+ const existed = clearSessionPeerOverride({
611
+ storePath: accountStorePath,
612
+ accountId,
613
+ sourceKind: parsedSessionCommand.sourceKind,
614
+ sourceId: parsedSessionCommand.sourceId,
615
+ });
616
+ await sendBySession(
617
+ dingtalkConfig,
618
+ sessionWebhook,
619
+ formatSessionAliasUnboundReply({
620
+ sourceKind: parsedSessionCommand.sourceKind,
621
+ sourceId: parsedSessionCommand.sourceId,
622
+ existed,
623
+ }),
624
+ { log },
625
+ );
626
+ return;
627
+ }
628
+ if (parsedLearnCommand.scope === "global" && parsedLearnCommand.instruction) {
629
+ const applied = applyManualGlobalLearningRule({
630
+ storePath: accountStorePath,
631
+ accountId,
632
+ instruction: parsedLearnCommand.instruction,
633
+ });
634
+ await sendBySession(
635
+ dingtalkConfig,
636
+ sessionWebhook,
637
+ formatLearnAppliedReply({
638
+ scope: "global",
639
+ instruction: parsedLearnCommand.instruction,
640
+ ruleId: applied?.ruleId,
641
+ }),
642
+ { log },
643
+ );
644
+ return;
645
+ }
646
+ if (parsedLearnCommand.scope === "session" && parsedLearnCommand.instruction) {
647
+ applyManualSessionLearningNote({
648
+ storePath: accountStorePath,
649
+ accountId,
650
+ targetId: data.conversationId,
651
+ instruction: parsedLearnCommand.instruction,
652
+ });
653
+ await sendBySession(
654
+ dingtalkConfig,
655
+ sessionWebhook,
656
+ formatLearnAppliedReply({
657
+ scope: "session",
658
+ instruction: parsedLearnCommand.instruction,
659
+ }),
660
+ { log },
661
+ );
662
+ return;
663
+ }
664
+ if (parsedLearnCommand.scope === "here" && parsedLearnCommand.instruction) {
665
+ const applied = applyManualTargetLearningRule({
666
+ storePath: accountStorePath,
667
+ accountId,
668
+ targetId: data.conversationId,
669
+ instruction: parsedLearnCommand.instruction,
670
+ });
671
+ await sendBySession(
672
+ dingtalkConfig,
673
+ sessionWebhook,
674
+ formatLearnAppliedReply({
675
+ scope: "target",
676
+ targetId: data.conversationId,
677
+ instruction: parsedLearnCommand.instruction,
678
+ ruleId: applied?.ruleId,
679
+ }),
680
+ { log },
681
+ );
682
+ return;
683
+ }
684
+ if (parsedLearnCommand.scope === "target" && parsedLearnCommand.targetId && parsedLearnCommand.instruction) {
685
+ const applied = applyManualTargetLearningRule({
686
+ storePath: accountStorePath,
687
+ accountId,
688
+ targetId: parsedLearnCommand.targetId,
689
+ instruction: parsedLearnCommand.instruction,
690
+ });
691
+ await sendBySession(
692
+ dingtalkConfig,
693
+ sessionWebhook,
694
+ formatLearnAppliedReply({
695
+ scope: "target",
696
+ targetId: parsedLearnCommand.targetId,
697
+ instruction: parsedLearnCommand.instruction,
698
+ ruleId: applied?.ruleId,
699
+ }),
700
+ { log },
701
+ );
702
+ return;
703
+ }
704
+ if (parsedLearnCommand.scope === "targets" && parsedLearnCommand.targetIds?.length && parsedLearnCommand.instruction) {
705
+ const applied = applyManualTargetsLearningRule({
706
+ storePath: accountStorePath,
707
+ accountId,
708
+ targetIds: parsedLearnCommand.targetIds,
709
+ instruction: parsedLearnCommand.instruction,
710
+ });
711
+ await sendBySession(
712
+ dingtalkConfig,
713
+ sessionWebhook,
714
+ formatLearnAppliedReply({
715
+ scope: "targets",
716
+ targetIds: parsedLearnCommand.targetIds,
717
+ instruction: parsedLearnCommand.instruction,
718
+ ruleId: applied[0]?.ruleId,
719
+ }),
720
+ { log },
721
+ );
722
+ return;
723
+ }
724
+ if (parsedLearnCommand.scope === "target-set-create" && parsedLearnCommand.setName && parsedLearnCommand.targetIds?.length) {
725
+ const saved = createOrUpdateTargetSet({
726
+ storePath: accountStorePath,
727
+ accountId,
728
+ name: parsedLearnCommand.setName,
729
+ targetIds: parsedLearnCommand.targetIds,
730
+ });
731
+ await sendBySession(
732
+ dingtalkConfig,
733
+ sessionWebhook,
734
+ saved
735
+ ? formatTargetSetSavedReply({
736
+ setName: parsedLearnCommand.setName,
737
+ targetIds: parsedLearnCommand.targetIds,
738
+ })
739
+ : "目标组保存失败,请检查名称和目标列表。",
740
+ { log },
741
+ );
742
+ return;
743
+ }
744
+ if (parsedLearnCommand.scope === "target-set-apply" && parsedLearnCommand.setName && parsedLearnCommand.instruction) {
745
+ const applied = applyTargetSetLearningRule({
746
+ storePath: accountStorePath,
747
+ accountId,
748
+ name: parsedLearnCommand.setName,
749
+ instruction: parsedLearnCommand.instruction,
750
+ });
751
+ await sendBySession(
752
+ dingtalkConfig,
753
+ sessionWebhook,
754
+ applied.length > 0
755
+ ? 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
+ })
762
+ : `未找到目标组 \`${parsedLearnCommand.setName}\`,或该目标组为空。`,
763
+ { log },
764
+ );
765
+ return;
766
+ }
767
+ if (parsedLearnCommand.scope === "list") {
768
+ const rules = listScopedLearningRules({ storePath: accountStorePath, accountId })
769
+ .slice(0, 20)
770
+ .map((rule) => {
771
+ const scope = rule.scope === "target" ? `target(${rule.targetId})` : "global";
772
+ const status = rule.enabled ? "enabled" : "disabled";
773
+ return `- [${scope}] ${rule.ruleId} (${status}) => ${rule.instruction}`;
774
+ });
775
+ const targetSets = listLearningTargetSets({ storePath: accountStorePath, accountId })
776
+ .slice(0, 10)
777
+ .map((targetSet) => `- [target-set] ${targetSet.name} => ${targetSet.targetIds.join(", ")}`);
778
+ await sendBySession(
779
+ dingtalkConfig,
780
+ sessionWebhook,
781
+ formatLearnListReply([...rules, ...targetSets]),
782
+ { log },
783
+ );
784
+ return;
785
+ }
786
+ if (parsedLearnCommand.scope === "disable" && parsedLearnCommand.ruleId) {
787
+ const result = disableManualRule({
788
+ storePath: accountStorePath,
789
+ accountId,
790
+ ruleId: parsedLearnCommand.ruleId,
791
+ });
792
+ await sendBySession(
793
+ dingtalkConfig,
794
+ sessionWebhook,
795
+ formatLearnDisabledReply({
796
+ ruleId: parsedLearnCommand.ruleId,
797
+ existed: result.existed,
798
+ scope: result.scope,
799
+ targetId: result.targetId,
800
+ }),
801
+ { log },
802
+ );
803
+ return;
804
+ }
805
+ if (parsedLearnCommand.scope === "delete" && parsedLearnCommand.ruleId) {
806
+ const result = deleteManualRule({
807
+ storePath: accountStorePath,
808
+ accountId,
809
+ ruleId: parsedLearnCommand.ruleId,
810
+ });
811
+ await sendBySession(
812
+ dingtalkConfig,
813
+ sessionWebhook,
814
+ formatLearnDeletedReply({
815
+ ruleId: parsedLearnCommand.ruleId,
816
+ existed: result.existed,
817
+ scope: result.scope,
818
+ targetId: result.targetId,
819
+ }),
820
+ { log },
821
+ );
822
+ return;
823
+ }
824
+ }
825
+ const manualForcedReply = resolveManualForcedReply({
826
+ storePath: accountStorePath,
827
+ accountId,
828
+ targetId: data.conversationId,
829
+ content: extractedContent,
830
+ });
831
+ if (manualForcedReply) {
832
+ await sendBySession(dingtalkConfig, sessionWebhook, manualForcedReply, { log });
833
+ return;
834
+ }
835
+ // 3) Select response mode (card vs markdown).
836
+ // Card creation runs BEFORE media download so the user sees immediate visual
837
+ // feedback while large files are still being downloaded.
838
+ let useCardMode = dingtalkConfig.messageType === "card";
839
+ let currentAICard = undefined;
840
+
841
+ if (useCardMode) {
842
+ try {
843
+ log?.debug?.(
844
+ `[DingTalk][AICard] conversationType=${data.conversationType}, conversationId=${to}`,
845
+ );
846
+ const aiCard = await createAICard(dingtalkConfig, to, log, {
847
+ accountId,
848
+ storePath: accountStorePath,
849
+ });
850
+ if (aiCard) {
851
+ currentAICard = aiCard;
852
+ } else {
853
+ useCardMode = false;
854
+ log?.warn?.(
855
+ "[DingTalk] Failed to create AI card (returned null), fallback to text/markdown.",
856
+ );
857
+ }
858
+ } catch (err: any) {
859
+ useCardMode = false;
860
+ log?.warn?.(
861
+ `[DingTalk] Failed to create AI card: ${err.message}, fallback to text/markdown.`,
862
+ );
863
+ }
864
+ }
865
+
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
+ }
896
+ }
897
+
898
+ try {
899
+ appendQuoteJournalEntry({
900
+ storePath,
901
+ accountId,
902
+ conversationId: groupId,
903
+ msgId: data.msgId,
904
+ messageType: content.messageType,
905
+ text: stripQuotedPrefixForJournal(content.text),
906
+ createdAt: data.createAt,
907
+ ttlDays: journalTTLDays,
908
+ });
909
+ } catch (err) {
910
+ log?.warn?.(`[DingTalk] Quote journal append failed: ${String(err)}`);
911
+ }
912
+
329
913
  let mediaPath: string | undefined;
330
914
  let mediaType: string | undefined;
331
915
  if (content.mediaPath && dingtalkConfig.robotCode) {
@@ -335,6 +919,264 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
335
919
  mediaType = media.mimeType;
336
920
  }
337
921
  }
922
+
923
+ // Cache downloadCode (+ spaceId/fileId) for quoted file lookups (DM + group).
924
+ if (content.mediaPath && data.msgId) {
925
+ cacheInboundDownloadCode(
926
+ 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
+ );
934
+ }
935
+
936
+ // User-sent DingTalk doc / Drive file card: cache msgId -> {spaceId,fileId}
937
+ // during the original message turn, and try downloading immediately in DM.
938
+ if (
939
+ content.messageType === "interactiveCardFile" &&
940
+ data.msgId &&
941
+ content.docSpaceId &&
942
+ content.docFileId
943
+ ) {
944
+ cacheInboundDownloadCode(
945
+ accountId,
946
+ data.conversationId,
947
+ data.msgId,
948
+ undefined,
949
+ content.messageType,
950
+ data.createAt,
951
+ { spaceId: content.docSpaceId, fileId: content.docFileId, storePath },
952
+ );
953
+
954
+ if (!mediaPath && isDirect && data.senderStaffId) {
955
+ try {
956
+ const unionId = await getUnionIdByStaffId(dingtalkConfig, data.senderStaffId, log);
957
+ const docMedia = await downloadGroupFile(
958
+ dingtalkConfig,
959
+ content.docSpaceId,
960
+ content.docFileId,
961
+ unionId,
962
+ log,
963
+ );
964
+ if (docMedia) {
965
+ mediaPath = docMedia.path;
966
+ mediaType = docMedia.mimeType;
967
+ }
968
+ } catch (err: any) {
969
+ log?.warn?.(`[DingTalk] Doc card download failed: ${err.message}`);
970
+ }
971
+ }
972
+ }
973
+
974
+ // Try downloading a quoted file from cached downloadCode/spaceId+fileId.
975
+ const tryDownloadFromCache = async (
976
+ quotedMsgId: string | undefined,
977
+ ): Promise<MediaFile | null> => {
978
+ if (!quotedMsgId) {
979
+ return null;
980
+ }
981
+ const cached = getCachedDownloadCode(accountId, data.conversationId, quotedMsgId, storePath);
982
+ if (!cached) {
983
+ return null;
984
+ }
985
+ let media: MediaFile | null = null;
986
+ if (cached.downloadCode) {
987
+ media = await downloadMedia(dingtalkConfig, cached.downloadCode, log);
988
+ }
989
+ if (!media && cached.spaceId && cached.fileId && data.senderStaffId) {
990
+ try {
991
+ const unionId = await getUnionIdByStaffId(dingtalkConfig, data.senderStaffId, log);
992
+ media = await downloadGroupFile(
993
+ dingtalkConfig,
994
+ cached.spaceId,
995
+ cached.fileId,
996
+ unionId,
997
+ log,
998
+ );
999
+ } catch (err: any) {
1000
+ log?.warn?.(`[DingTalk] spaceId+fileId fallback failed: ${err.message}`);
1001
+ }
1002
+ }
1003
+ return media;
1004
+ };
1005
+
1006
+ // Quoted picture: download via existing downloadMedia.
1007
+ if (!mediaPath && content.quoted?.mediaDownloadCode && dingtalkConfig.robotCode) {
1008
+ const media = await downloadMedia(dingtalkConfig, content.quoted.mediaDownloadCode, log);
1009
+ if (media) {
1010
+ mediaPath = media.path;
1011
+ mediaType = media.mimeType;
1012
+ } else {
1013
+ content.text = content.text.replace(
1014
+ content.quoted.prefix,
1015
+ "[引用了一张图片,但下载失败]\n\n",
1016
+ );
1017
+ }
1018
+ }
1019
+
1020
+ // Quoted file/video/audio (unknownMsgType): cache-first, then group file API fallback.
1021
+ if (!mediaPath && content.quoted?.isQuotedFile) {
1022
+ let fileResolved = false;
1023
+
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);
1026
+ if (cachedMedia) {
1027
+ mediaPath = cachedMedia.path;
1028
+ mediaType = cachedMedia.mimeType;
1029
+ fileResolved = true;
1030
+ }
1031
+
1032
+ // Step 2 (group only): Cache miss → fall back to group file API time-based matching.
1033
+ if (!fileResolved && !isDirect) {
1034
+ const resolved = await resolveQuotedFile(
1035
+ dingtalkConfig,
1036
+ {
1037
+ openConversationId: data.conversationId,
1038
+ senderStaffId: data.senderStaffId,
1039
+ fileCreatedAt: content.quoted.fileCreatedAt,
1040
+ },
1041
+ log,
1042
+ );
1043
+ if (resolved) {
1044
+ mediaPath = resolved.media.path;
1045
+ mediaType = resolved.media.mimeType;
1046
+ fileResolved = true;
1047
+ if (content.quoted.msgId) {
1048
+ cacheInboundDownloadCode(
1049
+ 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
+ );
1057
+ }
1058
+ }
1059
+ }
1060
+
1061
+ if (!fileResolved) {
1062
+ log?.warn?.(
1063
+ `[DingTalk] Quoted file unresolved: conversationType=${data.conversationType} conversationId=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
1064
+ );
1065
+ const hint = isDirect
1066
+ ? "[引用了一个文件,内容无法自动获取,请直接发送该文件]\n\n"
1067
+ : "[引用了一个文件,但无法获取内容]\n\n";
1068
+ content.text = content.text.replace(content.quoted.prefix, hint);
1069
+ }
1070
+ }
1071
+
1072
+ // Quoted DingTalk doc / Drive file card:
1073
+ // 1) Prefer msgId-based cached metadata captured when the original doc card
1074
+ // message was seen.
1075
+ // 2) In group chats, if the bot never saw the original doc card message,
1076
+ // reuse the same group-file fallback chain as ordinary quoted files.
1077
+ if (!mediaPath && content.quoted?.isQuotedDocCard) {
1078
+ let docResolved = false;
1079
+
1080
+ const cachedDocMedia = await tryDownloadFromCache(content.quoted.msgId);
1081
+ if (cachedDocMedia) {
1082
+ mediaPath = cachedDocMedia.path;
1083
+ mediaType = cachedDocMedia.mimeType;
1084
+ docResolved = true;
1085
+ content.text = content.text.replace(content.quoted.prefix, "[引用了钉钉文档]\n\n");
1086
+ }
1087
+
1088
+ if (!docResolved && !isDirect && content.quoted.fileCreatedAt) {
1089
+ const resolved = await resolveQuotedFile(
1090
+ dingtalkConfig,
1091
+ {
1092
+ openConversationId: data.conversationId,
1093
+ senderStaffId: data.senderStaffId,
1094
+ fileCreatedAt: content.quoted.fileCreatedAt,
1095
+ },
1096
+ log,
1097
+ );
1098
+ if (resolved) {
1099
+ mediaPath = resolved.media.path;
1100
+ mediaType = resolved.media.mimeType;
1101
+ docResolved = true;
1102
+ content.text = content.text.replace(content.quoted.prefix, "[引用了钉钉文档]\n\n");
1103
+ if (content.quoted.msgId) {
1104
+ cacheInboundDownloadCode(
1105
+ 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
+ );
1113
+ }
1114
+ }
1115
+ }
1116
+
1117
+ if (!docResolved) {
1118
+ log?.warn?.(
1119
+ `[DingTalk] Quoted doc card unresolved: conversationType=${data.conversationType} conversationId=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
1120
+ );
1121
+ const hint = isDirect
1122
+ ? "[引用了钉钉文档,内容无法自动获取,请直接发送该文档]\n\n"
1123
+ : "[引用了钉钉文档,但无法获取内容]\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
+ );
1147
+ }
1148
+ // Card cache miss: prefix already contains "[引用了机器人的回复]", keep as-is.
1149
+ }
1150
+
1151
+ let attachmentExtractedText: string | undefined;
1152
+ if (mediaPath) {
1153
+ try {
1154
+ const extracted = await extractAttachmentText({
1155
+ path: mediaPath,
1156
+ mimeType: mediaType,
1157
+ fileName: data.content?.fileName,
1158
+ });
1159
+ if (extracted?.text) {
1160
+ attachmentExtractedText = `${ATTACHMENT_TEXT_PREFIX}\n${extracted.text}`;
1161
+ }
1162
+ } catch (err: any) {
1163
+ log?.warn?.(`[DingTalk] Failed to extract attachment text: ${err.message}`);
1164
+ }
1165
+ }
1166
+
1167
+ const inboundBody =
1168
+ mediaPath && /<media:[^>]+>/.test(content.text)
1169
+ ? `${content.text}\n[media_path: ${mediaPath}]\n[media_type: ${mediaType || "unknown"}]`
1170
+ : content.text;
1171
+ const inboundText = attachmentExtractedText ? `${inboundBody}\n\n${attachmentExtractedText}` : inboundBody;
1172
+ const learningEnabled = isFeedbackLearningEnabled(dingtalkConfig);
1173
+ const learningContextBlock = buildLearningContextBlock({
1174
+ enabled: learningEnabled,
1175
+ storePath: accountStorePath,
1176
+ accountId,
1177
+ targetId: data.conversationId,
1178
+ content,
1179
+ });
338
1180
  const envelopeOptions = rt.channel.reply.resolveEnvelopeFormatOptions(cfg);
339
1181
  const previousTimestamp = rt.channel.session.readSessionUpdatedAt({
340
1182
  storePath,
@@ -343,11 +1185,18 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
343
1185
 
344
1186
  const groupConfig = !isDirect ? resolveGroupConfig(dingtalkConfig, groupId) : undefined;
345
1187
  // GroupSystemPrompt is injected every turn (not only first-turn intro).
346
- const groupSystemPrompt = !isDirect
347
- ? [`DingTalk group context: conversationId=${groupId}`, groupConfig?.systemPrompt?.trim()]
348
- .filter(Boolean)
349
- .join("\n")
350
- : undefined;
1188
+ const groupSystemPromptParts = !isDirect
1189
+ ? [
1190
+ buildGroupTurnContextPrompt({
1191
+ conversationId: groupId,
1192
+ senderDingtalkId: senderId,
1193
+ senderName,
1194
+ }),
1195
+ groupConfig?.systemPrompt?.trim(),
1196
+ ]
1197
+ : [];
1198
+ const extraSystemPrompt =
1199
+ [...groupSystemPromptParts, learningContextBlock].filter(Boolean).join("\n\n") || undefined;
351
1200
 
352
1201
  if (!isDirect) {
353
1202
  noteGroupMember(storePath, groupId, senderId, senderName);
@@ -359,18 +1208,17 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
359
1208
  channel: "DingTalk",
360
1209
  from: fromLabel,
361
1210
  timestamp: data.createAt,
362
- body: content.text,
1211
+ body: inboundText,
363
1212
  chatType: isDirect ? "direct" : "group",
364
1213
  sender: { name: senderName, id: senderId },
365
1214
  previousTimestamp,
366
1215
  envelope: envelopeOptions,
367
1216
  });
368
1217
 
369
- const to = isDirect ? senderId : groupId;
370
1218
  const ctx = rt.channel.reply.finalizeInboundContext({
371
1219
  Body: body,
372
- RawBody: content.text,
373
- CommandBody: content.text,
1220
+ RawBody: inboundText,
1221
+ CommandBody: inboundText,
374
1222
  From: to,
375
1223
  To: to,
376
1224
  SessionKey: route.sessionKey,
@@ -388,7 +1236,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
388
1236
  MediaType: mediaType,
389
1237
  MediaUrl: mediaPath,
390
1238
  GroupMembers: groupMembers,
391
- GroupSystemPrompt: groupSystemPrompt,
1239
+ GroupSystemPrompt: extraSystemPrompt,
392
1240
  GroupChannel: isDirect ? undefined : route.sessionKey,
393
1241
  CommandAuthorized: commandAuthorized,
394
1242
  OriginatingChannel: "dingtalk",
@@ -407,199 +1255,336 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
407
1255
 
408
1256
  log?.info?.(`[DingTalk] Inbound: from=${senderName} text="${content.text.slice(0, 50)}..."`);
409
1257
 
410
- // 3) Select response mode (card vs markdown).
411
- const useCardMode = dingtalkConfig.messageType === "card";
412
- let currentAICard = undefined;
413
- let lastCardContent = "";
1258
+ const ackReaction =
1259
+ typeof dingtalkConfig.ackReaction === "string"
1260
+ ? dingtalkConfig.ackReaction.trim()
1261
+ : resolveAckReactionSetting({
1262
+ cfg,
1263
+ accountId,
1264
+ agentId: route.agentId,
1265
+ });
1266
+ const resolvedAckReaction =
1267
+ ackReaction === "emoji" ? classifyAckReactionEmoji(content.text).emoji : ackReaction;
1268
+ const shouldAttachAckReaction = Boolean(resolvedAckReaction);
1269
+ let ackReactionAttached = false;
1270
+ let ackReactionAttachedAt = 0;
414
1271
 
415
- if (useCardMode) {
416
- const targetKey = `${accountId}:${to}`;
417
- const existingCardId = getActiveCardIdByTarget(targetKey);
418
- const existingCard = existingCardId ? getCardById(existingCardId) : undefined;
419
-
420
- // Reuse active non-terminal card to keep one card per conversation.
421
- if (existingCard && !isCardInTerminalState(existingCard.state)) {
422
- currentAICard = existingCard;
423
- log?.debug?.("[DingTalk] Reusing existing active AI card for this conversation.");
424
- } else {
425
- try {
426
- const aiCard = await createAICard(dingtalkConfig, to, data, accountId, log);
427
- if (aiCard) {
428
- currentAICard = aiCard;
429
- } else {
430
- log?.warn?.(
431
- "[DingTalk] Failed to create AI card (returned null), fallback to text/markdown.",
432
- );
433
- }
434
- } catch (err: any) {
435
- log?.warn?.(
436
- `[DingTalk] Failed to create AI card: ${err.message}, fallback to text/markdown.`,
437
- );
438
- }
1272
+ if (shouldAttachAckReaction) {
1273
+ ackReactionAttached = await attachNativeAckReaction(
1274
+ dingtalkConfig,
1275
+ {
1276
+ msgId: data.msgId,
1277
+ conversationId: groupId,
1278
+ reactionName: resolvedAckReaction,
1279
+ },
1280
+ log,
1281
+ );
1282
+ if (ackReactionAttached) {
1283
+ ackReactionAttachedAt = Date.now();
439
1284
  }
440
1285
  }
441
1286
 
442
- // 4) Optional "thinking..." feedback for non-card mode.
443
- if (dingtalkConfig.showThinking !== false) {
444
- try {
445
- const thinkingText = "🤔 思考中,请稍候...";
446
- if (useCardMode && currentAICard) {
447
- log?.debug?.("[DingTalk] AI Card in thinking state, skipping thinking message send.");
448
- } else {
449
- lastCardContent = thinkingText;
450
- await sendMessage(dingtalkConfig, to, thinkingText, {
451
- sessionWebhook,
452
- atUserId: !isDirect ? senderId : null,
453
- log,
454
- accountId,
455
- });
456
- }
457
- } catch (err: any) {
458
- log?.debug?.(`[DingTalk] Thinking message failed: ${err.message}`);
459
- if (err?.response?.data !== undefined) {
460
- log?.debug?.(formatDingTalkErrorPayloadLog("inbound.thinkingMessage", err.response.data));
461
- }
1287
+ // Serialize dispatchReply + card finalize per session to prevent the runtime
1288
+ // from receiving concurrent dispatch calls on the same session key, which
1289
+ // causes empty replies for all but the first caller.
1290
+ const releaseSessionLock = await acquireSessionLock(route.sessionKey);
1291
+ try {
1292
+ if (!ackReactionAttached && shouldAttachAckReaction) {
1293
+ log?.debug?.("[DingTalk] Native ack reaction unavailable; skipping fallback.");
462
1294
  }
463
- }
464
1295
 
465
- const { queuedFinal } = await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
466
- ctx,
467
- cfg,
468
- dispatcherOptions: {
469
- responsePrefix: "",
470
- deliver: async (payload: any, info?: { kind: string }) => {
471
- try {
472
- const textToSend = payload.markdown || payload.text;
473
- if (!textToSend) {
474
- return;
475
- }
1296
+ const controller = useCardMode && currentAICard
1297
+ ? createCardDraftController({ card: currentAICard, log })
1298
+ : undefined;
1299
+ let cardFinalized = false;
1300
+ let finalTextForFallback: string | undefined;
476
1301
 
477
- if (typeof textToSend === "string" && isUnhandledStopReasonText(textToSend)) {
478
- log?.warn?.(`[DingTalk] Suppressed stop reason from outbound chat content: ${textToSend}`);
479
- return;
480
- }
1302
+ try {
1303
+ await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1304
+ ctx,
1305
+ cfg,
1306
+ dispatcherOptions: {
1307
+ responsePrefix: "",
1308
+ 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
+ }
481
1348
 
482
- if (useCardMode && currentAICard && info?.kind === "final") {
483
- lastCardContent = textToSend;
484
- return;
485
- }
1349
+ 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
+ : [];
486
1360
 
487
- // Tool outputs are rendered into card stream as a separate formatted block.
488
- if (useCardMode && currentAICard && info?.kind === "tool") {
489
- if (isCardInTerminalState(currentAICard.state)) {
490
- log?.debug?.(
491
- `[DingTalk] Skipping tool stream update because card is terminal: state=${currentAICard.state}`,
492
- );
493
- return;
494
- }
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
+ }
495
1370
 
496
- log?.info?.(
497
- `[DingTalk] Tool result received, streaming to AI Card: ${textToSend.slice(0, 100)}`,
498
- );
499
- const toolText = formatContentForCard(textToSend, "tool");
500
- if (toolText) {
501
- await streamAICard(currentAICard, toolText, false, log);
502
- return;
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));
1451
+ }
1452
+ throw err;
503
1453
  }
504
- }
1454
+ },
1455
+ },
1456
+ replyOptions: {
1457
+ disableBlockStreaming: dingtalkConfig.cardRealTimeStream && controller ? true : undefined,
505
1458
 
506
- lastCardContent = textToSend;
507
- await sendMessage(dingtalkConfig, to, textToSend, {
508
- sessionWebhook,
509
- atUserId: !isDirect ? senderId : null,
510
- log,
511
- accountId,
512
- });
513
- } catch (err: any) {
514
- log?.error?.(`[DingTalk] Reply failed: ${err.message}`);
515
- if (err?.response?.data !== undefined) {
516
- log?.error?.(formatDingTalkErrorPayloadLog("inbound.replyDeliver", err.response.data));
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
+ },
1479
+ });
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();
517
1491
  }
518
- throw err;
519
- }
520
- },
521
- },
522
- replyOptions: {
523
- // Real-time reasoning stream support for card mode.
524
- onReasoningStream: async (payload: any) => {
525
- if (!useCardMode || !currentAICard) {
526
- return;
527
1492
  }
528
- if (isCardInTerminalState(currentAICard.state)) {
529
- log?.debug?.(
530
- `[DingTalk] Skipping thinking stream update because card is terminal: state=${currentAICard.state}`,
1493
+ }
1494
+ throw dispatchErr;
1495
+ }
1496
+
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`,
531
1514
  );
532
1515
  return;
533
1516
  }
534
- const thinkingText = formatContentForCard(payload.text, "thinking");
535
- if (!thinkingText) {
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
+ }
536
1539
  return;
537
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
+ }
538
1559
  try {
539
- await streamAICard(currentAICard, thinkingText, false, log);
540
- } catch (err: any) {
541
- log?.debug?.(`[DingTalk] Thinking stream update failed: ${err.message}`);
542
- if (err?.response?.data !== undefined) {
543
- log?.debug?.(formatDingTalkErrorPayloadLog("inbound.thinkingStream", err.response.data));
1560
+ if (currentAICard.state !== AICardStatus.FINISHED) {
1561
+ currentAICard.state = AICardStatus.FAILED;
1562
+ currentAICard.lastUpdated = Date.now();
544
1563
  }
1564
+ } catch (stateErr: any) {
1565
+ log?.debug?.(`[DingTalk] Failed to update card state to FAILED: ${stateErr.message}`);
545
1566
  }
546
- },
547
- },
548
- });
549
-
550
- // 5) Finalize card stream if card mode is active.
551
- if (useCardMode && currentAICard) {
552
- try {
553
- if (isCardInTerminalState(currentAICard.state)) {
554
- log?.debug?.(
555
- `[DingTalk] Skipping AI Card finalization because card is terminal: state=${currentAICard.state}`,
556
- );
557
- return;
558
1567
  }
559
-
560
- const isNonEmptyString = (value: any): boolean =>
561
- typeof value === "string" && value.trim().length > 0;
562
-
563
- const hasLastCardContent = isNonEmptyString(lastCardContent);
564
- const hasQueuedFinalString = isNonEmptyString(queuedFinal);
565
-
566
- if (hasLastCardContent || hasQueuedFinalString) {
567
- const finalContentCandidate =
568
- hasLastCardContent && typeof lastCardContent === "string"
569
- ? lastCardContent
570
- : typeof queuedFinal === "string"
571
- ? queuedFinal
572
- : "";
573
- if (isUnhandledStopReasonText(finalContentCandidate)) {
574
- log?.warn?.(
575
- `[DingTalk] Suppressed stop reason from AI Card final content: ${finalContentCandidate}`,
576
- );
577
- currentAICard.state = AICardStatus.FINISHED;
578
- currentAICard.lastUpdated = Date.now();
579
- return;
1568
+ }
1569
+ } finally {
1570
+ 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));
580
1577
  }
581
- const finalContent = finalContentCandidate;
582
- await finishAICard(currentAICard, finalContent, log);
583
- } else {
584
- const defaultFinalContent = "✅ Done";
585
- log?.debug?.(
586
- "[DingTalk] No textual content was produced; finalizing AI Card with default completion content.",
1578
+ await recallNativeAckReactionWithRetry(
1579
+ dingtalkConfig,
1580
+ {
1581
+ msgId: data.msgId,
1582
+ conversationId: groupId,
1583
+ reactionName: resolvedAckReaction,
1584
+ },
1585
+ log,
587
1586
  );
588
- await finishAICard(currentAICard, defaultFinalContent, log);
589
- }
590
- } catch (err: any) {
591
- log?.debug?.(`[DingTalk] AI Card finalization failed: ${err.message}`);
592
- if (err?.response?.data !== undefined) {
593
- log?.debug?.(formatDingTalkErrorPayloadLog("inbound.cardFinalize", err.response.data));
594
- }
595
- try {
596
- if (currentAICard.state !== AICardStatus.FINISHED) {
597
- currentAICard.state = AICardStatus.FAILED;
598
- currentAICard.lastUpdated = Date.now();
599
- }
600
- } catch (stateErr: any) {
601
- log?.debug?.(`[DingTalk] Failed to update card state to FAILED: ${stateErr.message}`);
602
- }
1587
+ })();
603
1588
  }
604
1589
  }
605
1590
  }