@soimy/dingtalk 3.5.0 → 3.5.2

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,43 +1,26 @@
1
- import axios from "axios";
1
+ import fs from "node:fs";
2
+ import axios from "./http-client";
2
3
  import { normalizeAllowFrom, isSenderAllowed, resolveGroupAccess } from "./access-control";
3
4
  import { buildAgentSessionKey, resolveSubAgentRoute, dispatchSubAgents } from "./targeting/agent-routing";
4
5
  import { classifyAckReactionEmoji } from "./ack-reaction-classifier";
5
6
  import { attachNativeAckReaction } from "./ack-reaction-service";
6
7
  import { createDynamicAckReactionController } from "./ack-reaction/dynamic-ack-reaction-controller";
7
- import { extractAttachmentText } from "./attachment-text-extractor";
8
+ import { extractAttachmentText } from "./messaging/attachment-text-extractor";
8
9
  import { getAccessToken } from "./auth";
9
10
  import { createAICard, finishAICard, isCardInTerminalState } from "./card-service";
11
+ import { handleInboundCommandDispatch } from "./command/inbound-command-dispatch-service";
10
12
  import { resolveAckReactionSetting, resolveGroupConfig, resolveRobotCode } from "./config";
13
+ import { AICardStatus } from "./types";
14
+ import {
15
+ isCardRunStopRequested,
16
+ registerCardRun,
17
+ removeCardRun,
18
+ } from "./card/card-run-registry";
11
19
  import {
12
- applyManualTargetLearningRule,
13
- applyManualTargetsLearningRule,
14
- applyManualGlobalLearningRule,
15
- applyManualSessionLearningNote,
16
- applyTargetSetLearningRule,
17
20
  buildLearningContextBlock,
18
- createOrUpdateTargetSet,
19
- deleteManualRule,
20
- disableManualRule,
21
21
  isLearningEnabled,
22
- listLearningTargetSets,
23
- listScopedLearningRules,
24
- resolveManualForcedReply,
25
22
  } from "./feedback-learning-service";
26
- import { formatGroupMembers, noteGroupMember } from "./group-members-store";
27
- import {
28
- formatLearnAppliedReply,
29
- formatLearnCommandHelp,
30
- formatLearnDeletedReply,
31
- formatLearnDisabledReply,
32
- formatLearnListReply,
33
- formatOwnerOnlyDeniedReply,
34
- formatOwnerStatusReply,
35
- formatTargetSetSavedReply,
36
- formatWhereAmIReply,
37
- formatWhoAmIReply,
38
- isLearningOwner,
39
- parseLearnCommand,
40
- } from "./learning-command-service";
23
+ import { formatGroupMembers, noteGroupMember } from "./targeting/group-members-store";
41
24
  import { setCurrentLogger } from "./logger-context";
42
25
  import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
