@soimy/dingtalk 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,29 +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
6
  createAICard,
7
+ findCardContent,
6
8
  finishAICard,
7
9
  formatContentForCard,
10
+ getCardContentByProcessQueryKey,
8
11
  isCardInTerminalState,
9
12
  } from "./card-service";
10
- import { resolveGroupConfig } from "./config";
13
+ import { classifyAckReactionEmoji } from "./ack-reaction-classifier";
14
+ import { resolveAckReactionSetting, resolveGroupConfig } from "./config";
11
15
  import { formatGroupMembers, noteGroupMember } from "./group-members-store";
12
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";
13
31
  import { extractMessageContent } from "./message-utils";
32
+ import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
14
33
  import { registerPeerId } from "./peer-id-registry";
15
34
  import {
16
35
  clearProactiveRiskObservationsForTest,
17
36
  getProactiveRiskObservationForAny,
18
37
  } from "./proactive-risk-registry";
38
+ import {
39
+ appendQuoteJournalEntry,
40
+ DEFAULT_JOURNAL_TTL_DAYS,
41
+ resolveQuotedMessageById,
42
+ } from "./quote-journal";
19
43
  import { getDingTalkRuntime } from "./runtime";
20
- 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";
21
47
  import type { DingTalkConfig, HandleDingTalkMessageParams, MediaFile } from "./types";
22
48
  import { AICardStatus } from "./types";
49
+ import { createCardDraftController } from "./card-draft-controller";
23
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";
24
79
  import { formatDingTalkErrorPayloadLog, maskSensitiveData } from "./utils";
25
80
 
26
81
  const DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS = 24;
82
+ const MIN_THINKING_REACTION_VISIBLE_MS = 1200;
83
+ const ATTACHMENT_TEXT_PREFIX = "[附件内容摘录]";
27
84
  const proactiveHintLastSentAt = new Map<string, number>();
28
85
 