43
26
  import {
@@ -57,21 +40,11 @@ import {
57
40
  clearProactiveRiskObservationsForTest,
58
41
  getProactiveRiskObservationForAny,
59
42
  } from "./proactive-risk-registry";
60
- import { downloadGroupFile, getUnionIdByStaffId, resolveQuotedFile } from "./quoted-file-service";
43
+ import { downloadGroupFile, getUnionIdByStaffId, resolveQuotedFile } from "./messaging/quoted-file-service";
61
44
  import { createReplyStrategy } from "./reply-strategy";
62
45
  import type { DeliverPayload } from "./reply-strategy";
63
46
  import { getDingTalkRuntime } from "./runtime";
64
47
  import { sendBySession, sendMessage, sendProactiveMedia } from "./send-service";
65
- import {
66
- formatSessionAliasBoundReply,
67
- formatSessionAliasClearedReply,
68
- formatSessionAliasReply,
69
- formatSessionAliasSetReply,
70
- formatSessionAliasUnboundReply,
71
- formatSessionAliasValidationErrorReply,
72
- parseSessionCommand,
73
- validateSessionAlias,
74
- } from "./session-command-service";
75
48
  import { acquireSessionLock } from "./session-lock";
76
49
  import {
77
50
  clearSessionPeerOverride,
@@ -83,8 +56,7 @@ import {
83
56
  upsertObservedGroupTarget,
84
57
  upsertObservedUserTarget,
85
58
  } from "./targeting/target-directory-store";
86
- import { AICardStatus } from "./types";
87
- import type { DingTalkConfig, HandleDingTalkMessageParams, MediaFile } from "./types";
59
+ import type { DingTalkConfig, HandleDingTalkMessageParams, Logger, MediaFile } from "./types";
88
60
  import { formatDingTalkErrorPayloadLog, getErrorMessage, getErrorResponseData, maskSensitiveData } from "./utils";
89
61
  import { isAbortRequestText } from "openclaw/plugin-sdk/reply-runtime";
90
62
 
@@ -93,6 +65,72 @@ const MIN_THINKING_REACTION_VISIBLE_MS = 1200;
93
65
  const MAX_DYNAMIC_ACK_DISPOSE_WAIT_MS = 500;
94
66
  const ATTACHMENT_TEXT_PREFIX = "[附件内容摘录]";
95
67
  const proactiveHintLastSentAt = new Map<string, number>();
68
+ const sessionReasoningLevelCache = new Map<string, {
69
+ updatedAt?: number;
70
+ reasoningLevel?: string;
71
+ }>();
72
+ type ReplyMode = "card" | "markdown";
73
+
74
+ function readSessionReasoningLevel(params: {
75
+ storePath?: string;
76
+ sessionKey: string;
77
+ sessionUpdatedAt?: number;
78
+ log?: Logger;
79
+ }): string | undefined {
80
+ if (!params.storePath || !params.sessionKey) {
81
+ return undefined;
82
+ }
83
+ const cacheKey = `${params.storePath}:${params.sessionKey}`;
84
+ const cached = sessionReasoningLevelCache.get(cacheKey);
85
+ if (
86
+ cached
87
+ && params.sessionUpdatedAt !== undefined
88
+ && cached.updatedAt === params.sessionUpdatedAt
89
+ ) {
90
+ return cached.reasoningLevel;
91
+ }
92
+ try {
93
+ const raw = fs.readFileSync(params.storePath, "utf8");
94
+ const parsed = JSON.parse(raw) as Record<string, { reasoningLevel?: unknown }>;
95
+ const value = parsed?.[params.sessionKey]?.reasoningLevel;
96
+ const reasoningLevel = typeof value === "string" ? value.trim().toLowerCase() : undefined;
97
+ sessionReasoningLevelCache.set(cacheKey, {
98
+ updatedAt: params.sessionUpdatedAt,
99
+ reasoningLevel,
100
+ });
101
+ return reasoningLevel;
102
+ } catch (err: unknown) {
103
+ params.log?.debug?.(
104
+ `[DingTalk][Session] Failed to read session reasoning level from ${params.storePath}: ${getErrorMessage(err)}`,
105
+ );
106
+ return undefined;
107
+ }
108
+ }
109
+
110
+ function shouldDisableBlockStreamingForReplyMode(params: {
111
+ replyMode: ReplyMode;
112
+ reasoningLevel?: string;
113
+ sessionKey: string;
114
+ log?: Logger;
115
+ }): boolean {
116
+ if (params.replyMode === "markdown") {
117
+ const shouldDisable = params.reasoningLevel === "on" || params.reasoningLevel === "stream";
118
+ if (shouldDisable) {
119
+ params.log?.debug?.(
120
+ `[DingTalk][Markdown] Disable block streaming for reasoningLevel=${params.reasoningLevel} sessionKey=${params.sessionKey}`,
121
+ );
122
+ }
123
+ return shouldDisable;
124
+ }
125
+
126
+ const shouldDisable = params.reasoningLevel !== "on";
127
+ if (!shouldDisable) {
128
+ params.log?.debug?.(
129
+ `[DingTalk][Card] Enable block streaming for reasoningLevel=${params.reasoningLevel} sessionKey=${params.sessionKey}`,
130
+ );
131
+ }
132
+ return shouldDisable;
133
+ }
96
134
 
97
135
  function resolvePinnedMainDmOwner(params: {
98
136
  dmScope?: string;
@@ -234,6 +272,7 @@ function buildGroupTurnContextPrompt(params: {
234
272
 
235
273
  type ReplyStreamPayload = {
236
274
  text?: string;
275
+ isReasoning?: boolean;
237
276
  };
238
277
 
239
278
  type ReplyChunkInfo = {
@@ -360,7 +399,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
360
399
  const rt = getDingTalkRuntime();
361
400
 
362
401
  // Save logger globally so shared services can log consistently without threading log everywhere.
363
- setCurrentLogger(log);
402
+ setCurrentLogger(log, accountId);
364
403
 
365
404
  log?.debug?.("[DingTalk] Full Inbound Data: " + JSON.stringify(maskSensitiveData(data)));
366
405
 
@@ -623,430 +662,38 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
623
662
  });
624
663
 
625
664
  const to = isDirect ? senderId : groupId;
626
- const parsedLearnCommand = parseLearnCommand(extractedContent.text);
627
- const parsedSessionCommand = parseSessionCommand(extractedContent.text);
628
- const isOwner = isLearningOwner({
665
+ const commandHandled = await handleInboundCommandDispatch({
629
666
  cfg,
630
- config: dingtalkConfig,
631
- senderId,
632
- rawSenderId: data.senderId,
633
- });
634
- if (isDirect && parsedLearnCommand.scope === "whoami") {
635
- await sendBySession(
636
- dingtalkConfig,
637
- sessionWebhook,
638
- formatWhoAmIReply({
639
- senderId,
640
- rawSenderId: data.senderId,
641
- senderStaffId: data.senderStaffId,
642
- isOwner,
643
- }),
644
- { log },
645
- );
646
- return;
647
- }
648
- if (parsedLearnCommand.scope === "whereami") {
649
- await sendBySession(
650
- dingtalkConfig,
651
- sessionWebhook,
652
- formatWhereAmIReply({
653
- conversationId: data.conversationId,
654
- conversationType: isDirect ? "dm" : "group",
655
- peerId: sessionPeer.peerId,
656
- }),
657
- { log },
658
- );
659
- return;
660
- }
661
- if (isDirect && parsedLearnCommand.scope === "owner-status") {
662
- await sendBySession(
663
- dingtalkConfig,
664
- sessionWebhook,
665
- formatOwnerStatusReply({
666
- senderId,
667
- rawSenderId: data.senderId,
668
- isOwner,
669
- }),
670
- { log },
671
- );
672
- return;
673
- }
674
- if (parsedLearnCommand.scope === "help") {
675
- await sendBySession(dingtalkConfig, sessionWebhook, formatLearnCommandHelp(), { log });
676
- return;
677
- }
678
- if (
679
- (parsedLearnCommand.scope === "global" ||
680
- parsedLearnCommand.scope === "session" ||
681
- parsedLearnCommand.scope === "here" ||
682
- parsedLearnCommand.scope === "target" ||
683
- parsedLearnCommand.scope === "targets" ||
684
- parsedLearnCommand.scope === "list" ||
685
- parsedLearnCommand.scope === "disable" ||
686
- parsedLearnCommand.scope === "delete" ||
687
- parsedLearnCommand.scope === "target-set-create" ||
688
- parsedLearnCommand.scope === "target-set-apply" ||
689
- parsedSessionCommand.scope === "session-alias-show" ||
690
- parsedSessionCommand.scope === "session-alias-set" ||
691
- parsedSessionCommand.scope === "session-alias-clear" ||
692
- parsedSessionCommand.scope === "session-alias-bind" ||
693
- parsedSessionCommand.scope === "session-alias-unbind") &&
694
- !isOwner
695
- ) {
696
- await sendBySession(dingtalkConfig, sessionWebhook, formatOwnerOnlyDeniedReply(), { log });
697
- return;
698
- }
699
- if (isOwner) {
700
- if (parsedSessionCommand.scope === "session-alias-show") {
701
- await sendBySession(
702
- dingtalkConfig,
703
- sessionWebhook,
704
- formatSessionAliasReply({
705
- sourceKind: currentSessionSourceKind,
706
- sourceId: currentSessionSourceId,
707
- peerId: sessionPeer.peerId,
708
- aliasSource: peerIdOverride ? "override" : "default",
709
- }),
710
- { log },
711
- );
712
- return;
713
- }
714
- if (parsedSessionCommand.scope === "session-alias-set" && parsedSessionCommand.peerId) {
715
- const aliasValidationError = validateSessionAlias(parsedSessionCommand.peerId);
716
- if (aliasValidationError) {
717
- await sendBySession(
718
- dingtalkConfig,
719
- sessionWebhook,
720
- formatSessionAliasValidationErrorReply(aliasValidationError),
721
- { log },
722
- );
723
- return;
724
- }
725
- setSessionPeerOverride({
726
- storePath: accountStorePath,
727
- accountId,
728
- sourceKind: currentSessionSourceKind,
729
- sourceId: currentSessionSourceId,
730
- peerId: parsedSessionCommand.peerId,
731
- });
732
- await sendBySession(
733
- dingtalkConfig,
734
- sessionWebhook,
735
- formatSessionAliasSetReply({
736
- sourceKind: currentSessionSourceKind,
737
- sourceId: currentSessionSourceId,
738
- peerId: parsedSessionCommand.peerId,
739
- }),
740
- { log },
741
- );
742
- return;
743
- }
744
- if (parsedSessionCommand.scope === "session-alias-clear") {
745
- clearSessionPeerOverride({
746
- storePath: accountStorePath,
747
- accountId,
748
- sourceKind: currentSessionSourceKind,
749
- sourceId: currentSessionSourceId,
750
- });
751
- await sendBySession(
752
- dingtalkConfig,
753
- sessionWebhook,
754
- formatSessionAliasClearedReply({
755
- sourceKind: currentSessionSourceKind,
756
- sourceId: currentSessionSourceId,
757
- }),
758
- { log },
759
- );
760
- return;
761
- }
762
- if (
763
- parsedSessionCommand.scope === "session-alias-bind" &&
764
- parsedSessionCommand.sourceKind &&
765
- parsedSessionCommand.sourceId &&
766
- parsedSessionCommand.peerId
767
- ) {
768
- const aliasValidationError = validateSessionAlias(parsedSessionCommand.peerId);
769
- if (aliasValidationError) {
770
- await sendBySession(
771
- dingtalkConfig,
772
- sessionWebhook,
773
- formatSessionAliasValidationErrorReply(aliasValidationError),
774
- { log },
775
- );
776
- return;
777
- }
778
- setSessionPeerOverride({
779
- storePath: accountStorePath,
780
- accountId,
781
- sourceKind: parsedSessionCommand.sourceKind,
782
- sourceId: parsedSessionCommand.sourceId,
783
- peerId: parsedSessionCommand.peerId,
784
- });
785
- await sendBySession(
786
- dingtalkConfig,
787
- sessionWebhook,
788
- formatSessionAliasBoundReply({
789
- sourceKind: parsedSessionCommand.sourceKind,
790
- sourceId: parsedSessionCommand.sourceId,
791
- peerId: parsedSessionCommand.peerId,
792
- }),
793
- { log },
794
- );
795
- return;
796
- }
797
- if (
798
- parsedSessionCommand.scope === "session-alias-unbind" &&
799
- parsedSessionCommand.sourceKind &&
800
- parsedSessionCommand.sourceId
801
- ) {
802
- const existed = clearSessionPeerOverride({
803
- storePath: accountStorePath,
804
- accountId,
805
- sourceKind: parsedSessionCommand.sourceKind,
806
- sourceId: parsedSessionCommand.sourceId,
807
- });
808
- await sendBySession(
809
- dingtalkConfig,
810
- sessionWebhook,
811
- formatSessionAliasUnboundReply({
812
- sourceKind: parsedSessionCommand.sourceKind,
813
- sourceId: parsedSessionCommand.sourceId,
814
- existed,
815
- }),
816
- { log },
817
- );
818
- return;
819
- }
820
- if (parsedLearnCommand.scope === "global" && parsedLearnCommand.instruction) {
821
- const applied = applyManualGlobalLearningRule({
822
- storePath: accountStorePath,
823
- accountId,
824
- instruction: parsedLearnCommand.instruction,
825
- });
826
- await sendBySession(
827
- dingtalkConfig,
828
- sessionWebhook,
829
- formatLearnAppliedReply({
830
- scope: "global",
831
- instruction: parsedLearnCommand.instruction,
832
- ruleId: applied?.ruleId,
833
- }),
834
- { log },
835
- );
836
- return;
837
- }
838
- if (parsedLearnCommand.scope === "session" && parsedLearnCommand.instruction) {
839
- applyManualSessionLearningNote({
840
- storePath: accountStorePath,
841
- accountId,
842
- targetId: data.conversationId,
843
- instruction: parsedLearnCommand.instruction,
844
- });
845
- await sendBySession(
846
- dingtalkConfig,
847
- sessionWebhook,
848
- formatLearnAppliedReply({
849
- scope: "session",
850
- instruction: parsedLearnCommand.instruction,
851
- }),
852
- { log },
853
- );
854
- return;
855
- }
856
- if (parsedLearnCommand.scope === "here" && parsedLearnCommand.instruction) {
857
- const applied = applyManualTargetLearningRule({
858
- storePath: accountStorePath,
859
- accountId,
860
- targetId: data.conversationId,
861
- instruction: parsedLearnCommand.instruction,
862
- });
863
- await sendBySession(
864
- dingtalkConfig,
865
- sessionWebhook,
866
- formatLearnAppliedReply({
867
- scope: "target",
868
- targetId: data.conversationId,
869
- instruction: parsedLearnCommand.instruction,
870
- ruleId: applied?.ruleId,
871
- }),
872
- { log },
873
- );
874
- return;
875
- }
876
- if (
877
- parsedLearnCommand.scope === "target" &&
878
- parsedLearnCommand.targetId &&
879
- parsedLearnCommand.instruction
880
- ) {
881
- const applied = applyManualTargetLearningRule({
882
- storePath: accountStorePath,
883
- accountId,
884
- targetId: parsedLearnCommand.targetId,
885
- instruction: parsedLearnCommand.instruction,
886
- });
887
- await sendBySession(
888
- dingtalkConfig,
889
- sessionWebhook,
890
- formatLearnAppliedReply({
891
- scope: "target",
892
- targetId: parsedLearnCommand.targetId,
893
- instruction: parsedLearnCommand.instruction,
894
- ruleId: applied?.ruleId,
895
- }),
896
- { log },
897
- );
898
- return;
899
- }
900
- if (
901
- parsedLearnCommand.scope === "targets" &&
902
- parsedLearnCommand.targetIds?.length &&
903
- parsedLearnCommand.instruction
904
- ) {
905
- const applied = applyManualTargetsLearningRule({
906
- storePath: accountStorePath,
907
- accountId,
908
- targetIds: parsedLearnCommand.targetIds,
909
- instruction: parsedLearnCommand.instruction,
910
- });
911
- await sendBySession(
912
- dingtalkConfig,
913
- sessionWebhook,
914
- formatLearnAppliedReply({
915
- scope: "targets",
916
- targetIds: parsedLearnCommand.targetIds,
917
- instruction: parsedLearnCommand.instruction,
918
- ruleId: applied[0]?.ruleId,
919
- }),
920
- { log },
921
- );
922
- return;
923
- }
924
- if (
925
- parsedLearnCommand.scope === "target-set-create" &&
926
- parsedLearnCommand.setName &&
927
- parsedLearnCommand.targetIds?.length
928
- ) {
929
- const saved = createOrUpdateTargetSet({
930
- storePath: accountStorePath,
931
- accountId,
932
- name: parsedLearnCommand.setName,
933
- targetIds: parsedLearnCommand.targetIds,
934
- });
935
- await sendBySession(
936
- dingtalkConfig,
937
- sessionWebhook,
938
- saved
939
- ? formatTargetSetSavedReply({
940
- setName: parsedLearnCommand.setName,
941
- targetIds: parsedLearnCommand.targetIds,
942
- })
943
- : "目标组保存失败,请检查名称和目标列表。",
944
- { log },
945
- );
946
- return;
947
- }
948
- if (
949
- parsedLearnCommand.scope === "target-set-apply" &&
950
- parsedLearnCommand.setName &&
951
- parsedLearnCommand.instruction
952
- ) {
953
- const applied = applyTargetSetLearningRule({
954
- storePath: accountStorePath,
955
- accountId,
956
- name: parsedLearnCommand.setName,
957
- instruction: parsedLearnCommand.instruction,
958
- });
959
- await sendBySession(
960
- dingtalkConfig,
961
- sessionWebhook,
962
- applied.length > 0
963
- ? formatLearnAppliedReply({
964
- scope: "target-set",
965
- setName: parsedLearnCommand.setName,
966
- targetIds: applied.map((item) => item.targetId),
967
- instruction: parsedLearnCommand.instruction,
968
- ruleId: applied[0]?.ruleId,
969
- })
970
- : `未找到目标组 \`${parsedLearnCommand.setName}\`,或该目标组为空。`,
971
- { log },
972
- );
973
- return;
974
- }
975
- if (parsedLearnCommand.scope === "list") {
976
- const rules = listScopedLearningRules({ storePath: accountStorePath, accountId })
977
- .slice(0, 20)
978
- .map((rule) => {
979
- const scope = rule.scope === "target" ? `target(${rule.targetId})` : "global";
980
- const status = rule.enabled ? "enabled" : "disabled";
981
- return `- [${scope}] ${rule.ruleId} (${status}) => ${rule.instruction}`;
982
- });
983
- const targetSets = listLearningTargetSets({ storePath: accountStorePath, accountId })
984
- .slice(0, 10)
985
- .map(
986
- (targetSet) => `- [target-set] ${targetSet.name} => ${targetSet.targetIds.join(", ")}`,
987
- );
988
- await sendBySession(
989
- dingtalkConfig,
990
- sessionWebhook,
991
- formatLearnListReply([...rules, ...targetSets]),
992
- { log },
993
- );
994
- return;
995
- }
996
- if (parsedLearnCommand.scope === "disable" && parsedLearnCommand.ruleId) {
997
- const result = disableManualRule({
998
- storePath: accountStorePath,
999
- accountId,
1000
- ruleId: parsedLearnCommand.ruleId,
1001
- });
1002
- await sendBySession(
1003
- dingtalkConfig,
1004
- sessionWebhook,
1005
- formatLearnDisabledReply({
1006
- ruleId: parsedLearnCommand.ruleId,
1007
- existed: result.existed,
1008
- scope: result.scope,
1009
- targetId: result.targetId,
1010
- }),
1011
- { log },
1012
- );
1013
- return;
1014
- }
1015
- if (parsedLearnCommand.scope === "delete" && parsedLearnCommand.ruleId) {
1016
- const result = deleteManualRule({
1017
- storePath: accountStorePath,
1018
- accountId,
1019
- ruleId: parsedLearnCommand.ruleId,
1020
- });
1021
- await sendBySession(
1022
- dingtalkConfig,
1023
- sessionWebhook,
1024
- formatLearnDeletedReply({
1025
- ruleId: parsedLearnCommand.ruleId,
1026
- existed: result.existed,
1027
- scope: result.scope,
1028
- targetId: result.targetId,
1029
- }),
1030
- { log },
1031
- );
1032
- return;
1033
- }
1034
- }
1035
- const manualForcedReply = resolveManualForcedReply({
1036
- storePath: accountStorePath,
1037
667
  accountId,
1038
- targetId: data.conversationId,
1039
- content: extractedContent,
668
+ dingtalkConfig,
669
+ senderId,
670
+ isDirect,
671
+ extractedText: extractedContent.text,
672
+ messageType: extractedContent.messageType,
673
+ data: {
674
+ conversationId: data.conversationId,
675
+ senderId: data.senderId,
676
+ senderStaffId: data.senderStaffId,
677
+ },
678
+ accountStorePath,
679
+ currentSessionSourceKind,
680
+ currentSessionSourceId,
681
+ peerIdOverride,
682
+ sessionPeer,
683
+ sendReply: async (text: string) => {
684
+ await sendBySession(dingtalkConfig, sessionWebhook, text, { log });
685
+ },
686
+ clearSessionPeerOverride,
687
+ setSessionPeerOverride,
1040
688
  });
1041
- if (manualForcedReply) {
1042
- await sendBySession(dingtalkConfig, sessionWebhook, manualForcedReply, { log });
689
+ if (commandHandled) {
1043
690
  return;
1044
691
  }
1045
692
  // 3) Select response mode (card vs markdown).
1046
693
  // Card creation runs BEFORE media download so the user sees immediate visual
1047
694
  // feedback while large files are still being downloaded.
1048
695
  let useCardMode = dingtalkConfig.messageType === "card";
1049
- let currentAICard = undefined;
696
+ let currentAICard: import("./types").AICardInstance | undefined;
1050
697
 
1051
698
  if (useCardMode) {
1052
699
  try {
@@ -1060,6 +707,15 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1060
707
  });
1061
708
  if (aiCard) {
1062
709
  currentAICard = aiCard;
710
+ if (aiCard.outTrackId) {
711
+ registerCardRun(aiCard.outTrackId, {
712
+ accountId,
713
+ sessionKey: route.sessionKey,
714
+ agentId: route.agentId,
715
+ ownerUserId: senderId,
716
+ card: aiCard,
717
+ });
718
+ }
1063
719
  } else {
1064
720
  useCardMode = false;
1065
721
  log?.warn?.(
@@ -1620,13 +1276,11 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1620
1276
  // tryFastAbortFromMessage (inside the SDK) kill any in-flight generation immediately,
1621
1277
  // rather than waiting for it to finish before the stop message is processed.
1622
1278
  //
1623
- // In group chats, DingTalk typically strips @BotName from text.content at the
1624
- // protocol level before delivery, but as a defensive measure we also strip leading
1625
- // @mention tokens here (e.g. "@Bot 停止" "停止") to match the SDK's own behavior
1626
- // in tryFastAbortFromMessage (which calls stripMentions for group messages).
1627
- const textForAbortCheck = !isDirect
1628
- ? inboundText.replace(/^(?:@\S+\s+)*/u, "").trim()
1629
- : inboundText;
1279
+ // Strip leading @mention tokens before the abort check so that messages like
1280
+ // "@Agent /stop" are correctly recognised as abort requests in both DM and group
1281
+ // chats. In groups DingTalk usually strips @BotName at the protocol level, but
1282
+ // in DMs with multi-agent routing the @mention prefix survives all the way here.
1283
+ const textForAbortCheck = inboundText.replace(/^(?:@\S+\s+)*/u, "").trim();
1630
1284
  if (isAbortRequestText(textForAbortCheck)) {
1631
1285
  log?.info?.(
1632
1286
  `[DingTalk] Abort request detected, bypassing session lock for session=${route.sessionKey}`,
@@ -1798,6 +1452,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1798
1452
  // causes empty replies for all but the first caller.
1799
1453
  // Each sub-agent call acquires its own lock since sub-agent sessions have
1800
1454
  // different session keys (different agentId), so no deadlock risk.
1455
+ const currentOutTrackId = currentAICard?.outTrackId;
1801
1456
  const shouldTrackDynamicAckReaction =
1802
1457
  (normalizedAckReaction === "emoji" || normalizedAckReaction === "kaomoji")
1803
1458
  && shouldAttachAckReaction;
@@ -1826,22 +1481,51 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1826
1481
  if (!ackReactionAttached && shouldAttachAckReaction) {
1827
1482
  log?.debug?.("[DingTalk] Native ack reaction unavailable; skipping fallback.");
1828
1483
  }
1484
+ const isCurrentCardStopRequested = () =>
1485
+ Boolean(
1486
+ currentAICard
1487
+ && (
1488
+ currentAICard.state === AICardStatus.STOPPED
1489
+ || (currentOutTrackId && isCardRunStopRequested(currentOutTrackId))
1490
+ ),
1491
+ );
1492
+
1493
+ if (isCurrentCardStopRequested()) {
1494
+ log?.info?.("[DingTalk][CardStop] Skip dispatch because card was already stopped before session lock was acquired");
1495
+ return;
1496
+ }
1829
1497
 
1830
1498
  // ---- Create reply strategy (card or markdown) ----
1499
+ const replyMode: ReplyMode = useCardMode && !!currentAICard ? "card" : "markdown";
1500
+ const sessionReasoningLevel = readSessionReasoningLevel({
1501
+ storePath,
1502
+ sessionKey: route.sessionKey,
1503
+ sessionUpdatedAt: previousTimestamp,
1504
+ log,
1505
+ });
1831
1506
  const strategy = createReplyStrategy({
1832
1507
  config: dingtalkConfig,
1833
1508
  card: currentAICard,
1834
- useCardMode: useCardMode && !!currentAICard,
1509
+ useCardMode: replyMode === "card",
1835
1510
  to,
1836
1511
  sessionWebhook,
1837
1512
  senderId,
1838
1513
  isDirect,
1839
1514
  accountId,
1840
1515
  storePath: accountStorePath,
1516
+ sessionKey: route.sessionKey,
1517
+ sessionAgentId: route.agentId,
1518
+ disableBlockStreaming: shouldDisableBlockStreamingForReplyMode({
1519
+ replyMode,
1520
+ sessionKey: route.sessionKey,
1521
+ reasoningLevel: sessionReasoningLevel,
1522
+ log,
1523
+ }),
1841
1524
  groupId,
1842
1525
  log,
1843
1526
  replyQuotedRef,
1844
1527
  deliverMedia: deliverMediaAttachments,
1528
+ isStopRequested: isCurrentCardStopRequested,
1845
1529
  });
1846
1530
 
1847
1531
  try {
@@ -1851,12 +1535,18 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1851
1535
  dispatcherOptions: {
1852
1536
  responsePrefix: subAgentOptions?.responsePrefix || "",
1853
1537
  deliver: async (payload: ReplyStreamPayload, info?: ReplyChunkInfo) => {
1538
+ if (isCurrentCardStopRequested()) {
1539
+ log?.debug?.("[DingTalk][CardStop] Ignoring reply delivery because stop was already requested");
1540
+ return;
1541
+ }
1854
1542
  try {
1855
1543
  const mediaUrls = extractMediaUrls(payload);
1544
+ const richPayload = payload as ReplyStreamPayload & { isReasoning?: boolean };
1856
1545
  await strategy.deliver({
1857
1546
  text: payload.text,
1858
1547
  mediaUrls,
1859
1548
  kind: (info?.kind as DeliverPayload["kind"]) || "block",
1549
+ isReasoning: richPayload.isReasoning === true,
1860
1550
  });
1861
1551
  } catch (err: unknown) {
1862
1552
  log?.error?.(`[DingTalk] Reply failed: ${getErrorMessage(err)}`);
@@ -1878,6 +1568,13 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1878
1568
 
1879
1569
  await strategy.finalize();
1880
1570
  } finally {
1571
+ // Only remove the registry entry if no stop was requested. When a stop is
1572
+ // in progress, card-stop-handler may still be running async operations
1573
+ // (finalize card, hide button, gateway abort) that read the record.
1574
+ // In that case, let the 30-minute TTL sweep handle cleanup.
1575
+ if (currentOutTrackId && !isCardRunStopRequested(currentOutTrackId)) {
1576
+ removeCardRun(currentOutTrackId);
1577
+ }
1881
1578
  await waitForDynamicAckDispose({
1882
1579
  dispose: () => dynamicAckReactionController.dispose(MIN_THINKING_REACTION_VISIBLE_MS),
1883
1580
  log,