29
86
  export function resetProactivePermissionHintStateForTest(): void {
@@ -35,6 +92,7 @@ function shouldSendProactivePermissionHint(params: {
35
92
  isDirect: boolean;
36
93
  accountId: string;
37
94
  senderId: string;
95
+ senderOriginalId?: string;
38
96
  senderStaffId?: string;
39
97
  config: DingTalkConfig;
40
98
  nowMs: number;
@@ -53,11 +111,14 @@ function shouldSendProactivePermissionHint(params: {
53
111
  return false;
54
112
  }
55
113
 
56
- const riskObservation = getProactiveRiskObservationForAny(
57
- params.accountId,
58
- [params.senderId, params.senderStaffId],
59
- params.nowMs,
60
- );
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);
61
122
  if (!riskObservation || riskObservation.source !== "proactive-api") {
62
123
  return false;
63
124
  }
@@ -77,14 +138,43 @@ function shouldSendProactivePermissionHint(params: {
77
138
  return true;
78
139
  }
79
140
 
80
- function isUnhandledStopReasonText(value: string): boolean {
81
- const normalized = value.trim();
82
- if (!normalized) {
83
- return false;
84
- }
85
- 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();
153
+ }
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");
86
168
  }
87
169
 
170
+ type ReplyStreamPayload = {
171
+ text?: string;
172
+ };
173
+
174
+ type ReplyChunkInfo = {
175
+ kind?: string;
176
+ };
177
+
88
178
  /**
89
179
  * Download DingTalk media file via runtime media service (sandbox-compatible).
90
180
  * Files are stored in the global media inbound directory.
@@ -183,7 +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)));
276
+ log?.debug?.("[DingTalk] Full Inbound Data: " + JSON.stringify(maskSensitiveData(data)));
187
277
 
188
278
  // 1) Ignore self messages from bot.
189
279
  if (data.senderId === data.chatbotUserId || data.senderStaffId === data.chatbotUserId) {
@@ -191,13 +281,15 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
191
281
  return;
192
282
  }
193
283
 
194
- const content = extractMessageContent(data);
195
- if (!content.text) {
284
+ const extractedContent = extractMessageContent(data);
285
+ if (!extractedContent.text) {
196
286
  return;
197
287
  }
198
288
 
199
289
  const isDirect = data.conversationType === "1";
200
- const senderId = data.senderStaffId || data.senderId;
290
+ const senderOriginalId = (data.senderId || "").trim();
291
+ const senderStaffId = (data.senderStaffId || "").trim();
292
+ const senderId = senderStaffId || senderOriginalId;
201
293
  const senderName = data.senderNick || "Unknown";
202
294
  const groupId = data.conversationId;
203
295
  const groupName = data.conversationTitle || "Group";
@@ -212,7 +304,8 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
212
304
  isDirect,
213
305
  accountId,
214
306
  senderId,
215
- senderStaffId: data.senderStaffId,
307
+ senderOriginalId,
308
+ senderStaffId,
216
309
  config: dingtalkConfig,
217
310
  nowMs: Date.now(),
218
311
  })
@@ -311,11 +404,29 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
311
404
  }
312
405
  }
313
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
+ });
314
425
  const route = rt.channel.routing.resolveAgentRoute({
315
426
  cfg,
316
427
  channel: "dingtalk",
317
428
  accountId,
318
- peer: { kind: isDirect ? "direct" : "group", id: isDirect ? senderId : groupId },
429
+ peer: { kind: sessionPeer.kind, id: sessionPeer.peerId },
319
430
  });
320
431
 
321
432
  // Route resolved before media download for session context and routing metadata.
@@ -324,34 +435,481 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
324
435
  });
325
436
 
326
437
  const to = isDirect ? senderId : groupId;
327
-
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
+ }
328
835
  // 3) Select response mode (card vs markdown).
329
836
  // Card creation runs BEFORE media download so the user sees immediate visual
330
837
  // feedback while large files are still being downloaded.
331
- const useCardMode = dingtalkConfig.messageType === "card";
838
+ let useCardMode = dingtalkConfig.messageType === "card";
332
839
  let currentAICard = undefined;
333
- let lastCardContent = "";
334
840
 
335
841
  if (useCardMode) {
336
842
  try {
337
843
  log?.debug?.(
338
844
  `[DingTalk][AICard] conversationType=${data.conversationType}, conversationId=${to}`,
339
845
  );
340
- const aiCard = await createAICard(dingtalkConfig, to, log);
846
+ const aiCard = await createAICard(dingtalkConfig, to, log, {
847
+ accountId,
848
+ storePath: accountStorePath,
849
+ });
341
850
  if (aiCard) {
342
851
  currentAICard = aiCard;
343
852
  } else {
853
+ useCardMode = false;
344
854
  log?.warn?.(
345
855
  "[DingTalk] Failed to create AI card (returned null), fallback to text/markdown.",
346
856
  );
347
857
  }
348
858
  } catch (err: any) {
859
+ useCardMode = false;
349
860
  log?.warn?.(
350
861
  `[DingTalk] Failed to create AI card: ${err.message}, fallback to text/markdown.`,
351
862
  );
352
863
  }
353
864
  }
354
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
+
355
913
  let mediaPath: string | undefined;
356
914
  let mediaType: string | undefined;
357
915
  if (content.mediaPath && dingtalkConfig.robotCode) {
@@ -361,6 +919,264 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
361
919
  mediaType = media.mimeType;
362
920
  }
363
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
+ });
364
1180
  const envelopeOptions = rt.channel.reply.resolveEnvelopeFormatOptions(cfg);
365
1181
  const previousTimestamp = rt.channel.session.readSessionUpdatedAt({
366
1182
  storePath,
@@ -369,11 +1185,18 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
369
1185
 
370
1186
  const groupConfig = !isDirect ? resolveGroupConfig(dingtalkConfig, groupId) : undefined;
371
1187
  // GroupSystemPrompt is injected every turn (not only first-turn intro).
372
- const groupSystemPrompt = !isDirect
373
- ? [`DingTalk group context: conversationId=${groupId}`, groupConfig?.systemPrompt?.trim()]
374
- .filter(Boolean)
375
- .join("\n")
376
- : 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;
377
1200
 
378
1201
  if (!isDirect) {
379
1202
  noteGroupMember(storePath, groupId, senderId, senderName);
@@ -385,7 +1208,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
385
1208
  channel: "DingTalk",
386
1209
  from: fromLabel,
387
1210
  timestamp: data.createAt,
388
- body: content.text,
1211
+ body: inboundText,
389
1212
  chatType: isDirect ? "direct" : "group",
390
1213
  sender: { name: senderName, id: senderId },
391
1214
  previousTimestamp,
@@ -394,8 +1217,8 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
394
1217
 
395
1218
  const ctx = rt.channel.reply.finalizeInboundContext({
396
1219
  Body: body,
397
- RawBody: content.text,
398
- CommandBody: content.text,
1220
+ RawBody: inboundText,
1221
+ CommandBody: inboundText,
399
1222
  From: to,
400
1223
  To: to,
401
1224
  SessionKey: route.sessionKey,
@@ -413,7 +1236,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
413
1236
  MediaType: mediaType,
414
1237
  MediaUrl: mediaPath,
415
1238
  GroupMembers: groupMembers,
416
- GroupSystemPrompt: groupSystemPrompt,
1239
+ GroupSystemPrompt: extraSystemPrompt,
417
1240
  GroupChannel: isDirect ? undefined : route.sessionKey,
418
1241
  CommandAuthorized: commandAuthorized,
419
1242
  OriginatingChannel: "dingtalk",
@@ -432,98 +1255,194 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
432
1255
 
433
1256
  log?.info?.(`[DingTalk] Inbound: from=${senderName} text="${content.text.slice(0, 50)}..."`);
434
1257
 
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;
1271
+
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();
1284
+ }
1285
+ }
1286
+
435
1287
  // Serialize dispatchReply + card finalize per session to prevent the runtime
436
1288
  // from receiving concurrent dispatch calls on the same session key, which
437
1289
  // causes empty replies for all but the first caller.
438
1290
  const releaseSessionLock = await acquireSessionLock(route.sessionKey);
439
1291
  try {
440
- // 4) Optional "thinking..." feedback for non-card mode.
441
- if (dingtalkConfig.showThinking !== false) {
442
- try {
443
- const thinkingText = "🤔 思考中,请稍候...";
444
- if (useCardMode && currentAICard) {
445
- log?.debug?.("[DingTalk] AI Card in thinking state, skipping thinking message send.");
446
- } else {
447
- lastCardContent = thinkingText;
448
- const sendResult = await sendMessage(dingtalkConfig, to, thinkingText, {
449
- sessionWebhook,
450
- atUserId: !isDirect ? senderId : null,
451
- log,
452
- card: currentAICard,
453
- });
454
- if (!sendResult.ok) {
455
- throw new Error(sendResult.error || "Thinking message send failed");
456
- }
457
- }
458
- } catch (err: any) {
459
- log?.debug?.(`[DingTalk] Thinking message failed: ${err.message}`);
460
- if (err?.response?.data !== undefined) {
461
- log?.debug?.(formatDingTalkErrorPayloadLog("inbound.thinkingMessage", err.response.data));
462
- }
463
- }
1292
+ if (!ackReactionAttached && shouldAttachAckReaction) {
1293
+ log?.debug?.("[DingTalk] Native ack reaction unavailable; skipping fallback.");
464
1294
  }
465
1295
 
466
- let queuedFinal: unknown;
1296
+ const controller = useCardMode && currentAICard
1297
+ ? createCardDraftController({ card: currentAICard, log })
1298
+ : undefined;
1299
+ let cardFinalized = false;
1300
+ let finalTextForFallback: string | undefined;
1301
+
467
1302
  try {
468
- const dispatchResult = await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1303
+ await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
469
1304
  ctx,
470
1305
  cfg,
471
1306
  dispatcherOptions: {
472
1307
  responsePrefix: "",
473
- deliver: async (payload: any, info?: { kind: string }) => {
474
- try {
475
- const textToSend = payload.markdown || payload.text;
476
- if (!textToSend) {
477
- return;
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
+ }
478
1346
  }
1347
+ }
479
1348
 
480
- if (typeof textToSend === "string" && isUnhandledStopReasonText(textToSend)) {
481
- log?.warn?.(`[DingTalk] Suppressed stop reason from outbound chat content: ${textToSend}`);
482
- return;
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
+ : [];
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
+ }
483
1369
  }
484
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.
485
1377
  if (useCardMode && currentAICard && info?.kind === "final") {
486
- lastCardContent = textToSend;
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
+ }
487
1392
  return;
488
1393
  }
489
1394
 
1395
+ // ---- card mode: tool ----
490
1396
  if (useCardMode && currentAICard && info?.kind === "tool") {
491
- if (isCardInTerminalState(currentAICard.state)) {
492
- log?.debug?.(
493
- `[DingTalk] Skipping tool stream update because card is terminal: state=${currentAICard.state}`,
494
- );
1397
+ if (controller!.isFailed() || isCardInTerminalState(currentAICard.state)) {
1398
+ log?.debug?.("[DingTalk] Card failed, skipping tool result (will send full reply on final)");
495
1399
  return;
496
1400
  }
497
-
1401
+ await controller!.flush();
1402
+ await controller!.waitForInFlight();
498
1403
  log?.info?.(
499
- `[DingTalk] Tool result received, streaming to AI Card: ${textToSend.slice(0, 100)}`,
1404
+ `[DingTalk] Tool result received, streaming to AI Card: ${(textToSend ?? "").slice(0, 100)}`,
500
1405
  );
501
- const toolText = formatContentForCard(textToSend, "tool");
1406
+ const toolText = typeof textToSend === "string" ? formatContentForCard(textToSend, "tool") : "";
502
1407
  if (toolText) {
503
1408
  const sendResult = await sendMessage(dingtalkConfig, to, toolText, {
504
1409
  sessionWebhook,
505
1410
  atUserId: !isDirect ? senderId : null,
506
1411
  log,
507
1412
  card: currentAICard,
1413
+ accountId,
1414
+ storePath,
1415
+ conversationId: groupId,
508
1416
  cardUpdateMode: "append",
509
1417
  });
510
1418
  if (!sendResult.ok) {
511
1419
  throw new Error(sendResult.error || "Tool stream send failed");
512
1420
  }
513
- lastCardContent = currentAICard.lastStreamedContent || toolText;
514
- return;
515
1421
  }
1422
+ return;
1423
+ }
1424
+
1425
+ // ---- media delivery (all modes) ----
1426
+ if (mediaUrls.length > 0) {
1427
+ await deliverMediaAttachments(mediaUrls);
516
1428
  }
517
1429
 
518
- lastCardContent = textToSend;
519
- const sendResult = await sendMessage(dingtalkConfig, to, textToSend, {
520
- sessionWebhook,
521
- atUserId: !isDirect ? senderId : null,
522
- log,
523
- card: currentAICard,
524
- });
525
- if (!sendResult.ok) {
526
- throw new Error(sendResult.error || "Reply send failed");
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
+ }
527
1446
  }
528
1447
  } catch (err: any) {
529
1448
  log?.error?.(`[DingTalk] Reply failed: ${err.message}`);
@@ -535,99 +1454,103 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
535
1454
  },
536
1455
  },
537
1456
  replyOptions: {
538
- onReasoningStream: async (payload: any) => {
539
- if (!useCardMode || !currentAICard) {
540
- return;
541
- }
542
- if (isCardInTerminalState(currentAICard.state)) {
543
- log?.debug?.(
544
- `[DingTalk] Skipping thinking stream update because card is terminal: state=${currentAICard.state}`,
545
- );
546
- return;
547
- }
548
- const thinkingText = formatContentForCard(payload.text, "thinking");
549
- if (!thinkingText) {
550
- return;
551
- }
552
- try {
553
- const sendResult = await sendMessage(dingtalkConfig, to, thinkingText, {
554
- sessionWebhook,
555
- atUserId: !isDirect ? senderId : null,
556
- log,
557
- card: currentAICard,
558
- cardUpdateMode: "append",
559
- });
560
- if (!sendResult.ok) {
561
- throw new Error(sendResult.error || "Thinking stream send failed");
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
+ }
562
1468
  }
563
- } catch (err: any) {
564
- log?.debug?.(`[DingTalk] Thinking stream update failed: ${err.message}`);
565
- if (err?.response?.data !== undefined) {
566
- log?.debug?.(formatDingTalkErrorPayloadLog("inbound.thinkingStream", err.response.data));
1469
+ : undefined,
1470
+
1471
+ onReasoningStream: controller
1472
+ ? (payload: ReplyStreamPayload) => {
1473
+ if (payload.text) {
1474
+ controller.updateReasoning(payload.text);
1475
+ }
567
1476
  }
568
- }
569
- },
1477
+ : undefined,
570
1478
  },
571
1479
  });
572
- queuedFinal = dispatchResult?.queuedFinal;
573
1480
  } catch (dispatchErr: any) {
574
1481
  if (useCardMode && currentAICard && !isCardInTerminalState(currentAICard.state)) {
575
- try {
576
- await finishAICard(currentAICard, "❌ 处理失败", log);
577
- } catch (cardCloseErr: any) {
578
- log?.debug?.(`[DingTalk] Failed to finalize card after dispatch error: ${cardCloseErr.message}`);
579
- currentAICard.state = AICardStatus.FAILED;
580
- currentAICard.lastUpdated = Date.now();
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
+ }
581
1492
  }
582
1493
  }
583
1494
  throw dispatchErr;
584
1495
  }
585
1496
 
586
- // 5) Finalize card stream if card mode is active.
587
- if (useCardMode && currentAICard) {
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) {
588
1510
  try {
589
- if (isCardInTerminalState(currentAICard.state)) {
590
- log?.debug?.(
591
- `[DingTalk] Skipping AI Card finalization because card is terminal: state=${currentAICard.state}`,
1511
+ if (currentAICard.state === AICardStatus.FINISHED) {
1512
+ log?.info?.(
1513
+ `[DingTalk][Finalize] Skipping card already FINISHED`,
592
1514
  );
593
1515
  return;
594
1516
  }
595
1517
 
596
- const isNonEmptyString = (value: any): boolean =>
597
- typeof value === "string" && value.trim().length > 0;
598
-
599
- const hasLastCardContent = isNonEmptyString(lastCardContent);
600
- const hasQueuedFinalString = isNonEmptyString(queuedFinal);
601
-
602
- if (hasLastCardContent || hasQueuedFinalString) {
603
- const finalContentCandidate =
604
- hasLastCardContent && typeof lastCardContent === "string"
605
- ? lastCardContent
606
- : typeof queuedFinal === "string"
607
- ? queuedFinal
608
- : "";
609
- if (isUnhandledStopReasonText(finalContentCandidate)) {
610
- log?.warn?.(
611
- `[DingTalk] Suppressed stop reason from AI Card final content: ${finalContentCandidate}`,
612
- );
613
- currentAICard.state = AICardStatus.FINISHED;
614
- currentAICard.lastUpdated = Date.now();
615
- return;
616
- }
617
- const finalContent = finalContentCandidate;
618
- await finishAICard(currentAICard, finalContent, log);
619
- } else {
620
- const lastStreamed = currentAICard.lastStreamedContent;
621
- if (typeof lastStreamed === "string" && lastStreamed.trim().length > 0) {
622
- await finishAICard(currentAICard, lastStreamed, log);
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
+ }
623
1536
  } else {
624
- const defaultFinalContent = "✅ Done";
625
- log?.debug?.(
626
- "[DingTalk] No textual content was produced; finalizing AI Card with default completion content.",
627
- );
628
- await finishAICard(currentAICard, defaultFinalContent, log);
1537
+ log?.debug?.("[DingTalk] Card failed but no content to fallback with");
629
1538
  }
1539
+ return;
630
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);
631
1554
  } catch (err: any) {
632
1555
  log?.debug?.(`[DingTalk] AI Card finalization failed: ${err.message}`);
633
1556
  if (err?.response?.data !== undefined) {
@@ -645,5 +1568,23 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
645
1568
  }
646
1569
  } finally {
647
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));
1577
+ }
1578
+ await recallNativeAckReactionWithRetry(
1579
+ dingtalkConfig,
1580
+ {
1581
+ msgId: data.msgId,
1582
+ conversationId: groupId,
1583
+ reactionName: resolvedAckReaction,
1584
+ },
1585
+ log,
1586
+ );
1587
+ })();
1588
+ }
648
1589
  }
649
1590
  }