@soimy/dingtalk 3.5.3 → 3.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/index.ts +7 -0
- package/openclaw.plugin.json +153 -5
- package/package.json +1 -1
- package/src/auth.ts +5 -2
- package/src/card/card-markdown-image-reroute.ts +106 -0
- package/src/card/card-run-registry.ts +54 -1
- package/src/card/card-stop-handler.ts +10 -20
- package/src/card/card-template.ts +14 -3
- package/src/card/statusline-renderer.ts +94 -0
- package/src/card-draft-controller.ts +245 -52
- package/src/card-service.ts +408 -23
- package/src/channel.ts +24 -1083
- package/src/config-schema.ts +21 -1
- package/src/config.ts +139 -66
- package/src/device-registration.ts +245 -0
- package/src/gateway/channel-gateway.ts +637 -0
- package/src/inbound-handler.ts +1276 -975
- package/src/media-utils.ts +6 -0
- package/src/message-context-store.ts +183 -85
- package/src/message-utils.ts +124 -16
- package/src/messaging/btw-deliver.ts +85 -0
- package/src/messaging/channel-actions.ts +174 -0
- package/src/messaging/channel-outbound.ts +158 -0
- package/src/onboarding.ts +333 -235
- package/src/path-utils.ts +49 -0
- package/src/platform/channel-status.ts +81 -0
- package/src/reply-strategy-card.ts +373 -64
- package/src/reply-strategy-markdown.ts +1 -1
- package/src/reply-strategy-types.ts +93 -0
- package/src/reply-strategy-with-reaction.ts +1 -1
- package/src/reply-strategy.ts +14 -72
- package/src/run-usage-store.ts +59 -0
- package/src/secret-input.ts +216 -0
- package/src/send-service.ts +115 -3
- package/src/session-state.ts +62 -0
- package/src/targeting/agent-name-matcher.ts +28 -0
- package/src/targeting/agent-routing.ts +30 -5
- package/src/types.ts +48 -157
package/src/inbound-handler.ts
CHANGED
|
@@ -1,27 +1,24 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import
|
|
3
|
+
import { isAbortRequestText, isBtwRequestText } from "openclaw/plugin-sdk/reply-runtime";
|
|
4
|
+
import { parseInlineDirectives } from "openclaw/plugin-sdk/text-runtime";
|
|
4
5
|
import { normalizeAllowFrom, isSenderAllowed, resolveGroupAccess } from "./access-control";
|
|
5
|
-
import { buildAgentSessionKey, resolveSubAgentRoute, dispatchSubAgents } from "./targeting/agent-routing";
|
|
6
6
|
import { classifyAckReactionEmoji } from "./ack-reaction-classifier";
|
|
7
7
|
import { attachNativeAckReaction } from "./ack-reaction-service";
|
|
8
8
|
import { createDynamicAckReactionController } from "./ack-reaction/dynamic-ack-reaction-controller";
|
|
9
|
-
import { extractAttachmentText } from "./messaging/attachment-text-extractor";
|
|
10
9
|
import { getAccessToken } from "./auth";
|
|
11
|
-
import { createAICard,
|
|
10
|
+
import { createAICard, commitAICardBlocks, isCardInTerminalState } from "./card-service";
|
|
11
|
+
import { isCardRunStopRequested, registerCardRun, removeCardRun } from "./card/card-run-registry";
|
|
12
|
+
import { renderStatusLine } from "./card/statusline-renderer";
|
|
12
13
|
import { handleInboundCommandDispatch } from "./command/inbound-command-dispatch-service";
|
|
13
|
-
import { resolveAckReactionSetting, resolveGroupConfig, resolveRelativePath, resolveRobotCode } from "./config";
|
|
14
|
-
import { AICardStatus } from "./types";
|
|
15
14
|
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
} from "./feedback-learning-service";
|
|
24
|
-
import { formatGroupMembers, noteGroupMember } from "./targeting/group-members-store";
|
|
15
|
+
resolveAckReactionSetting,
|
|
16
|
+
resolveGroupConfig,
|
|
17
|
+
resolveRelativePath,
|
|
18
|
+
resolveRobotCode,
|
|
19
|
+
} from "./config";
|
|
20
|
+
import { buildLearningContextBlock, isLearningEnabled } from "./feedback-learning-service";
|
|
21
|
+
import axios from "./http-client";
|
|
25
22
|
import { setCurrentLogger } from "./logger-context";
|
|
26
23
|
import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
|
|
27
24
|
import {
|
|
@@ -30,7 +27,14 @@ import {
|
|
|
30
27
|
upsertInboundMessageContext,
|
|
31
28
|
} from "./message-context-store";
|
|
32
29
|
import { extractMessageContent } from "./message-utils";
|
|
30
|
+
import { extractAttachmentText } from "./messaging/attachment-text-extractor";
|
|
31
|
+
import { deliverBtwReply, stripLeadingMentions } from "./messaging/btw-deliver";
|
|
33
32
|
import { resolveQuotedRuntimeContext } from "./messaging/quoted-context";
|
|
33
|
+
import {
|
|
34
|
+
downloadGroupFile,
|
|
35
|
+
getUnionIdByStaffId,
|
|
36
|
+
resolveQuotedFile,
|
|
37
|
+
} from "./messaging/quoted-file-service";
|
|
34
38
|
import {
|
|
35
39
|
buildInboundQuotedRef,
|
|
36
40
|
createReplyQuotedRef,
|
|
@@ -41,9 +45,8 @@ import {
|
|
|
41
45
|
clearProactiveRiskObservationsForTest,
|
|
42
46
|
getProactiveRiskObservationForAny,
|
|
43
47
|
} from "./proactive-risk-registry";
|
|
44
|
-
import { downloadGroupFile, getUnionIdByStaffId, resolveQuotedFile } from "./messaging/quoted-file-service";
|
|
45
48
|
import { createReplyStrategy } from "./reply-strategy";
|
|
46
|
-
import type { DeliverPayload } from "./reply-strategy";
|
|
49
|
+
import type { DeliverPayload } from "./reply-strategy-types";
|
|
47
50
|
import { getDingTalkRuntime } from "./runtime";
|
|
48
51
|
import { sendBySession, sendMessage, sendProactiveMedia } from "./send-service";
|
|
49
52
|
import { acquireSessionLock } from "./session-lock";
|
|
@@ -53,14 +56,27 @@ import {
|
|
|
53
56
|
setSessionPeerOverride,
|
|
54
57
|
} from "./session-peer-store";
|
|
55
58
|
import { resolveDingTalkSessionPeer } from "./session-routing";
|
|
59
|
+
import { getSessionState, initSessionState } from "./session-state";
|
|
60
|
+
import { getAgentDisplayName } from "./targeting/agent-name-matcher";
|
|
61
|
+
import {
|
|
62
|
+
buildAgentSessionKey,
|
|
63
|
+
resolveSubAgentRoute,
|
|
64
|
+
dispatchSubAgents,
|
|
65
|
+
} from "./targeting/agent-routing";
|
|
66
|
+
import { formatGroupMembers, noteGroupMember } from "./targeting/group-members-store";
|
|
56
67
|
import {
|
|
57
68
|
upsertObservedGroupTarget,
|
|
58
69
|
upsertObservedUserTarget,
|
|
59
70
|
} from "./targeting/target-directory-store";
|
|
71
|
+
import { AICardStatus } from "./types";
|
|
60
72
|
import type { DingTalkConfig, HandleDingTalkMessageParams, Logger, MediaFile } from "./types";
|
|
61
|
-
import {
|
|
62
|
-
|
|
63
|
-
|
|
73
|
+
import {
|
|
74
|
+
formatDingTalkErrorPayloadLog,
|
|
75
|
+
getErrorMessage,
|
|
76
|
+
getErrorResponseData,
|
|
77
|
+
maskSensitiveData,
|
|
78
|
+
parseBooleanLike,
|
|
79
|
+
} from "./utils";
|
|
64
80
|
|
|
65
81
|
const DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS = 24;
|
|
66
82
|
const MIN_THINKING_REACTION_VISIBLE_MS = 1200;
|
|
@@ -91,10 +107,19 @@ const STANDALONE_MEDIA_PATH_EXTENSIONS = new Set([
|
|
|
91
107
|
".rar",
|
|
92
108
|
]);
|
|
93
109
|
const proactiveHintLastSentAt = new Map<string, number>();
|
|
94
|
-
const sessionReasoningLevelCache = new Map<
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
110
|
+
const sessionReasoningLevelCache = new Map<
|
|
111
|
+
string,
|
|
112
|
+
{
|
|
113
|
+
updatedAt?: number;
|
|
114
|
+
reasoningLevel?: string;
|
|
115
|
+
}
|
|
116
|
+
>();
|
|
117
|
+
// Prevent concurrent card creation for the same conversation.
|
|
118
|
+
// createAICard runs before acquireSessionLock (for immediate visual feedback),
|
|
119
|
+
// but two inbound messages can race past each other's card-run registration.
|
|
120
|
+
// This synchronous Set closes that window because JavaScript check-and-set here
|
|
121
|
+
// is atomic from the caller's perspective.
|
|
122
|
+
const cardCreationInFlight = new Set<string>();
|
|
98
123
|
type ReplyMode = "card" | "markdown";
|
|
99
124
|
|
|
100
125
|
function resolveQuotedContextAllowFrom(
|
|
@@ -129,7 +154,15 @@ function filterQuotedRuntimeContext(params: {
|
|
|
129
154
|
currentSenderId: string;
|
|
130
155
|
currentSenderOriginalId: string;
|
|
131
156
|
}): ReturnType<typeof resolveQuotedRuntimeContext> {
|
|
132
|
-
const {
|
|
157
|
+
const {
|
|
158
|
+
context,
|
|
159
|
+
config,
|
|
160
|
+
isDirect,
|
|
161
|
+
groupId,
|
|
162
|
+
quotedSenderId,
|
|
163
|
+
currentSenderId,
|
|
164
|
+
currentSenderOriginalId,
|
|
165
|
+
} = params;
|
|
133
166
|
if (!context || isDirect) {
|
|
134
167
|
return context;
|
|
135
168
|
}
|
|
@@ -146,9 +179,7 @@ function filterQuotedRuntimeContext(params: {
|
|
|
146
179
|
currentSenderOriginalId,
|
|
147
180
|
});
|
|
148
181
|
const senderAllowed =
|
|
149
|
-
allow.hasEntries && !!senderId
|
|
150
|
-
? isSenderAllowed({ allow, senderId })
|
|
151
|
-
: false;
|
|
182
|
+
allow.hasEntries && !!senderId ? isSenderAllowed({ allow, senderId }) : false;
|
|
152
183
|
|
|
153
184
|
if (senderAllowed) {
|
|
154
185
|
return context;
|
|
@@ -174,9 +205,9 @@ function readSessionReasoningLevel(params: {
|
|
|
174
205
|
const cacheKey = `${params.storePath}:${params.sessionKey}`;
|
|
175
206
|
const cached = sessionReasoningLevelCache.get(cacheKey);
|
|
176
207
|
if (
|
|
177
|
-
cached
|
|
178
|
-
|
|
179
|
-
|
|
208
|
+
cached &&
|
|
209
|
+
params.sessionUpdatedAt !== undefined &&
|
|
210
|
+
cached.updatedAt === params.sessionUpdatedAt
|
|
180
211
|
) {
|
|
181
212
|
return cached.reasoningLevel;
|
|
182
213
|
}
|
|
@@ -387,6 +418,8 @@ function buildGroupTurnContextPrompt(params: {
|
|
|
387
418
|
type ReplyStreamPayload = {
|
|
388
419
|
text?: string;
|
|
389
420
|
isReasoning?: boolean;
|
|
421
|
+
mediaUrl?: string;
|
|
422
|
+
mediaUrls?: string[];
|
|
390
423
|
};
|
|
391
424
|
|
|
392
425
|
type ReplyChunkInfo = {
|
|
@@ -514,7 +547,16 @@ export async function downloadMedia(
|
|
|
514
547
|
}
|
|
515
548
|
|
|
516
549
|
export async function handleDingTalkMessage(params: HandleDingTalkMessageParams): Promise<void> {
|
|
517
|
-
const {
|
|
550
|
+
const {
|
|
551
|
+
cfg,
|
|
552
|
+
accountId,
|
|
553
|
+
data,
|
|
554
|
+
sessionWebhook,
|
|
555
|
+
log,
|
|
556
|
+
dingtalkConfig,
|
|
557
|
+
subAgentOptions,
|
|
558
|
+
preDownloadedMedia,
|
|
559
|
+
} = params;
|
|
518
560
|
const rt = getDingTalkRuntime();
|
|
519
561
|
|
|
520
562
|
// Save logger globally so shared services can log consistently without threading log everywhere.
|
|
@@ -534,6 +576,10 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
534
576
|
return;
|
|
535
577
|
}
|
|
536
578
|
|
|
579
|
+
// Preserve raw inbound text before any rewriting (e.g., sub-agent context hint)
|
|
580
|
+
// for use in card quoteContent which should show the user's original message.
|
|
581
|
+
const rawInboundText = extractedContent.text.trim();
|
|
582
|
+
|
|
537
583
|
// Add context hint for sub-agent mode, stripping quoted prefix to avoid protocol noise in agent context.
|
|
538
584
|
if (subAgentOptions) {
|
|
539
585
|
const cleanText = extractedContent.text.replace(/^\[引用[^\]]*\]\s*/, "");
|
|
@@ -639,31 +685,32 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
639
685
|
if (groupAccess.legacyFallback) {
|
|
640
686
|
log?.info?.(
|
|
641
687
|
`[DingTalk] DEPRECATED: groupPolicy=allowlist is using "allowFrom" for group access control. ` +
|
|
642
|
-
|
|
688
|
+
`Please migrate to "groups" (group ID allowlist) or "groupAllowFrom" (sender allowlist).`,
|
|
643
689
|
);
|
|
644
690
|
}
|
|
645
691
|
|
|
646
692
|
if (!groupAccess.allowed) {
|
|
647
693
|
if (groupAccess.reason === "disabled") {
|
|
648
|
-
log?.debug?.(
|
|
694
|
+
log?.debug?.(
|
|
695
|
+
`[DingTalk] Group disabled: all group messages dropped (groupPolicy=disabled)`,
|
|
696
|
+
);
|
|
649
697
|
return;
|
|
650
698
|
}
|
|
651
699
|
|
|
652
|
-
const denyMessage =
|
|
653
|
-
|
|
654
|
-
|
|
700
|
+
const denyMessage =
|
|
701
|
+
groupAccess.reason === "sender_not_allowed"
|
|
702
|
+
? `⛔ 访问受限\n\n您的用户ID:\`${senderId}\`\n\n请联系管理员将此ID添加到群聊允许列表中。`
|
|
703
|
+
: `⛔ 访问受限\n\n您的群聊ID:\`${groupId}\`\n\n请联系管理员将此ID添加到允许列表中。`;
|
|
655
704
|
|
|
656
705
|
log?.debug?.(
|
|
657
706
|
`[DingTalk] Group blocked: conversationId=${groupId} senderId=${senderId} reason=${groupAccess.reason}`,
|
|
658
707
|
);
|
|
659
708
|
|
|
660
709
|
try {
|
|
661
|
-
await sendBySession(
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
{ log, atUserId: senderId },
|
|
666
|
-
);
|
|
710
|
+
await sendBySession(dingtalkConfig, sessionWebhook, denyMessage, {
|
|
711
|
+
log,
|
|
712
|
+
atUserId: senderId,
|
|
713
|
+
});
|
|
667
714
|
} catch (err: any) {
|
|
668
715
|
log?.debug?.(`[DingTalk] Failed to send group access denied message: ${err.message}`);
|
|
669
716
|
if (err?.response?.data !== undefined) {
|
|
@@ -676,9 +723,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
676
723
|
return;
|
|
677
724
|
}
|
|
678
725
|
|
|
679
|
-
log?.debug?.(
|
|
680
|
-
`[DingTalk] Group authorized: conversationId=${groupId} senderId=${senderId}`,
|
|
681
|
-
);
|
|
726
|
+
log?.debug?.(`[DingTalk] Group authorized: conversationId=${groupId} senderId=${senderId}`);
|
|
682
727
|
}
|
|
683
728
|
|
|
684
729
|
// Calculate account store path and session peer (for session alias feature)
|
|
@@ -808,21 +853,69 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
808
853
|
if (commandHandled) {
|
|
809
854
|
return;
|
|
810
855
|
}
|
|
856
|
+
|
|
857
|
+
const journalTTLDays = dingtalkConfig.journalTTLDays ?? DEFAULT_MESSAGE_CONTEXT_TTL_DAYS;
|
|
858
|
+
const quotedRef = buildInboundQuotedRef(data, extractedContent);
|
|
859
|
+
const replyQuotedRef = createReplyQuotedRef(data.msgId);
|
|
860
|
+
const content = extractedContent;
|
|
861
|
+
const isBtwBypass = isBtwRequestText(stripLeadingMentions(content.text).trim());
|
|
862
|
+
const taskInfoConversationId = groupId || to;
|
|
863
|
+
const sessionTaskState = initSessionState(accountId, taskInfoConversationId);
|
|
864
|
+
const initialStatusLine =
|
|
865
|
+
renderStatusLine(
|
|
866
|
+
{
|
|
867
|
+
model: sessionTaskState.model,
|
|
868
|
+
effort: sessionTaskState.effort,
|
|
869
|
+
agent: getAgentDisplayName({
|
|
870
|
+
subAgentOptions,
|
|
871
|
+
agentId: route.agentId,
|
|
872
|
+
agentsList: cfg.agents?.list,
|
|
873
|
+
}),
|
|
874
|
+
},
|
|
875
|
+
dingtalkConfig,
|
|
876
|
+
) || undefined;
|
|
877
|
+
|
|
811
878
|
// 3) Select response mode (card vs markdown).
|
|
812
879
|
// Card creation runs BEFORE media download so the user sees immediate visual
|
|
813
880
|
// feedback while large files are still being downloaded.
|
|
881
|
+
// /btw is gated out here (`!isBtwBypass`) because it must never create a card —
|
|
882
|
+
// it has its own bypass dispatch later that returns before reaching the main
|
|
883
|
+
// run. Abort (/stop) is intentionally NOT gated: in card mode the existing
|
|
884
|
+
// abort branch finalizes the card with the abort confirmation text instead of
|
|
885
|
+
// sending a separate plain-text message.
|
|
814
886
|
let useCardMode = dingtalkConfig.messageType === "card";
|
|
815
887
|
let currentAICard: import("./types").AICardInstance | undefined;
|
|
816
888
|
|
|
817
|
-
|
|
889
|
+
let cardFlightKey: string | undefined;
|
|
890
|
+
if (useCardMode && !isBtwBypass) {
|
|
891
|
+
const key = `${accountId}:${to}`;
|
|
892
|
+
if (cardCreationInFlight.has(key)) {
|
|
893
|
+
useCardMode = false;
|
|
894
|
+
log?.debug?.(
|
|
895
|
+
`[DingTalk][AICard] Skip card creation - active card already exists for account=${accountId} conversation=${to}`,
|
|
896
|
+
);
|
|
897
|
+
} else {
|
|
898
|
+
cardCreationInFlight.add(key);
|
|
899
|
+
cardFlightKey = key;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (useCardMode && !isBtwBypass) {
|
|
818
904
|
try {
|
|
819
905
|
log?.debug?.(
|
|
820
906
|
`[DingTalk][AICard] conversationType=${data.conversationType}, conversationId=${to}`,
|
|
821
907
|
);
|
|
908
|
+
// quoteContent always shows the inbound message text so the user can
|
|
909
|
+
// identify which of their messages this card is replying to.
|
|
910
|
+
// Use rawInboundText ( preserved before sub-agent rewriting) to avoid
|
|
911
|
+
// showing internal routing context like "[你被 @ 为...]" in the card UI.
|
|
912
|
+
const inboundQuoteText = rawInboundText.slice(0, 200);
|
|
822
913
|
const aiCard = await createAICard(dingtalkConfig, to, log, {
|
|
823
914
|
accountId,
|
|
824
915
|
storePath: accountStorePath,
|
|
825
916
|
contextConversationId: groupId,
|
|
917
|
+
quoteContent: inboundQuoteText,
|
|
918
|
+
statusLine: initialStatusLine,
|
|
826
919
|
});
|
|
827
920
|
if (aiCard) {
|
|
828
921
|
currentAICard = aiCard;
|
|
@@ -837,1066 +930,1274 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
837
930
|
}
|
|
838
931
|
} else {
|
|
839
932
|
useCardMode = false;
|
|
933
|
+
if (cardFlightKey) {
|
|
934
|
+
cardCreationInFlight.delete(cardFlightKey);
|
|
935
|
+
cardFlightKey = undefined;
|
|
936
|
+
}
|
|
840
937
|
log?.warn?.(
|
|
841
938
|
"[DingTalk] Failed to create AI card (returned null), fallback to text/markdown.",
|
|
842
939
|
);
|
|
843
940
|
}
|
|
844
941
|
} catch (err: any) {
|
|
845
942
|
useCardMode = false;
|
|
943
|
+
if (cardFlightKey) {
|
|
944
|
+
cardCreationInFlight.delete(cardFlightKey);
|
|
945
|
+
cardFlightKey = undefined;
|
|
946
|
+
}
|
|
846
947
|
log?.warn?.(
|
|
847
948
|
`[DingTalk] Failed to create AI card: ${err.message}, fallback to text/markdown.`,
|
|
848
949
|
);
|
|
849
950
|
}
|
|
850
951
|
}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
const quotedRef = buildInboundQuotedRef(data, extractedContent);
|
|
854
|
-
const replyQuotedRef = createReplyQuotedRef(data.msgId);
|
|
855
|
-
const content = extractedContent;
|
|
856
|
-
const hasLegacyQuoteContent =
|
|
857
|
-
typeof data.content?.quoteContent === "string" && data.content.quoteContent.trim().length > 0;
|
|
858
|
-
|
|
859
|
-
if (hasLegacyQuoteContent && !quotedRef) {
|
|
860
|
-
log?.debug?.(
|
|
861
|
-
`[DingTalk] Legacy quoteContent present without resolvable quotedRef: ` +
|
|
862
|
-
`conversationType=${data.conversationType} conversationId=${data.conversationId} ` +
|
|
863
|
-
`msgId=${data.msgId} originalMsgId=${data.originalMsgId || "(none)"}`,
|
|
864
|
-
);
|
|
865
|
-
}
|
|
866
|
-
if (quotedRef) {
|
|
867
|
-
log?.debug?.(
|
|
868
|
-
`[DingTalk][QuotedRef] Built inbound quotedRef msgId=${data.msgId} scope=${groupId} ` +
|
|
869
|
-
`quotedRef=${JSON.stringify(quotedRef)}`,
|
|
870
|
-
);
|
|
871
|
-
} else if (
|
|
872
|
-
data.text?.isReplyMsg ||
|
|
873
|
-
data.originalMsgId ||
|
|
874
|
-
data.originalProcessQueryKey ||
|
|
875
|
-
content.quoted
|
|
876
|
-
) {
|
|
877
|
-
log?.debug?.(
|
|
878
|
-
`[DingTalk][QuotedRef] Reply metadata present without resolvable quotedRef ` +
|
|
879
|
-
`msgId=${data.msgId} scope=${groupId} originalMsgId=${data.originalMsgId || "(none)"} ` +
|
|
880
|
-
`originalProcessQueryKey=${data.originalProcessQueryKey || "(none)"}`,
|
|
881
|
-
);
|
|
882
|
-
}
|
|
883
|
-
|
|
952
|
+
// Outer try/finally guarantees cardFlightKey cleanup even when the handler
|
|
953
|
+
// returns or throws between card creation and session-lock release.
|
|
884
954
|
try {
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
accountId,
|
|
888
|
-
conversationId: groupId,
|
|
889
|
-
msgId: data.msgId,
|
|
890
|
-
messageType: content.messageType,
|
|
891
|
-
text: content.text,
|
|
892
|
-
quotedRef,
|
|
893
|
-
senderId,
|
|
894
|
-
senderName,
|
|
895
|
-
createdAt: data.createAt,
|
|
896
|
-
ttlMs: ttlDaysToMs(journalTTLDays),
|
|
897
|
-
ttlReferenceMs: data.createAt,
|
|
898
|
-
cleanupCreatedAtTtlDays: journalTTLDays,
|
|
899
|
-
topic: null,
|
|
900
|
-
});
|
|
901
|
-
} catch (err) {
|
|
902
|
-
log?.warn?.(`[DingTalk] Message context inbound append failed: ${String(err)}`);
|
|
903
|
-
}
|
|
955
|
+
const hasLegacyQuoteContent =
|
|
956
|
+
typeof data.content?.quoteContent === "string" && data.content.quoteContent.trim().length > 0;
|
|
904
957
|
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
content.
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
958
|
+
if (hasLegacyQuoteContent && !quotedRef) {
|
|
959
|
+
log?.debug?.(
|
|
960
|
+
`[DingTalk] Legacy quoteContent present without resolvable quotedRef: ` +
|
|
961
|
+
`conversationType=${data.conversationType} conversationId=${data.conversationId} ` +
|
|
962
|
+
`msgId=${data.msgId} originalMsgId=${data.originalMsgId || "(none)"}`,
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
if (quotedRef) {
|
|
966
|
+
log?.debug?.(
|
|
967
|
+
`[DingTalk][QuotedRef] Built inbound quotedRef msgId=${data.msgId} scope=${groupId} ` +
|
|
968
|
+
`quotedRef=${JSON.stringify(quotedRef)}`,
|
|
969
|
+
);
|
|
970
|
+
} else if (
|
|
971
|
+
data.text?.isReplyMsg ||
|
|
972
|
+
data.originalMsgId ||
|
|
973
|
+
data.originalProcessQueryKey ||
|
|
974
|
+
content.quoted
|
|
975
|
+
) {
|
|
976
|
+
log?.debug?.(
|
|
977
|
+
`[DingTalk][QuotedRef] Reply metadata present without resolvable quotedRef ` +
|
|
978
|
+
`msgId=${data.msgId} scope=${groupId} originalMsgId=${data.originalMsgId || "(none)"} ` +
|
|
979
|
+
`originalProcessQueryKey=${data.originalProcessQueryKey || "(none)"}`,
|
|
980
|
+
);
|
|
928
981
|
}
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
// Cache downloadCode (+ spaceId/fileId) for quoted file lookups (DM + group).
|
|
932
|
-
if (content.mediaPath && data.msgId) {
|
|
933
|
-
upsertInboundMessageContext({
|
|
934
|
-
storePath: accountStorePath,
|
|
935
|
-
accountId,
|
|
936
|
-
conversationId: data.conversationId,
|
|
937
|
-
msgId: data.msgId,
|
|
938
|
-
createdAt: data.createAt,
|
|
939
|
-
messageType: content.messageType,
|
|
940
|
-
media: {
|
|
941
|
-
downloadCode: content.mediaPath,
|
|
942
|
-
spaceId: data.content?.spaceId,
|
|
943
|
-
fileId: data.content?.fileId,
|
|
944
|
-
},
|
|
945
|
-
attachmentFileName: attachmentContextFileName,
|
|
946
|
-
ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
|
|
947
|
-
topic: null,
|
|
948
|
-
});
|
|
949
|
-
}
|
|
950
982
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
|
|
972
|
-
topic: null,
|
|
973
|
-
});
|
|
983
|
+
try {
|
|
984
|
+
upsertInboundMessageContext({
|
|
985
|
+
storePath: accountStorePath,
|
|
986
|
+
accountId,
|
|
987
|
+
conversationId: groupId,
|
|
988
|
+
msgId: data.msgId,
|
|
989
|
+
messageType: content.messageType,
|
|
990
|
+
text: content.text,
|
|
991
|
+
quotedRef,
|
|
992
|
+
senderId,
|
|
993
|
+
senderName,
|
|
994
|
+
createdAt: data.createAt,
|
|
995
|
+
ttlMs: ttlDaysToMs(journalTTLDays),
|
|
996
|
+
ttlReferenceMs: data.createAt,
|
|
997
|
+
cleanupCreatedAtTtlDays: journalTTLDays,
|
|
998
|
+
topic: null,
|
|
999
|
+
});
|
|
1000
|
+
} catch (err) {
|
|
1001
|
+
log?.warn?.(`[DingTalk] Message context inbound append failed: ${String(err)}`);
|
|
1002
|
+
}
|
|
974
1003
|
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1004
|
+
const robotCode = resolveRobotCode(dingtalkConfig);
|
|
1005
|
+
let mediaPath: string | undefined;
|
|
1006
|
+
let mediaType: string | undefined;
|
|
1007
|
+
const mediaPaths: string[] = [];
|
|
1008
|
+
const mediaTypes: string[] = [];
|
|
1009
|
+
let attachmentContextMsgId = data.msgId;
|
|
1010
|
+
let attachmentContextCreatedAt = data.createAt;
|
|
1011
|
+
let attachmentContextMessageType = content.messageType;
|
|
1012
|
+
let attachmentContextFileName = data.content?.fileName;
|
|
1013
|
+
|
|
1014
|
+
// Use pre-downloaded media if available (from sub-agent outer call)
|
|
1015
|
+
if (preDownloadedMedia?.mediaPath) {
|
|
1016
|
+
mediaPath = preDownloadedMedia.mediaPath;
|
|
1017
|
+
mediaType = preDownloadedMedia.mediaType;
|
|
1018
|
+
if (preDownloadedMedia.mediaPaths?.length) {
|
|
1019
|
+
mediaPaths.push(...preDownloadedMedia.mediaPaths);
|
|
1020
|
+
for (let i = 0; i < preDownloadedMedia.mediaPaths.length; i++) {
|
|
1021
|
+
mediaTypes.push(
|
|
1022
|
+
preDownloadedMedia.mediaTypes?.[i] || preDownloadedMedia.mediaType || "file",
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
} else {
|
|
1026
|
+
mediaPaths.push(mediaPath);
|
|
1027
|
+
mediaTypes.push(mediaType || "file");
|
|
1028
|
+
}
|
|
1029
|
+
} else if (robotCode) {
|
|
1030
|
+
// Download all media attachments (richText may carry multiple images).
|
|
1031
|
+
const downloadCodes =
|
|
1032
|
+
content.mediaPaths && content.mediaPaths.length > 0
|
|
1033
|
+
? content.mediaPaths
|
|
1034
|
+
: content.mediaPath
|
|
1035
|
+
? [content.mediaPath]
|
|
1036
|
+
: [];
|
|
1037
|
+
for (const downloadCode of downloadCodes) {
|
|
1038
|
+
const media = await downloadMedia(
|
|
979
1039
|
dingtalkConfig,
|
|
980
|
-
|
|
981
|
-
content.docFileId,
|
|
982
|
-
unionId,
|
|
1040
|
+
downloadCode,
|
|
983
1041
|
log,
|
|
984
1042
|
attachmentContextFileName,
|
|
985
1043
|
);
|
|
986
|
-
if (
|
|
987
|
-
mediaPath
|
|
988
|
-
|
|
1044
|
+
if (media) {
|
|
1045
|
+
if (!mediaPath) {
|
|
1046
|
+
mediaPath = media.path;
|
|
1047
|
+
mediaType = media.mimeType;
|
|
1048
|
+
}
|
|
1049
|
+
mediaPaths.push(media.path);
|
|
1050
|
+
mediaTypes.push(media.mimeType);
|
|
989
1051
|
}
|
|
990
|
-
} catch (err: any) {
|
|
991
|
-
log?.warn?.(`[DingTalk] Doc card download failed: ${err.message}`);
|
|
992
1052
|
}
|
|
993
1053
|
}
|
|
994
|
-
}
|
|
995
1054
|
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1055
|
+
// Cache downloadCode(s) (+ spaceId/fileId) for quoted file lookups (DM + group).
|
|
1056
|
+
const allMediaDownloadCodes =
|
|
1057
|
+
content.mediaPaths && content.mediaPaths.length > 0
|
|
1058
|
+
? content.mediaPaths
|
|
1059
|
+
: content.mediaPath
|
|
1060
|
+
? [content.mediaPath]
|
|
1061
|
+
: [];
|
|
1062
|
+
if (allMediaDownloadCodes.length > 0 && data.msgId) {
|
|
1063
|
+
upsertInboundMessageContext({
|
|
1064
|
+
storePath: accountStorePath,
|
|
1065
|
+
accountId,
|
|
1066
|
+
conversationId: data.conversationId,
|
|
1067
|
+
msgId: data.msgId,
|
|
1068
|
+
createdAt: data.createAt,
|
|
1069
|
+
messageType: content.messageType,
|
|
1070
|
+
media: {
|
|
1071
|
+
downloadCode: allMediaDownloadCodes[0],
|
|
1072
|
+
downloadCodes: allMediaDownloadCodes.length > 1 ? allMediaDownloadCodes : undefined,
|
|
1073
|
+
spaceId: data.content?.spaceId,
|
|
1074
|
+
fileId: data.content?.fileId,
|
|
1075
|
+
},
|
|
1076
|
+
attachmentFileName: attachmentContextFileName,
|
|
1077
|
+
ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
|
|
1078
|
+
topic: null,
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// User-sent DingTalk doc / Drive file card: cache msgId -> {spaceId,fileId}
|
|
1083
|
+
// during the original message turn, and try downloading immediately in DM.
|
|
1084
|
+
if (
|
|
1085
|
+
content.messageType === "interactiveCardFile" &&
|
|
1086
|
+
data.msgId &&
|
|
1087
|
+
content.docSpaceId &&
|
|
1088
|
+
content.docFileId
|
|
1089
|
+
) {
|
|
1090
|
+
upsertInboundMessageContext({
|
|
1091
|
+
storePath: accountStorePath,
|
|
1092
|
+
accountId,
|
|
1093
|
+
conversationId: data.conversationId,
|
|
1094
|
+
msgId: data.msgId,
|
|
1095
|
+
createdAt: data.createAt,
|
|
1096
|
+
messageType: content.messageType,
|
|
1097
|
+
media: {
|
|
1098
|
+
spaceId: content.docSpaceId,
|
|
1099
|
+
fileId: content.docFileId,
|
|
1100
|
+
},
|
|
1101
|
+
attachmentFileName: attachmentContextFileName,
|
|
1102
|
+
ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
|
|
1103
|
+
topic: null,
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
if (!mediaPath && isDirect && data.senderStaffId) {
|
|
1107
|
+
try {
|
|
1108
|
+
const unionId = await getUnionIdByStaffId(dingtalkConfig, data.senderStaffId, log);
|
|
1109
|
+
const docMedia = await downloadGroupFile(
|
|
1110
|
+
dingtalkConfig,
|
|
1111
|
+
content.docSpaceId,
|
|
1112
|
+
content.docFileId,
|
|
1113
|
+
unionId,
|
|
1114
|
+
log,
|
|
1115
|
+
attachmentContextFileName,
|
|
1116
|
+
);
|
|
1117
|
+
if (docMedia) {
|
|
1118
|
+
mediaPath = docMedia.path;
|
|
1119
|
+
mediaType = docMedia.mimeType;
|
|
1120
|
+
mediaPaths.push(docMedia.path);
|
|
1121
|
+
mediaTypes.push(docMedia.mimeType);
|
|
1122
|
+
}
|
|
1123
|
+
} catch (err: any) {
|
|
1124
|
+
log?.warn?.(`[DingTalk] Doc card download failed: ${err.message}`);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const quotedRecord = resolveQuotedRecord({
|
|
1005
1130
|
storePath: accountStorePath,
|
|
1006
1131
|
accountId,
|
|
1007
1132
|
conversationId: data.conversationId,
|
|
1008
1133
|
quotedRef,
|
|
1009
|
-
firstRecord: quotedRecord,
|
|
1010
|
-
firstPreview:
|
|
1011
|
-
content.quoted?.previewText ||
|
|
1012
|
-
content.quoted?.previewMessageType
|
|
1013
|
-
? {
|
|
1014
|
-
text: content.quoted.previewText,
|
|
1015
|
-
messageType: content.quoted.previewMessageType,
|
|
1016
|
-
senderId: content.quoted.previewSenderId,
|
|
1017
|
-
}
|
|
1018
|
-
: undefined,
|
|
1019
1134
|
log,
|
|
1020
|
-
})
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1135
|
+
});
|
|
1136
|
+
const quotedRuntimeContext = filterQuotedRuntimeContext({
|
|
1137
|
+
context: resolveQuotedRuntimeContext({
|
|
1138
|
+
storePath: accountStorePath,
|
|
1139
|
+
accountId,
|
|
1140
|
+
conversationId: data.conversationId,
|
|
1141
|
+
quotedRef,
|
|
1142
|
+
firstRecord: quotedRecord,
|
|
1143
|
+
firstPreview:
|
|
1144
|
+
content.quoted?.previewText || content.quoted?.previewMessageType
|
|
1145
|
+
? {
|
|
1146
|
+
text: content.quoted.previewText,
|
|
1147
|
+
messageType: content.quoted.previewMessageType,
|
|
1148
|
+
senderId: content.quoted.previewSenderId,
|
|
1149
|
+
}
|
|
1150
|
+
: undefined,
|
|
1151
|
+
log,
|
|
1152
|
+
}),
|
|
1153
|
+
config: dingtalkConfig,
|
|
1154
|
+
isDirect,
|
|
1155
|
+
groupId,
|
|
1156
|
+
quotedSenderId: quotedRecord?.senderId || content.quoted?.previewSenderId,
|
|
1157
|
+
currentSenderId: senderId,
|
|
1158
|
+
currentSenderOriginalId: senderOriginalId,
|
|
1159
|
+
});
|
|
1028
1160
|
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
}
|
|
1044
|
-
let media: MediaFile | null = null;
|
|
1045
|
-
if (record.media.downloadCode) {
|
|
1046
|
-
media = await downloadMedia(dingtalkConfig, record.media.downloadCode, log, originalFilename);
|
|
1047
|
-
if (media) {
|
|
1048
|
-
log?.debug?.(
|
|
1049
|
-
`[DingTalk][QuotedRef] Recovered quoted media from cached downloadCode ` +
|
|
1050
|
-
`recordMsgId=${record.msgId || "(none)"} scope=${data.conversationId}`,
|
|
1051
|
-
);
|
|
1161
|
+
// Try downloading a quoted file from cached downloadCode/spaceId+fileId.
|
|
1162
|
+
const tryDownloadFromRecord = async (
|
|
1163
|
+
record: {
|
|
1164
|
+
msgId?: string;
|
|
1165
|
+
media?: {
|
|
1166
|
+
downloadCode?: string;
|
|
1167
|
+
spaceId?: string;
|
|
1168
|
+
fileId?: string;
|
|
1169
|
+
};
|
|
1170
|
+
} | null,
|
|
1171
|
+
originalFilename?: string,
|
|
1172
|
+
): Promise<MediaFile | null> => {
|
|
1173
|
+
if (!record?.media) {
|
|
1174
|
+
return null;
|
|
1052
1175
|
}
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
const unionId = await getUnionIdByStaffId(dingtalkConfig, data.senderStaffId, log);
|
|
1057
|
-
media = await downloadGroupFile(
|
|
1176
|
+
let media: MediaFile | null = null;
|
|
1177
|
+
if (record.media.downloadCode) {
|
|
1178
|
+
media = await downloadMedia(
|
|
1058
1179
|
dingtalkConfig,
|
|
1059
|
-
record.media.
|
|
1060
|
-
record.media.fileId,
|
|
1061
|
-
unionId,
|
|
1180
|
+
record.media.downloadCode,
|
|
1062
1181
|
log,
|
|
1063
1182
|
originalFilename,
|
|
1064
1183
|
);
|
|
1065
1184
|
if (media) {
|
|
1066
1185
|
log?.debug?.(
|
|
1067
|
-
`[DingTalk][QuotedRef] Recovered quoted media from cached
|
|
1186
|
+
`[DingTalk][QuotedRef] Recovered quoted media from cached downloadCode ` +
|
|
1068
1187
|
`recordMsgId=${record.msgId || "(none)"} scope=${data.conversationId}`,
|
|
1069
1188
|
);
|
|
1070
1189
|
}
|
|
1071
|
-
} catch (err: any) {
|
|
1072
|
-
log?.warn?.(`[DingTalk] spaceId+fileId fallback failed: ${err.message}`);
|
|
1073
1190
|
}
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1191
|
+
if (!media && record.media.spaceId && record.media.fileId && data.senderStaffId) {
|
|
1192
|
+
try {
|
|
1193
|
+
const unionId = await getUnionIdByStaffId(dingtalkConfig, data.senderStaffId, log);
|
|
1194
|
+
media = await downloadGroupFile(
|
|
1195
|
+
dingtalkConfig,
|
|
1196
|
+
record.media.spaceId,
|
|
1197
|
+
record.media.fileId,
|
|
1198
|
+
unionId,
|
|
1199
|
+
log,
|
|
1200
|
+
originalFilename,
|
|
1201
|
+
);
|
|
1202
|
+
if (media) {
|
|
1203
|
+
log?.debug?.(
|
|
1204
|
+
`[DingTalk][QuotedRef] Recovered quoted media from cached spaceId/fileId ` +
|
|
1205
|
+
`recordMsgId=${record.msgId || "(none)"} scope=${data.conversationId}`,
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
} catch (err: any) {
|
|
1209
|
+
log?.warn?.(`[DingTalk] spaceId+fileId fallback failed: ${err.message}`);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
return media;
|
|
1213
|
+
};
|
|
1077
1214
|
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1215
|
+
// Quoted multi-image richText: recover all cached downloadCodes.
|
|
1216
|
+
// Placed before the single-image/ file/ doc-card paths so that the
|
|
1217
|
+
// !mediaPath guard prevents those paths from downloading only the first image.
|
|
1218
|
+
if (
|
|
1219
|
+
!mediaPath &&
|
|
1220
|
+
quotedRecord?.media?.downloadCodes &&
|
|
1221
|
+
quotedRecord.media.downloadCodes.length > 1
|
|
1222
|
+
) {
|
|
1223
|
+
const recovered: MediaFile[] = [];
|
|
1224
|
+
for (const code of quotedRecord.media.downloadCodes) {
|
|
1225
|
+
const result = await downloadMedia(dingtalkConfig, code, log);
|
|
1226
|
+
if (result) {
|
|
1227
|
+
recovered.push(result);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
if (recovered.length > 0) {
|
|
1231
|
+
mediaPath = recovered[0].path;
|
|
1232
|
+
mediaType = recovered[0].mimeType;
|
|
1233
|
+
for (const m of recovered) {
|
|
1234
|
+
mediaPaths.push(m.path);
|
|
1235
|
+
mediaTypes.push(m.mimeType);
|
|
1236
|
+
}
|
|
1237
|
+
attachmentContextMsgId = quotedRecord.msgId || data.msgId;
|
|
1238
|
+
attachmentContextCreatedAt = quotedRecord.createdAt || data.createAt;
|
|
1239
|
+
attachmentContextMessageType = quotedRecord.messageType || "richText";
|
|
1091
1240
|
log?.debug?.(
|
|
1092
|
-
`[DingTalk][QuotedRef] Recovered
|
|
1241
|
+
`[DingTalk][QuotedRef] Recovered ${recovered.length} images from cached multi-image richText ` +
|
|
1242
|
+
`recordMsgId=${quotedRecord.msgId || "(none)"} scope=${data.conversationId}`,
|
|
1093
1243
|
);
|
|
1094
1244
|
}
|
|
1095
|
-
mediaPath = media.path;
|
|
1096
|
-
mediaType = media.mimeType;
|
|
1097
|
-
attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
|
|
1098
|
-
attachmentContextCreatedAt = quotedRecord?.createdAt || data.createAt;
|
|
1099
|
-
attachmentContextMessageType = quotedRecord?.messageType || content.quoted.previewMessageType || "picture";
|
|
1100
|
-
attachmentContextFileName = content.quoted.previewFileName;
|
|
1101
|
-
} else {
|
|
1102
|
-
content.text = `[引用了一张图片,但下载失败]\n\n${content.text}`;
|
|
1103
1245
|
}
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
// Quoted file/audio/video (file/audio/video msgType) or unknownMsgType:
|
|
1107
|
-
// Step 0 tries direct downloadCode; Steps 1-2 fall back to cache and group file API.
|
|
1108
|
-
if (!mediaPath && content.quoted?.isQuotedFile) {
|
|
1109
|
-
let fileResolved = false;
|
|
1110
1246
|
|
|
1111
|
-
//
|
|
1112
|
-
if (!
|
|
1113
|
-
const
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1247
|
+
// Quoted picture: download via existing downloadMedia.
|
|
1248
|
+
if (!mediaPath && content.quoted?.mediaDownloadCode && robotCode) {
|
|
1249
|
+
const quotedOriginalFilename = content.quoted.previewFileName;
|
|
1250
|
+
const media =
|
|
1251
|
+
(await tryDownloadFromRecord(quotedRecord, quotedOriginalFilename)) ||
|
|
1252
|
+
(await downloadMedia(
|
|
1253
|
+
dingtalkConfig,
|
|
1254
|
+
content.quoted.mediaDownloadCode,
|
|
1255
|
+
log,
|
|
1256
|
+
quotedOriginalFilename,
|
|
1257
|
+
));
|
|
1119
1258
|
if (media) {
|
|
1259
|
+
if (!quotedRecord) {
|
|
1260
|
+
log?.debug?.(
|
|
1261
|
+
`[DingTalk][QuotedRef] Recovered quoted image from inbound downloadCode fallback scope=${data.conversationId}`,
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1120
1264
|
mediaPath = media.path;
|
|
1121
1265
|
mediaType = media.mimeType;
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1266
|
+
mediaPaths.push(media.path);
|
|
1267
|
+
mediaTypes.push(media.mimeType);
|
|
1268
|
+
attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
|
|
1269
|
+
attachmentContextCreatedAt = quotedRecord?.createdAt || data.createAt;
|
|
1270
|
+
attachmentContextMessageType =
|
|
1271
|
+
quotedRecord?.messageType || content.quoted.previewMessageType || "picture";
|
|
1125
1272
|
attachmentContextFileName = content.quoted.previewFileName;
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
`[DingTalk][QuotedRef] Downloaded quoted file via direct downloadCode scope=${data.conversationId}`,
|
|
1129
|
-
);
|
|
1273
|
+
} else {
|
|
1274
|
+
content.text = `[引用了一张图片,但下载失败]\n\n${content.text}`;
|
|
1130
1275
|
}
|
|
1131
1276
|
}
|
|
1132
1277
|
|
|
1133
|
-
//
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
)
|
|
1139
|
-
if (
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1278
|
+
// Quoted file/audio/video (file/audio/video msgType) or unknownMsgType:
|
|
1279
|
+
// Step 0 tries direct downloadCode; Steps 1-2 fall back to cache and group file API.
|
|
1280
|
+
if (!mediaPath && content.quoted?.isQuotedFile) {
|
|
1281
|
+
let fileResolved = false;
|
|
1282
|
+
|
|
1283
|
+
// Step 0: Direct download via downloadCode from quoted payload (file/audio/video msgType).
|
|
1284
|
+
if (!fileResolved && content.quoted.fileDownloadCode && robotCode) {
|
|
1285
|
+
const media = await downloadMedia(
|
|
1286
|
+
dingtalkConfig,
|
|
1287
|
+
content.quoted.fileDownloadCode,
|
|
1288
|
+
log,
|
|
1289
|
+
content.quoted.previewFileName,
|
|
1290
|
+
);
|
|
1291
|
+
if (media) {
|
|
1292
|
+
mediaPath = media.path;
|
|
1293
|
+
mediaType = media.mimeType;
|
|
1294
|
+
mediaPaths.push(media.path);
|
|
1295
|
+
mediaTypes.push(media.mimeType);
|
|
1296
|
+
attachmentContextMsgId = content.quoted.msgId || data.msgId;
|
|
1297
|
+
attachmentContextCreatedAt = content.quoted.fileCreatedAt || data.createAt;
|
|
1298
|
+
attachmentContextMessageType = content.quoted.previewMessageType || "file";
|
|
1299
|
+
attachmentContextFileName = content.quoted.previewFileName;
|
|
1300
|
+
fileResolved = true;
|
|
1301
|
+
log?.debug?.(
|
|
1302
|
+
`[DingTalk][QuotedRef] Downloaded quoted file via direct downloadCode scope=${data.conversationId}`,
|
|
1303
|
+
);
|
|
1304
|
+
}
|
|
1147
1305
|
}
|
|
1148
|
-
}
|
|
1149
1306
|
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
openConversationId: data.conversationId,
|
|
1156
|
-
senderStaffId: data.senderStaffId,
|
|
1157
|
-
fileCreatedAt: content.quoted.fileCreatedAt,
|
|
1158
|
-
},
|
|
1159
|
-
log,
|
|
1160
|
-
);
|
|
1161
|
-
if (resolved) {
|
|
1162
|
-
mediaPath = resolved.media.path;
|
|
1163
|
-
mediaType = resolved.media.mimeType;
|
|
1164
|
-
attachmentContextMsgId = content.quoted.msgId || data.msgId;
|
|
1165
|
-
attachmentContextCreatedAt = content.quoted.fileCreatedAt || data.createAt;
|
|
1166
|
-
attachmentContextMessageType = "file";
|
|
1167
|
-
attachmentContextFileName = resolved.name || content.quoted.previewFileName;
|
|
1168
|
-
fileResolved = true;
|
|
1169
|
-
log?.debug?.(
|
|
1170
|
-
`[DingTalk][QuotedRef] Recovered quoted file from group file fallback ` +
|
|
1171
|
-
`scope=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
|
|
1307
|
+
// Step 1: Prefer quotedRef-backed record lookup, then msgId-based cache.
|
|
1308
|
+
if (!fileResolved) {
|
|
1309
|
+
const cachedMedia = await tryDownloadFromRecord(
|
|
1310
|
+
quotedRecord,
|
|
1311
|
+
quotedRecord?.attachmentFileName || content.quoted.previewFileName,
|
|
1172
1312
|
);
|
|
1173
|
-
if (
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
attachmentFileName: resolved.name,
|
|
1186
|
-
ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
|
|
1187
|
-
topic: null,
|
|
1188
|
-
});
|
|
1313
|
+
if (cachedMedia) {
|
|
1314
|
+
mediaPath = cachedMedia.path;
|
|
1315
|
+
mediaType = cachedMedia.mimeType;
|
|
1316
|
+
mediaPaths.push(cachedMedia.path);
|
|
1317
|
+
mediaTypes.push(cachedMedia.mimeType);
|
|
1318
|
+
attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
|
|
1319
|
+
attachmentContextCreatedAt =
|
|
1320
|
+
quotedRecord?.createdAt || content.quoted.fileCreatedAt || data.createAt;
|
|
1321
|
+
attachmentContextMessageType = quotedRecord?.messageType || "file";
|
|
1322
|
+
attachmentContextFileName =
|
|
1323
|
+
quotedRecord?.attachmentFileName || content.quoted.previewFileName;
|
|
1324
|
+
fileResolved = true;
|
|
1189
1325
|
}
|
|
1190
1326
|
}
|
|
1191
|
-
}
|
|
1192
1327
|
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1328
|
+
// Step 2 (group only): Cache miss → fall back to group file API time-based matching.
|
|
1329
|
+
if (!fileResolved && !isDirect) {
|
|
1330
|
+
const resolved = await resolveQuotedFile(
|
|
1331
|
+
dingtalkConfig,
|
|
1332
|
+
{
|
|
1333
|
+
openConversationId: data.conversationId,
|
|
1334
|
+
senderStaffId: data.senderStaffId,
|
|
1335
|
+
fileCreatedAt: content.quoted.fileCreatedAt,
|
|
1336
|
+
},
|
|
1337
|
+
log,
|
|
1338
|
+
);
|
|
1339
|
+
if (resolved) {
|
|
1340
|
+
mediaPath = resolved.media.path;
|
|
1341
|
+
mediaType = resolved.media.mimeType;
|
|
1342
|
+
mediaPaths.push(resolved.media.path);
|
|
1343
|
+
mediaTypes.push(resolved.media.mimeType);
|
|
1344
|
+
attachmentContextMsgId = content.quoted.msgId || data.msgId;
|
|
1345
|
+
attachmentContextCreatedAt = content.quoted.fileCreatedAt || data.createAt;
|
|
1346
|
+
attachmentContextMessageType = "file";
|
|
1347
|
+
attachmentContextFileName = resolved.name || content.quoted.previewFileName;
|
|
1348
|
+
fileResolved = true;
|
|
1349
|
+
log?.debug?.(
|
|
1350
|
+
`[DingTalk][QuotedRef] Recovered quoted file from group file fallback ` +
|
|
1351
|
+
`scope=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
|
|
1352
|
+
);
|
|
1353
|
+
if (content.quoted.msgId) {
|
|
1354
|
+
upsertInboundMessageContext({
|
|
1355
|
+
storePath: accountStorePath,
|
|
1356
|
+
accountId,
|
|
1357
|
+
conversationId: data.conversationId,
|
|
1358
|
+
msgId: content.quoted.msgId,
|
|
1359
|
+
createdAt: content.quoted.fileCreatedAt || Date.now(),
|
|
1360
|
+
messageType: "file",
|
|
1361
|
+
media: {
|
|
1362
|
+
spaceId: resolved.spaceId,
|
|
1363
|
+
fileId: resolved.fileId,
|
|
1364
|
+
},
|
|
1365
|
+
attachmentFileName: resolved.name,
|
|
1366
|
+
ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
|
|
1367
|
+
topic: null,
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1203
1372
|
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
quotedRecord,
|
|
1214
|
-
quotedRecord?.attachmentFileName || content.quoted?.previewFileName,
|
|
1215
|
-
);
|
|
1216
|
-
if (cachedDocMedia) {
|
|
1217
|
-
mediaPath = cachedDocMedia.path;
|
|
1218
|
-
mediaType = cachedDocMedia.mimeType;
|
|
1219
|
-
attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
|
|
1220
|
-
attachmentContextCreatedAt = quotedRecord?.createdAt || content.quoted.fileCreatedAt || data.createAt;
|
|
1221
|
-
attachmentContextMessageType =
|
|
1222
|
-
quotedRecord?.messageType || content.quoted.previewMessageType || "interactiveCardFile";
|
|
1223
|
-
attachmentContextFileName = quotedRecord?.attachmentFileName || content.quoted.previewFileName;
|
|
1224
|
-
docResolved = true;
|
|
1373
|
+
if (!fileResolved) {
|
|
1374
|
+
log?.warn?.(
|
|
1375
|
+
`[DingTalk] Quoted file unresolved: conversationType=${data.conversationType} conversationId=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
|
|
1376
|
+
);
|
|
1377
|
+
const hint = isDirect
|
|
1378
|
+
? "[引用了一个文件,内容无法自动获取,请直接发送该文件]\n\n"
|
|
1379
|
+
: "[引用了一个文件,但无法获取内容]\n\n";
|
|
1380
|
+
content.text = `${hint}${content.text}`;
|
|
1381
|
+
}
|
|
1225
1382
|
}
|
|
1226
1383
|
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1384
|
+
// Quoted DingTalk doc / Drive file card:
|
|
1385
|
+
// 1) Prefer msgId-based cached metadata captured when the original doc card
|
|
1386
|
+
// message was seen.
|
|
1387
|
+
// 2) In group chats, if the bot never saw the original doc card message,
|
|
1388
|
+
// reuse the same group-file fallback chain as ordinary quoted files.
|
|
1389
|
+
if (!mediaPath && content.quoted?.isQuotedDocCard) {
|
|
1390
|
+
let docResolved = false;
|
|
1391
|
+
|
|
1392
|
+
const cachedDocMedia = await tryDownloadFromRecord(
|
|
1393
|
+
quotedRecord,
|
|
1394
|
+
quotedRecord?.attachmentFileName || content.quoted?.previewFileName,
|
|
1236
1395
|
);
|
|
1237
|
-
if (
|
|
1238
|
-
mediaPath =
|
|
1239
|
-
mediaType =
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1396
|
+
if (cachedDocMedia) {
|
|
1397
|
+
mediaPath = cachedDocMedia.path;
|
|
1398
|
+
mediaType = cachedDocMedia.mimeType;
|
|
1399
|
+
mediaPaths.push(cachedDocMedia.path);
|
|
1400
|
+
mediaTypes.push(cachedDocMedia.mimeType);
|
|
1401
|
+
attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
|
|
1402
|
+
attachmentContextCreatedAt =
|
|
1403
|
+
quotedRecord?.createdAt || content.quoted.fileCreatedAt || data.createAt;
|
|
1404
|
+
attachmentContextMessageType =
|
|
1405
|
+
quotedRecord?.messageType || content.quoted.previewMessageType || "interactiveCardFile";
|
|
1406
|
+
attachmentContextFileName =
|
|
1407
|
+
quotedRecord?.attachmentFileName || content.quoted.previewFileName;
|
|
1244
1408
|
docResolved = true;
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
if (!docResolved && !isDirect && content.quoted.fileCreatedAt) {
|
|
1412
|
+
const resolved = await resolveQuotedFile(
|
|
1413
|
+
dingtalkConfig,
|
|
1414
|
+
{
|
|
1415
|
+
openConversationId: data.conversationId,
|
|
1416
|
+
senderStaffId: data.senderStaffId,
|
|
1417
|
+
fileCreatedAt: content.quoted.fileCreatedAt,
|
|
1418
|
+
},
|
|
1419
|
+
log,
|
|
1420
|
+
);
|
|
1421
|
+
if (resolved) {
|
|
1422
|
+
mediaPath = resolved.media.path;
|
|
1423
|
+
mediaType = resolved.media.mimeType;
|
|
1424
|
+
mediaPaths.push(resolved.media.path);
|
|
1425
|
+
mediaTypes.push(resolved.media.mimeType);
|
|
1426
|
+
attachmentContextMsgId = content.quoted.msgId || data.msgId;
|
|
1427
|
+
attachmentContextCreatedAt = content.quoted.fileCreatedAt || data.createAt;
|
|
1428
|
+
attachmentContextMessageType = "interactiveCardFile";
|
|
1429
|
+
attachmentContextFileName = resolved.name || content.quoted.previewFileName;
|
|
1430
|
+
docResolved = true;
|
|
1431
|
+
log?.debug?.(
|
|
1432
|
+
`[DingTalk][QuotedRef] Recovered quoted doc card from group file fallback ` +
|
|
1433
|
+
`scope=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
|
|
1434
|
+
);
|
|
1435
|
+
if (content.quoted.msgId) {
|
|
1436
|
+
upsertInboundMessageContext({
|
|
1437
|
+
storePath: accountStorePath,
|
|
1438
|
+
accountId,
|
|
1439
|
+
conversationId: data.conversationId,
|
|
1440
|
+
msgId: content.quoted.msgId,
|
|
1441
|
+
createdAt: content.quoted.fileCreatedAt || Date.now(),
|
|
1442
|
+
messageType: "interactiveCardFile",
|
|
1443
|
+
media: {
|
|
1444
|
+
spaceId: resolved.spaceId,
|
|
1445
|
+
fileId: resolved.fileId,
|
|
1446
|
+
},
|
|
1447
|
+
attachmentFileName: resolved.name,
|
|
1448
|
+
ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
|
|
1449
|
+
topic: null,
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
if (!docResolved) {
|
|
1456
|
+
log?.warn?.(
|
|
1457
|
+
`[DingTalk] Quoted doc card unresolved: conversationType=${data.conversationType} conversationId=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
|
|
1458
|
+
);
|
|
1459
|
+
const hint = isDirect
|
|
1460
|
+
? "[引用了钉钉文档,内容无法自动获取,请直接发送该文档]\n\n"
|
|
1461
|
+
: "[引用了钉钉文档,但无法获取内容]\n\n";
|
|
1462
|
+
content.text = `${hint}${content.text}`;
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
let attachmentExtractedText: string | undefined;
|
|
1467
|
+
if (mediaPath) {
|
|
1468
|
+
try {
|
|
1469
|
+
const extracted = await extractAttachmentText({
|
|
1470
|
+
path: mediaPath,
|
|
1471
|
+
mimeType: mediaType,
|
|
1472
|
+
fileName: attachmentContextFileName || data.content?.fileName,
|
|
1473
|
+
});
|
|
1474
|
+
if (extracted?.text) {
|
|
1250
1475
|
upsertInboundMessageContext({
|
|
1251
1476
|
storePath: accountStorePath,
|
|
1252
1477
|
accountId,
|
|
1253
1478
|
conversationId: data.conversationId,
|
|
1254
|
-
msgId:
|
|
1255
|
-
createdAt:
|
|
1256
|
-
messageType:
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
|
|
1479
|
+
msgId: attachmentContextMsgId,
|
|
1480
|
+
createdAt: attachmentContextCreatedAt,
|
|
1481
|
+
messageType: attachmentContextMessageType,
|
|
1482
|
+
attachmentText: extracted.text,
|
|
1483
|
+
attachmentTextSource: extracted.sourceType,
|
|
1484
|
+
attachmentTextTruncated: extracted.truncated,
|
|
1485
|
+
attachmentFileName: attachmentContextFileName,
|
|
1486
|
+
ttlMs: ttlDaysToMs(journalTTLDays),
|
|
1263
1487
|
topic: null,
|
|
1264
1488
|
});
|
|
1489
|
+
attachmentExtractedText = `${ATTACHMENT_TEXT_PREFIX}\n${extracted.text}`;
|
|
1265
1490
|
}
|
|
1491
|
+
} catch (err: any) {
|
|
1492
|
+
log?.warn?.(`[DingTalk] Failed to extract attachment text: ${err.message}`);
|
|
1266
1493
|
}
|
|
1267
1494
|
}
|
|
1268
1495
|
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1496
|
+
const inboundBody = content.text;
|
|
1497
|
+
const inboundText = attachmentExtractedText
|
|
1498
|
+
? `${inboundBody.trimEnd()}\n\n${attachmentExtractedText}`
|
|
1499
|
+
: inboundBody;
|
|
1500
|
+
const learningEnabled = isLearningEnabled(dingtalkConfig);
|
|
1501
|
+
const learningContextBlock = buildLearningContextBlock({
|
|
1502
|
+
enabled: learningEnabled,
|
|
1503
|
+
storePath: accountStorePath,
|
|
1504
|
+
accountId,
|
|
1505
|
+
targetId: data.conversationId,
|
|
1506
|
+
content,
|
|
1507
|
+
});
|
|
1508
|
+
const envelopeOptions = rt.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
1509
|
+
const previousTimestamp = rt.channel.session.readSessionUpdatedAt({
|
|
1510
|
+
storePath,
|
|
1511
|
+
sessionKey: route.sessionKey,
|
|
1512
|
+
});
|
|
1279
1513
|
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
attachmentTextSource: extracted.sourceType,
|
|
1298
|
-
attachmentTextTruncated: extracted.truncated,
|
|
1299
|
-
attachmentFileName: attachmentContextFileName,
|
|
1300
|
-
ttlMs: ttlDaysToMs(journalTTLDays),
|
|
1301
|
-
topic: null,
|
|
1302
|
-
});
|
|
1303
|
-
attachmentExtractedText = `${ATTACHMENT_TEXT_PREFIX}\n${extracted.text}`;
|
|
1304
|
-
}
|
|
1305
|
-
} catch (err: any) {
|
|
1306
|
-
log?.warn?.(`[DingTalk] Failed to extract attachment text: ${err.message}`);
|
|
1514
|
+
const groupConfig = !isDirect ? resolveGroupConfig(dingtalkConfig, groupId) : undefined;
|
|
1515
|
+
// GroupSystemPrompt is injected every turn (not only first-turn intro).
|
|
1516
|
+
const groupSystemPromptParts = !isDirect
|
|
1517
|
+
? [
|
|
1518
|
+
buildGroupTurnContextPrompt({
|
|
1519
|
+
conversationId: groupId,
|
|
1520
|
+
senderDingtalkId: senderId,
|
|
1521
|
+
senderName,
|
|
1522
|
+
}),
|
|
1523
|
+
groupConfig?.systemPrompt?.trim(),
|
|
1524
|
+
]
|
|
1525
|
+
: [];
|
|
1526
|
+
const extraSystemPrompt =
|
|
1527
|
+
[...groupSystemPromptParts, learningContextBlock].filter(Boolean).join("\n\n") || undefined;
|
|
1528
|
+
|
|
1529
|
+
if (!isDirect) {
|
|
1530
|
+
noteGroupMember(storePath, groupId, senderId, senderName);
|
|
1307
1531
|
}
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
});
|
|
1322
|
-
const envelopeOptions = rt.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
1323
|
-
const previousTimestamp = rt.channel.session.readSessionUpdatedAt({
|
|
1324
|
-
storePath,
|
|
1325
|
-
sessionKey: route.sessionKey,
|
|
1326
|
-
});
|
|
1327
|
-
|
|
1328
|
-
const groupConfig = !isDirect ? resolveGroupConfig(dingtalkConfig, groupId) : undefined;
|
|
1329
|
-
// GroupSystemPrompt is injected every turn (not only first-turn intro).
|
|
1330
|
-
const groupSystemPromptParts = !isDirect
|
|
1331
|
-
? [
|
|
1332
|
-
buildGroupTurnContextPrompt({
|
|
1333
|
-
conversationId: groupId,
|
|
1334
|
-
senderDingtalkId: senderId,
|
|
1335
|
-
senderName,
|
|
1336
|
-
}),
|
|
1337
|
-
groupConfig?.systemPrompt?.trim(),
|
|
1338
|
-
]
|
|
1339
|
-
: [];
|
|
1340
|
-
const extraSystemPrompt =
|
|
1341
|
-
[...groupSystemPromptParts, learningContextBlock].filter(Boolean).join("\n\n") || undefined;
|
|
1342
|
-
|
|
1343
|
-
if (!isDirect) {
|
|
1344
|
-
noteGroupMember(storePath, groupId, senderId, senderName);
|
|
1345
|
-
}
|
|
1346
|
-
const groupMembers = !isDirect ? formatGroupMembers(storePath, groupId) : undefined;
|
|
1347
|
-
|
|
1348
|
-
const fromLabel = isDirect ? `${senderName} (${senderId})` : `${groupName} - ${senderName}`;
|
|
1349
|
-
const body = rt.channel.reply.formatInboundEnvelope({
|
|
1350
|
-
channel: "DingTalk",
|
|
1351
|
-
from: fromLabel,
|
|
1352
|
-
timestamp: data.createAt,
|
|
1353
|
-
body: inboundText,
|
|
1354
|
-
chatType: isDirect ? "direct" : "group",
|
|
1355
|
-
sender: { name: senderName, id: senderId },
|
|
1356
|
-
previousTimestamp,
|
|
1357
|
-
envelope: envelopeOptions,
|
|
1358
|
-
});
|
|
1532
|
+
const groupMembers = !isDirect ? formatGroupMembers(storePath, groupId) : undefined;
|
|
1533
|
+
|
|
1534
|
+
const fromLabel = isDirect ? `${senderName} (${senderId})` : `${groupName} - ${senderName}`;
|
|
1535
|
+
const body = rt.channel.reply.formatInboundEnvelope({
|
|
1536
|
+
channel: "DingTalk",
|
|
1537
|
+
from: fromLabel,
|
|
1538
|
+
timestamp: data.createAt,
|
|
1539
|
+
body: inboundText,
|
|
1540
|
+
chatType: isDirect ? "direct" : "group",
|
|
1541
|
+
sender: { name: senderName, id: senderId },
|
|
1542
|
+
previousTimestamp,
|
|
1543
|
+
envelope: envelopeOptions,
|
|
1544
|
+
});
|
|
1359
1545
|
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1546
|
+
const ctx = rt.channel.reply.finalizeInboundContext({
|
|
1547
|
+
Body: body,
|
|
1548
|
+
RawBody: inboundText,
|
|
1549
|
+
CommandBody: inboundText,
|
|
1550
|
+
QuotedRef: quotedRef,
|
|
1551
|
+
QuotedRefJson: quotedRef ? JSON.stringify(quotedRef) : undefined,
|
|
1552
|
+
ReplyToId: quotedRuntimeContext?.replyToId,
|
|
1553
|
+
ReplyToBody: quotedRuntimeContext?.replyToBody,
|
|
1554
|
+
ReplyToSender: quotedRuntimeContext?.replyToSender,
|
|
1555
|
+
ReplyToIsQuote: quotedRuntimeContext?.replyToIsQuote,
|
|
1556
|
+
UntrustedContext: quotedRuntimeContext?.untrustedContext
|
|
1557
|
+
? [quotedRuntimeContext.untrustedContext]
|
|
1558
|
+
: undefined,
|
|
1559
|
+
From: to,
|
|
1560
|
+
To: to,
|
|
1561
|
+
SessionKey: route.sessionKey,
|
|
1562
|
+
AccountId: accountId,
|
|
1563
|
+
ChatType: isDirect ? "direct" : "group",
|
|
1564
|
+
ConversationLabel: fromLabel,
|
|
1565
|
+
GroupSubject: isDirect ? undefined : groupName,
|
|
1566
|
+
SenderName: senderName,
|
|
1567
|
+
SenderId: senderId,
|
|
1568
|
+
Provider: "dingtalk",
|
|
1569
|
+
Surface: "dingtalk",
|
|
1570
|
+
MessageSid: data.msgId,
|
|
1571
|
+
Timestamp: data.createAt,
|
|
1572
|
+
MediaPath: mediaPath,
|
|
1573
|
+
MediaType: mediaType,
|
|
1574
|
+
MediaUrl: mediaPath,
|
|
1575
|
+
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
|
1576
|
+
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
|
|
1577
|
+
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
|
1578
|
+
GroupMembers: groupMembers,
|
|
1579
|
+
GroupSystemPrompt: extraSystemPrompt,
|
|
1580
|
+
GroupChannel: isDirect ? undefined : route.sessionKey,
|
|
1581
|
+
CommandAuthorized: commandAuthorized,
|
|
1582
|
+
OriginatingChannel: "dingtalk",
|
|
1583
|
+
OriginatingTo: to,
|
|
1584
|
+
});
|
|
1396
1585
|
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1586
|
+
await rt.channel.session.recordInboundSession({
|
|
1587
|
+
storePath,
|
|
1588
|
+
sessionKey: ctx.SessionKey || route.sessionKey,
|
|
1589
|
+
ctx,
|
|
1590
|
+
updateLastRoute: (() => {
|
|
1591
|
+
if (!isDirect) {
|
|
1592
|
+
return undefined;
|
|
1593
|
+
}
|
|
1594
|
+
const pinnedMainDmOwner = resolvePinnedMainDmOwner({
|
|
1595
|
+
dmScope: cfg.session?.dmScope,
|
|
1596
|
+
allowFrom: dingtalkConfig.allowFrom,
|
|
1597
|
+
});
|
|
1598
|
+
const senderRecipient = (senderOriginalId || senderId || "").trim().toLowerCase();
|
|
1599
|
+
if (
|
|
1600
|
+
pinnedMainDmOwner &&
|
|
1601
|
+
senderRecipient &&
|
|
1602
|
+
pinnedMainDmOwner.trim().toLowerCase() !== senderRecipient
|
|
1603
|
+
) {
|
|
1604
|
+
log?.debug?.(
|
|
1605
|
+
`[DingTalk] Skipping main-session last route update for ${senderRecipient} (pinned owner ${pinnedMainDmOwner})`,
|
|
1606
|
+
);
|
|
1607
|
+
return undefined;
|
|
1608
|
+
}
|
|
1609
|
+
return { sessionKey: route.mainSessionKey, channel: "dingtalk", to, accountId };
|
|
1610
|
+
})(),
|
|
1611
|
+
onRecordError: (err: unknown) => {
|
|
1612
|
+
log?.error?.(`[DingTalk] Failed to record inbound session: ${String(err)}`);
|
|
1613
|
+
},
|
|
1614
|
+
});
|
|
1426
1615
|
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1616
|
+
log?.info?.(`[DingTalk] Inbound: from=${senderName} text="${content.text.slice(0, 50)}..."`);
|
|
1617
|
+
|
|
1618
|
+
// ---- Pre-lock abort: bypass session lock for stop requests ----
|
|
1619
|
+
// isAbortRequestText matches "/stop", "停止", "stop", "esc", etc.
|
|
1620
|
+
// Calling dispatchReplyWithBufferedBlockDispatcher without holding the lock lets
|
|
1621
|
+
// tryFastAbortFromMessage (inside the SDK) kill any in-flight generation immediately,
|
|
1622
|
+
// rather than waiting for it to finish before the stop message is processed.
|
|
1623
|
+
//
|
|
1624
|
+
// Strip leading @mention tokens before the abort check so that messages like
|
|
1625
|
+
// "@Agent /stop" are correctly recognised as abort requests in both DM and group
|
|
1626
|
+
// chats. In groups DingTalk usually strips @BotName at the protocol level, but
|
|
1627
|
+
// in DMs with multi-agent routing the @mention prefix survives all the way here.
|
|
1628
|
+
const textForAbortCheck = stripLeadingMentions(inboundText).trim();
|
|
1629
|
+
if (isAbortRequestText(textForAbortCheck)) {
|
|
1630
|
+
log?.info?.(
|
|
1631
|
+
`[DingTalk] Abort request detected, bypassing session lock for session=${route.sessionKey}`,
|
|
1632
|
+
);
|
|
1633
|
+
// In card mode: capture the abort confirmation text so we can write it into
|
|
1634
|
+
// the card (instead of sending a separate plain text message).
|
|
1635
|
+
let abortConfirmationText: string | undefined;
|
|
1636
|
+
try {
|
|
1637
|
+
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1638
|
+
ctx,
|
|
1639
|
+
cfg,
|
|
1640
|
+
dispatcherOptions: {
|
|
1641
|
+
responsePrefix: "",
|
|
1642
|
+
deliver: async (payload) => {
|
|
1643
|
+
if (!payload.text) {
|
|
1644
|
+
log?.debug?.(`[DingTalk] Abort deliver received non-text payload, skipping`);
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
if (currentAICard) {
|
|
1648
|
+
// Card mode: capture text — will be written to card after dispatch.
|
|
1649
|
+
abortConfirmationText = payload.text;
|
|
1650
|
+
} else {
|
|
1651
|
+
try {
|
|
1652
|
+
if (sessionWebhook) {
|
|
1653
|
+
await sendBySession(dingtalkConfig, sessionWebhook, payload.text, {
|
|
1654
|
+
log,
|
|
1655
|
+
accountId,
|
|
1656
|
+
storePath: accountStorePath,
|
|
1657
|
+
});
|
|
1658
|
+
} else {
|
|
1659
|
+
await sendMessage(dingtalkConfig, to, payload.text, {
|
|
1660
|
+
log,
|
|
1661
|
+
accountId,
|
|
1662
|
+
storePath: accountStorePath,
|
|
1663
|
+
conversationId: groupId,
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
} catch (deliverErr) {
|
|
1667
|
+
log?.warn?.(
|
|
1668
|
+
`[DingTalk] Abort reply delivery failed: ${getErrorMessage(deliverErr)}`,
|
|
1669
|
+
);
|
|
1476
1670
|
}
|
|
1477
|
-
} catch (deliverErr) {
|
|
1478
|
-
log?.warn?.(
|
|
1479
|
-
`[DingTalk] Abort reply delivery failed: ${getErrorMessage(deliverErr)}`,
|
|
1480
|
-
);
|
|
1481
1671
|
}
|
|
1482
|
-
}
|
|
1672
|
+
},
|
|
1483
1673
|
},
|
|
1484
|
-
}
|
|
1485
|
-
})
|
|
1486
|
-
|
|
1487
|
-
log?.warn?.(`[DingTalk] Abort dispatch failed: ${getErrorMessage(abortErr)}`);
|
|
1488
|
-
}
|
|
1489
|
-
// Finalize the card that was created for this message before the abort check.
|
|
1490
|
-
// Without this, the card stays in PROCESSING ("处理中...") indefinitely.
|
|
1491
|
-
if (currentAICard && !isCardInTerminalState(currentAICard.state)) {
|
|
1492
|
-
try {
|
|
1493
|
-
await finishAICard(currentAICard, abortConfirmationText ?? "已停止", log);
|
|
1494
|
-
} catch (cardErr) {
|
|
1495
|
-
log?.warn?.(`[DingTalk] Abort card finalize failed: ${getErrorMessage(cardErr)}`);
|
|
1496
|
-
currentAICard.state = AICardStatus.FAILED;
|
|
1674
|
+
});
|
|
1675
|
+
} catch (abortErr) {
|
|
1676
|
+
log?.warn?.(`[DingTalk] Abort dispatch failed: ${getErrorMessage(abortErr)}`);
|
|
1497
1677
|
}
|
|
1678
|
+
// Finalize the card that was created for this message before the abort check.
|
|
1679
|
+
// Without this, the card stays in PROCESSING ("处理中...") indefinitely.
|
|
1680
|
+
// Use V2 finalize (commitAICardBlocks) for consistent state transition.
|
|
1681
|
+
if (currentAICard && !isCardInTerminalState(currentAICard.state)) {
|
|
1682
|
+
try {
|
|
1683
|
+
const abortText = abortConfirmationText ?? "已停止";
|
|
1684
|
+
const abortBlockList = [{ type: 0, markdown: abortText }];
|
|
1685
|
+
const blockListJson = JSON.stringify(abortBlockList);
|
|
1686
|
+
|
|
1687
|
+
await commitAICardBlocks(
|
|
1688
|
+
currentAICard,
|
|
1689
|
+
{
|
|
1690
|
+
blockListJson,
|
|
1691
|
+
content: abortText,
|
|
1692
|
+
},
|
|
1693
|
+
log,
|
|
1694
|
+
);
|
|
1695
|
+
|
|
1696
|
+
log?.debug?.(
|
|
1697
|
+
`[DingTalk] Abort card finalized via V2 API: card=${currentAICard.cardInstanceId}`,
|
|
1698
|
+
);
|
|
1699
|
+
} catch (cardErr) {
|
|
1700
|
+
log?.warn?.(`[DingTalk] Abort card finalize failed: ${getErrorMessage(cardErr)}`);
|
|
1701
|
+
currentAICard.state = AICardStatus.FAILED;
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
return;
|
|
1498
1705
|
}
|
|
1499
|
-
return;
|
|
1500
|
-
}
|
|
1501
1706
|
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1707
|
+
// ---- Pre-lock BTW: bypass session lock for /btw side questions ----
|
|
1708
|
+
// /btw runs an isolated, tool-less side query in openclaw without polluting
|
|
1709
|
+
// the main run's transcript. The dispatch must NOT acquire the session lock,
|
|
1710
|
+
// otherwise it would queue behind the in-flight main task and lose its "side
|
|
1711
|
+
// question" semantics.
|
|
1712
|
+
//
|
|
1713
|
+
// The `isBtwBypass` flag is computed once early (just after `content` is
|
|
1714
|
+
// resolved, just before `createAICard`). The same constant gates `createAICard` above and
|
|
1715
|
+
// drives this branch — single decision, two consequences. See the comment
|
|
1716
|
+
// at the flag's definition for why /btw uses pre-OCR `content.text` while
|
|
1717
|
+
// abort uses `inboundText`.
|
|
1718
|
+
if (isBtwBypass) {
|
|
1719
|
+
log?.info?.(
|
|
1720
|
+
`[DingTalk] BTW request detected, bypassing session lock for session=${route.sessionKey}`,
|
|
1721
|
+
);
|
|
1722
|
+
// Empty fallback (NOT "Unknown" like the file's main `senderName` variable):
|
|
1723
|
+
// when the nickname is missing we want the blockquote to render as
|
|
1724
|
+
// `> /btw <question>` rather than `> Unknown: /btw <question>`. Read locally
|
|
1725
|
+
// here so the existing `senderName` semantics elsewhere in the file are not
|
|
1726
|
+
// affected.
|
|
1727
|
+
const btwSenderName = data.senderNick || "";
|
|
1728
|
+
try {
|
|
1729
|
+
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1730
|
+
ctx,
|
|
1506
1731
|
cfg,
|
|
1507
|
-
|
|
1508
|
-
|
|
1732
|
+
dispatcherOptions: {
|
|
1733
|
+
responsePrefix: "",
|
|
1734
|
+
deliver: async (payload) => {
|
|
1735
|
+
if (!payload.text) {
|
|
1736
|
+
log?.debug?.(`[DingTalk] BTW deliver received non-text payload, skipping`);
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
await deliverBtwReply({
|
|
1740
|
+
config: dingtalkConfig,
|
|
1741
|
+
sessionWebhook,
|
|
1742
|
+
conversationId: groupId,
|
|
1743
|
+
to,
|
|
1744
|
+
senderName: btwSenderName,
|
|
1745
|
+
// Use pre-OCR content.text (consistent with isBtwBypass detection)
|
|
1746
|
+
// so the blockquote shows the user's typed body, not body + OCR.
|
|
1747
|
+
rawQuestion: content.text,
|
|
1748
|
+
replyText: payload.text,
|
|
1749
|
+
log,
|
|
1750
|
+
accountId,
|
|
1751
|
+
storePath: accountStorePath,
|
|
1752
|
+
});
|
|
1753
|
+
},
|
|
1754
|
+
},
|
|
1509
1755
|
});
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
: normalizedAckReaction === "emoji"
|
|
1515
|
-
? "🤔思考中"
|
|
1516
|
-
: normalizedAckReaction;
|
|
1517
|
-
const shouldAttachAckReaction = Boolean(resolvedAckReaction);
|
|
1518
|
-
let ackReactionAttached = false;
|
|
1519
|
-
let ackReactionAttachedAt = 0;
|
|
1520
|
-
|
|
1521
|
-
if (shouldAttachAckReaction) {
|
|
1522
|
-
ackReactionAttached = await attachNativeAckReaction(
|
|
1523
|
-
dingtalkConfig,
|
|
1524
|
-
{
|
|
1525
|
-
msgId: data.msgId,
|
|
1526
|
-
conversationId: groupId,
|
|
1527
|
-
reactionName: resolvedAckReaction,
|
|
1528
|
-
},
|
|
1529
|
-
log,
|
|
1530
|
-
);
|
|
1531
|
-
if (ackReactionAttached) {
|
|
1532
|
-
ackReactionAttachedAt = Date.now();
|
|
1533
|
-
log?.debug?.(
|
|
1534
|
-
`[DingTalk] Initial ack reaction attached mode=${normalizedAckReaction || "off"} reaction=${resolvedAckReaction}`,
|
|
1535
|
-
);
|
|
1756
|
+
} catch (btwErr) {
|
|
1757
|
+
log?.warn?.(`[DingTalk] BTW dispatch failed: ${getErrorMessage(btwErr)}`);
|
|
1758
|
+
}
|
|
1759
|
+
return;
|
|
1536
1760
|
}
|
|
1537
|
-
}
|
|
1538
1761
|
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1762
|
+
const ackReaction =
|
|
1763
|
+
typeof dingtalkConfig.ackReaction === "string"
|
|
1764
|
+
? dingtalkConfig.ackReaction.trim()
|
|
1765
|
+
: resolveAckReactionSetting({
|
|
1766
|
+
cfg,
|
|
1767
|
+
accountId,
|
|
1768
|
+
agentId: route.agentId,
|
|
1769
|
+
});
|
|
1770
|
+
const normalizedAckReaction = ackReaction === "off" ? "" : ackReaction;
|
|
1771
|
+
const resolvedAckReaction =
|
|
1772
|
+
normalizedAckReaction === "kaomoji"
|
|
1773
|
+
? classifyAckReactionEmoji(content.text).emoji
|
|
1774
|
+
: normalizedAckReaction === "emoji"
|
|
1775
|
+
? "🤔思考中"
|
|
1776
|
+
: normalizedAckReaction;
|
|
1777
|
+
const shouldAttachAckReaction = Boolean(resolvedAckReaction);
|
|
1778
|
+
let ackReactionAttached = false;
|
|
1779
|
+
let ackReactionAttachedAt = 0;
|
|
1780
|
+
|
|
1781
|
+
if (shouldAttachAckReaction) {
|
|
1782
|
+
ackReactionAttached = await attachNativeAckReaction(
|
|
1783
|
+
dingtalkConfig,
|
|
1784
|
+
{
|
|
1785
|
+
msgId: data.msgId,
|
|
1786
|
+
conversationId: groupId,
|
|
1787
|
+
reactionName: resolvedAckReaction,
|
|
1788
|
+
},
|
|
1789
|
+
log,
|
|
1790
|
+
);
|
|
1791
|
+
if (ackReactionAttached) {
|
|
1792
|
+
ackReactionAttachedAt = Date.now();
|
|
1793
|
+
log?.debug?.(
|
|
1794
|
+
`[DingTalk] Initial ack reaction attached mode=${normalizedAckReaction || "off"} reaction=${resolvedAckReaction}`,
|
|
1795
|
+
);
|
|
1796
|
+
}
|
|
1546
1797
|
}
|
|
1547
1798
|
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
text: normalizedText,
|
|
1556
|
-
audioAsVoice: false,
|
|
1557
|
-
replyToCurrent: false,
|
|
1558
|
-
hasAudioTag: false,
|
|
1559
|
-
hasReplyTag: false,
|
|
1560
|
-
};
|
|
1561
|
-
const mediaUrls: string[] = [];
|
|
1562
|
-
const contentLines: string[] = [];
|
|
1563
|
-
|
|
1564
|
-
for (const line of parsedInline.text.split("\n")) {
|
|
1565
|
-
const trimmed = line.trim();
|
|
1566
|
-
const mediaCandidate = trimmed.replace(/^(?:\[\[[^[\]]+\]\]\s*)+/, "");
|
|
1567
|
-
if (mediaCandidate.startsWith(MEDIA_DIRECTIVE_PREFIX)) {
|
|
1568
|
-
const mediaSource = mediaCandidate.slice(MEDIA_DIRECTIVE_PREFIX.length).trim();
|
|
1569
|
-
if (mediaSource) {
|
|
1570
|
-
mediaUrls.push(mediaSource);
|
|
1571
|
-
continue;
|
|
1572
|
-
}
|
|
1799
|
+
function parseInlineReplyPayloadText(text: unknown): {
|
|
1800
|
+
text?: string;
|
|
1801
|
+
mediaUrls: string[];
|
|
1802
|
+
audioAsVoice: boolean;
|
|
1803
|
+
} {
|
|
1804
|
+
if (typeof text !== "string") {
|
|
1805
|
+
return { text: undefined, mediaUrls: [], audioAsVoice: false };
|
|
1573
1806
|
}
|
|
1574
|
-
contentLines.push(line);
|
|
1575
|
-
}
|
|
1576
1807
|
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1808
|
+
const normalizedText = text.replace(/\r\n/g, "\n");
|
|
1809
|
+
const parsedInline = normalizedText.includes("[[")
|
|
1810
|
+
? parseInlineDirectives(normalizedText, {
|
|
1811
|
+
stripAudioTag: true,
|
|
1812
|
+
stripReplyTags: false,
|
|
1813
|
+
})
|
|
1814
|
+
: {
|
|
1815
|
+
text: normalizedText,
|
|
1816
|
+
audioAsVoice: false,
|
|
1817
|
+
replyToCurrent: false,
|
|
1818
|
+
hasAudioTag: false,
|
|
1819
|
+
hasReplyTag: false,
|
|
1820
|
+
};
|
|
1821
|
+
const mediaUrls: string[] = [];
|
|
1822
|
+
const contentLines: string[] = [];
|
|
1823
|
+
|
|
1824
|
+
for (const line of parsedInline.text.split("\n")) {
|
|
1825
|
+
const trimmed = line.trim();
|
|
1826
|
+
const mediaCandidate = trimmed.replace(/^(?:\[\[[^[\]]+\]\]\s*)+/, "");
|
|
1827
|
+
if (mediaCandidate.startsWith(MEDIA_DIRECTIVE_PREFIX)) {
|
|
1828
|
+
const mediaSource = mediaCandidate.slice(MEDIA_DIRECTIVE_PREFIX.length).trim();
|
|
1829
|
+
if (mediaSource) {
|
|
1830
|
+
mediaUrls.push(mediaSource);
|
|
1831
|
+
continue;
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
contentLines.push(line);
|
|
1587
1835
|
}
|
|
1588
|
-
}
|
|
1589
1836
|
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1837
|
+
const cleanedText = contentLines.join("\n").trim();
|
|
1838
|
+
const inlineTextWasTransformed = parsedInline.text !== normalizedText;
|
|
1839
|
+
if (mediaUrls.length === 0) {
|
|
1840
|
+
const standaloneMediaSource = extractStandaloneMediaSource(cleanedText);
|
|
1841
|
+
if (standaloneMediaSource) {
|
|
1842
|
+
return {
|
|
1843
|
+
text: undefined,
|
|
1844
|
+
mediaUrls: [standaloneMediaSource],
|
|
1845
|
+
audioAsVoice: parsedInline.audioAsVoice,
|
|
1846
|
+
};
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1599
1849
|
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1850
|
+
return {
|
|
1851
|
+
// Keep ordinary text formatting stable except for newline normalization.
|
|
1852
|
+
// Once inline parsing actually strips directives/media lines, return the
|
|
1853
|
+
// cleaned body text instead of the original raw payload.
|
|
1854
|
+
text:
|
|
1855
|
+
mediaUrls.length > 0 || inlineTextWasTransformed
|
|
1856
|
+
? cleanedText || undefined
|
|
1857
|
+
: normalizedText,
|
|
1858
|
+
mediaUrls,
|
|
1859
|
+
audioAsVoice: parsedInline.audioAsVoice,
|
|
1860
|
+
};
|
|
1604
1861
|
}
|
|
1605
1862
|
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
trimmed.
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
trimmed.startsWith(".\\") ||
|
|
1612
|
-
trimmed.startsWith("../") ||
|
|
1613
|
-
trimmed.startsWith("..\\") ||
|
|
1614
|
-
trimmed.startsWith("/") ||
|
|
1615
|
-
trimmed.startsWith("\\") ||
|
|
1616
|
-
/^[a-zA-Z]:[\\/]/.test(trimmed) ||
|
|
1617
|
-
trimmed.includes("/") ||
|
|
1618
|
-
trimmed.includes("\\");
|
|
1619
|
-
if (!hasPathLikeShape) {
|
|
1620
|
-
return undefined;
|
|
1621
|
-
}
|
|
1863
|
+
function extractStandaloneMediaSource(text: string): string | undefined {
|
|
1864
|
+
const trimmed = text.trim();
|
|
1865
|
+
if (!trimmed || trimmed.includes("\n") || /\s/.test(trimmed)) {
|
|
1866
|
+
return undefined;
|
|
1867
|
+
}
|
|
1622
1868
|
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1869
|
+
const hasPathLikeShape =
|
|
1870
|
+
/^https?:\/\/\S+$/i.test(trimmed) ||
|
|
1871
|
+
trimmed.startsWith("~/") ||
|
|
1872
|
+
trimmed.startsWith("~\\") ||
|
|
1873
|
+
trimmed.startsWith("./") ||
|
|
1874
|
+
trimmed.startsWith(".\\") ||
|
|
1875
|
+
trimmed.startsWith("../") ||
|
|
1876
|
+
trimmed.startsWith("..\\") ||
|
|
1877
|
+
trimmed.startsWith("/") ||
|
|
1878
|
+
trimmed.startsWith("\\") ||
|
|
1879
|
+
/^[a-zA-Z]:[\\/]/.test(trimmed) ||
|
|
1880
|
+
trimmed.includes("/") ||
|
|
1881
|
+
trimmed.includes("\\");
|
|
1882
|
+
if (!hasPathLikeShape) {
|
|
1883
|
+
return undefined;
|
|
1884
|
+
}
|
|
1627
1885
|
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
inlineReplyPayload?: ReturnType<typeof parseInlineReplyPayloadText>,
|
|
1632
|
-
): boolean {
|
|
1633
|
-
// Normalize all reply-time voice hints into the shared `audioAsVoice`
|
|
1634
|
-
// semantic that strategies and media delivery use downstream.
|
|
1635
|
-
const richPayload = payload as ReplyStreamPayload & {
|
|
1636
|
-
audioAsVoice?: unknown;
|
|
1637
|
-
asVoice?: unknown;
|
|
1638
|
-
};
|
|
1639
|
-
const sharedValue = parseBooleanLike(richPayload.audioAsVoice);
|
|
1640
|
-
if (sharedValue !== undefined) {
|
|
1641
|
-
return sharedValue;
|
|
1886
|
+
return STANDALONE_MEDIA_PATH_EXTENSIONS.has(path.extname(trimmed).toLowerCase())
|
|
1887
|
+
? trimmed
|
|
1888
|
+
: undefined;
|
|
1642
1889
|
}
|
|
1643
|
-
|
|
1644
|
-
|
|
1890
|
+
|
|
1891
|
+
// ---- Shared media delivery helper ----
|
|
1892
|
+
function extractSharedAudioAsVoice(
|
|
1893
|
+
payload: ReplyStreamPayload,
|
|
1894
|
+
inlineReplyPayload?: ReturnType<typeof parseInlineReplyPayloadText>,
|
|
1895
|
+
): boolean {
|
|
1896
|
+
// Normalize all reply-time voice hints into the shared `audioAsVoice`
|
|
1897
|
+
// semantic that strategies and media delivery use downstream.
|
|
1898
|
+
const richPayload = payload as ReplyStreamPayload & {
|
|
1899
|
+
audioAsVoice?: unknown;
|
|
1900
|
+
asVoice?: unknown;
|
|
1901
|
+
};
|
|
1902
|
+
const sharedValue = parseBooleanLike(richPayload.audioAsVoice);
|
|
1903
|
+
if (sharedValue !== undefined) {
|
|
1904
|
+
return sharedValue;
|
|
1905
|
+
}
|
|
1906
|
+
if (parseBooleanLike(richPayload.asVoice) === true) {
|
|
1907
|
+
return true;
|
|
1908
|
+
}
|
|
1909
|
+
return inlineReplyPayload?.audioAsVoice === true;
|
|
1645
1910
|
}
|
|
1646
|
-
return inlineReplyPayload?.audioAsVoice === true;
|
|
1647
|
-
}
|
|
1648
1911
|
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
mediaPath: actualMediaPath,
|
|
1662
|
-
asVoice: options?.audioAsVoice === true,
|
|
1663
|
-
});
|
|
1664
|
-
if (sessionWebhook) {
|
|
1665
|
-
const sendResult = await sendMessage(dingtalkConfig, to, "", {
|
|
1666
|
-
sessionWebhook,
|
|
1912
|
+
async function deliverMediaAttachments(urls: string[], options?: { audioAsVoice?: boolean }) {
|
|
1913
|
+
for (const rawMediaUrl of urls) {
|
|
1914
|
+
const preparedMedia = await prepareMediaInput(
|
|
1915
|
+
rawMediaUrl,
|
|
1916
|
+
log,
|
|
1917
|
+
dingtalkConfig.mediaUrlAllowlist,
|
|
1918
|
+
);
|
|
1919
|
+
try {
|
|
1920
|
+
const actualMediaPath = preparedMedia.cleanup
|
|
1921
|
+
? preparedMedia.path
|
|
1922
|
+
: resolveRelativePath(preparedMedia.path);
|
|
1923
|
+
const outMediaType = resolveOutboundMediaType({
|
|
1667
1924
|
mediaPath: actualMediaPath,
|
|
1668
|
-
|
|
1669
|
-
log,
|
|
1670
|
-
accountId,
|
|
1671
|
-
storePath: accountStorePath,
|
|
1672
|
-
conversationId: groupId,
|
|
1673
|
-
quotedRef: replyQuotedRef,
|
|
1925
|
+
asVoice: options?.audioAsVoice === true,
|
|
1674
1926
|
});
|
|
1675
|
-
if (
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
dingtalkConfig,
|
|
1681
|
-
to,
|
|
1682
|
-
actualMediaPath,
|
|
1683
|
-
outMediaType,
|
|
1684
|
-
{
|
|
1685
|
-
accountId,
|
|
1927
|
+
if (sessionWebhook) {
|
|
1928
|
+
const sendResult = await sendMessage(dingtalkConfig, to, "", {
|
|
1929
|
+
sessionWebhook,
|
|
1930
|
+
mediaPath: actualMediaPath,
|
|
1931
|
+
mediaType: outMediaType,
|
|
1686
1932
|
log,
|
|
1933
|
+
accountId,
|
|
1687
1934
|
storePath: accountStorePath,
|
|
1688
1935
|
conversationId: groupId,
|
|
1689
1936
|
quotedRef: replyQuotedRef,
|
|
1690
|
-
}
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1937
|
+
});
|
|
1938
|
+
if (!sendResult.ok) {
|
|
1939
|
+
throw new Error(sendResult.error || "Media reply send failed");
|
|
1940
|
+
}
|
|
1941
|
+
} else {
|
|
1942
|
+
const sendResult = await sendProactiveMedia(
|
|
1943
|
+
dingtalkConfig,
|
|
1944
|
+
to,
|
|
1945
|
+
actualMediaPath,
|
|
1946
|
+
outMediaType,
|
|
1947
|
+
{
|
|
1948
|
+
accountId,
|
|
1949
|
+
log,
|
|
1950
|
+
storePath: accountStorePath,
|
|
1951
|
+
conversationId: groupId,
|
|
1952
|
+
quotedRef: replyQuotedRef,
|
|
1953
|
+
},
|
|
1954
|
+
);
|
|
1955
|
+
if (!sendResult.ok) {
|
|
1956
|
+
throw new Error(sendResult.error || "Media reply send failed");
|
|
1957
|
+
}
|
|
1694
1958
|
}
|
|
1959
|
+
} finally {
|
|
1960
|
+
await preparedMedia.cleanup?.();
|
|
1695
1961
|
}
|
|
1696
|
-
} finally {
|
|
1697
|
-
await preparedMedia.cleanup?.();
|
|
1698
1962
|
}
|
|
1699
1963
|
}
|
|
1700
|
-
}
|
|
1701
|
-
|
|
1702
|
-
// ---- Extract mediaUrls from runtime payload ----
|
|
1703
|
-
function extractMediaUrls(
|
|
1704
|
-
payload: ReplyStreamPayload,
|
|
1705
|
-
inlineReplyPayload?: ReturnType<typeof parseInlineReplyPayloadText>,
|
|
1706
|
-
): string[] {
|
|
1707
|
-
const richPayload = payload as typeof payload & {
|
|
1708
|
-
mediaUrl?: string;
|
|
1709
|
-
mediaUrls?: string[];
|
|
1710
|
-
};
|
|
1711
|
-
const explicitMediaUrls = Array.isArray(richPayload.mediaUrls)
|
|
1712
|
-
? richPayload.mediaUrls.filter((entry: unknown) => typeof entry === "string" && entry.trim())
|
|
1713
|
-
: richPayload.mediaUrl &&
|
|
1714
|
-
typeof richPayload.mediaUrl === "string" &&
|
|
1715
|
-
richPayload.mediaUrl.trim()
|
|
1716
|
-
? [richPayload.mediaUrl]
|
|
1717
|
-
: [];
|
|
1718
|
-
return explicitMediaUrls.length > 0
|
|
1719
|
-
? explicitMediaUrls
|
|
1720
|
-
: inlineReplyPayload?.mediaUrls ?? [];
|
|
1721
|
-
}
|
|
1722
|
-
|
|
1723
|
-
// Serialize dispatchReply + card finalize per session to prevent the runtime
|
|
1724
|
-
// from receiving concurrent dispatch calls on the same session key, which
|
|
1725
|
-
// causes empty replies for all but the first caller.
|
|
1726
|
-
// Each sub-agent call acquires its own lock since sub-agent sessions have
|
|
1727
|
-
// different session keys (different agentId), so no deadlock risk.
|
|
1728
|
-
const currentOutTrackId = currentAICard?.outTrackId;
|
|
1729
|
-
const shouldTrackDynamicAckReaction =
|
|
1730
|
-
(normalizedAckReaction === "emoji" || normalizedAckReaction === "kaomoji")
|
|
1731
|
-
&& shouldAttachAckReaction;
|
|
1732
|
-
const runtimeEvents = (rt as typeof rt & {
|
|
1733
|
-
events?: {
|
|
1734
|
-
onAgentEvent?: (listener: (event: unknown) => void) => (() => void);
|
|
1735
|
-
};
|
|
1736
|
-
}).events;
|
|
1737
|
-
const releaseSessionLock = await acquireSessionLock(route.sessionKey);
|
|
1738
|
-
const dynamicAckReactionController = createDynamicAckReactionController({
|
|
1739
|
-
enabled: shouldTrackDynamicAckReaction,
|
|
1740
|
-
initialReaction: resolvedAckReaction || "",
|
|
1741
|
-
initialAttached: ackReactionAttached,
|
|
1742
|
-
initialAttachedAt: ackReactionAttachedAt,
|
|
1743
|
-
dingtalkConfig,
|
|
1744
|
-
msgId: data.msgId,
|
|
1745
|
-
conversationId: groupId,
|
|
1746
|
-
sessionKey: route.sessionKey,
|
|
1747
|
-
log,
|
|
1748
|
-
runtimeEvents,
|
|
1749
|
-
onReactionDisposed: () => {
|
|
1750
|
-
ackReactionAttached = false;
|
|
1751
|
-
},
|
|
1752
|
-
});
|
|
1753
|
-
try {
|
|
1754
|
-
if (!ackReactionAttached && shouldAttachAckReaction) {
|
|
1755
|
-
log?.debug?.("[DingTalk] Native ack reaction unavailable; skipping fallback.");
|
|
1756
|
-
}
|
|
1757
|
-
const isCurrentCardStopRequested = () =>
|
|
1758
|
-
Boolean(
|
|
1759
|
-
currentAICard
|
|
1760
|
-
&& (
|
|
1761
|
-
currentAICard.state === AICardStatus.STOPPED
|
|
1762
|
-
|| (currentOutTrackId && isCardRunStopRequested(currentOutTrackId))
|
|
1763
|
-
),
|
|
1764
|
-
);
|
|
1765
1964
|
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1965
|
+
// ---- Extract mediaUrls from runtime payload ----
|
|
1966
|
+
function extractMediaUrls(
|
|
1967
|
+
payload: ReplyStreamPayload,
|
|
1968
|
+
inlineReplyPayload?: ReturnType<typeof parseInlineReplyPayloadText>,
|
|
1969
|
+
): string[] {
|
|
1970
|
+
const richPayload = payload as typeof payload & {
|
|
1971
|
+
mediaUrl?: string;
|
|
1972
|
+
mediaUrls?: string[];
|
|
1973
|
+
};
|
|
1974
|
+
const explicitMediaUrls = Array.isArray(richPayload.mediaUrls)
|
|
1975
|
+
? richPayload.mediaUrls.filter(
|
|
1976
|
+
(entry: unknown) => typeof entry === "string" && entry.trim(),
|
|
1977
|
+
)
|
|
1978
|
+
: richPayload.mediaUrl &&
|
|
1979
|
+
typeof richPayload.mediaUrl === "string" &&
|
|
1980
|
+
richPayload.mediaUrl.trim()
|
|
1981
|
+
? [richPayload.mediaUrl]
|
|
1982
|
+
: [];
|
|
1983
|
+
return explicitMediaUrls.length > 0
|
|
1984
|
+
? explicitMediaUrls
|
|
1985
|
+
: (inlineReplyPayload?.mediaUrls ?? []);
|
|
1769
1986
|
}
|
|
1770
1987
|
|
|
1771
|
-
//
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1988
|
+
// Serialize dispatchReply + card finalize per session to prevent the runtime
|
|
1989
|
+
// from receiving concurrent dispatch calls on the same session key, which
|
|
1990
|
+
// causes empty replies for all but the first caller.
|
|
1991
|
+
// Each sub-agent call acquires its own lock since sub-agent sessions have
|
|
1992
|
+
// different session keys (different agentId), so no deadlock risk.
|
|
1993
|
+
const currentOutTrackId = currentAICard?.outTrackId;
|
|
1994
|
+
const shouldTrackDynamicAckReaction =
|
|
1995
|
+
(normalizedAckReaction === "emoji" || normalizedAckReaction === "kaomoji") &&
|
|
1996
|
+
shouldAttachAckReaction;
|
|
1997
|
+
const runtimeEvents = (
|
|
1998
|
+
rt as typeof rt & {
|
|
1999
|
+
events?: {
|
|
2000
|
+
onAgentEvent?: (listener: (event: unknown) => void) => () => void;
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
2003
|
+
).events;
|
|
2004
|
+
const releaseSessionLock = await acquireSessionLock(route.sessionKey);
|
|
2005
|
+
const dynamicAckReactionController = createDynamicAckReactionController({
|
|
2006
|
+
enabled: shouldTrackDynamicAckReaction,
|
|
2007
|
+
initialReaction: resolvedAckReaction || "",
|
|
2008
|
+
initialAttached: ackReactionAttached,
|
|
2009
|
+
initialAttachedAt: ackReactionAttachedAt,
|
|
2010
|
+
dingtalkConfig,
|
|
2011
|
+
msgId: data.msgId,
|
|
2012
|
+
conversationId: groupId,
|
|
1775
2013
|
sessionKey: route.sessionKey,
|
|
1776
|
-
sessionUpdatedAt: previousTimestamp,
|
|
1777
2014
|
log,
|
|
2015
|
+
runtimeEvents,
|
|
2016
|
+
onReactionDisposed: () => {
|
|
2017
|
+
ackReactionAttached = false;
|
|
2018
|
+
},
|
|
1778
2019
|
});
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
2020
|
+
try {
|
|
2021
|
+
if (!ackReactionAttached && shouldAttachAckReaction) {
|
|
2022
|
+
log?.debug?.("[DingTalk] Native ack reaction unavailable; skipping fallback.");
|
|
2023
|
+
}
|
|
2024
|
+
const isCurrentCardStopRequested = () =>
|
|
2025
|
+
Boolean(
|
|
2026
|
+
currentAICard &&
|
|
2027
|
+
(currentAICard.state === AICardStatus.STOPPED ||
|
|
2028
|
+
(currentOutTrackId && isCardRunStopRequested(currentOutTrackId))),
|
|
2029
|
+
);
|
|
2030
|
+
|
|
2031
|
+
if (isCurrentCardStopRequested()) {
|
|
2032
|
+
log?.info?.(
|
|
2033
|
+
"[DingTalk][CardStop] Skip dispatch because card was already stopped before session lock was acquired",
|
|
2034
|
+
);
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
// ---- Create reply strategy (card or markdown) ----
|
|
2039
|
+
const replyMode: ReplyMode = useCardMode && !!currentAICard ? "card" : "markdown";
|
|
2040
|
+
const sessionReasoningLevel = readSessionReasoningLevel({
|
|
2041
|
+
storePath,
|
|
1801
2042
|
sessionKey: route.sessionKey,
|
|
1802
|
-
|
|
2043
|
+
sessionUpdatedAt: previousTimestamp,
|
|
1803
2044
|
log,
|
|
1804
|
-
})
|
|
1805
|
-
|
|
1806
|
-
log,
|
|
1807
|
-
replyQuotedRef,
|
|
1808
|
-
deliverMedia: deliverMediaAttachments,
|
|
1809
|
-
isStopRequested: isCurrentCardStopRequested,
|
|
1810
|
-
});
|
|
1811
|
-
|
|
1812
|
-
try {
|
|
1813
|
-
let deliveredFinalCount = 0;
|
|
1814
|
-
const dispatchResult = await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1815
|
-
ctx,
|
|
2045
|
+
});
|
|
2046
|
+
const legacyCardStreamReasoning = resolveLegacyCardStreamReasoningForInternalUse({
|
|
1816
2047
|
cfg,
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
2048
|
+
accountId,
|
|
2049
|
+
});
|
|
2050
|
+
const strategyConfig =
|
|
2051
|
+
legacyCardStreamReasoning === undefined
|
|
2052
|
+
? dingtalkConfig
|
|
2053
|
+
: { ...dingtalkConfig, cardStreamReasoning: legacyCardStreamReasoning };
|
|
2054
|
+
const sessionTaskState = getSessionState(accountId, taskInfoConversationId);
|
|
2055
|
+
const taskMeta = {
|
|
2056
|
+
model: sessionTaskState?.model,
|
|
2057
|
+
effort: sessionTaskState?.effort,
|
|
2058
|
+
elapsedMs:
|
|
2059
|
+
typeof sessionTaskState?.taskStartTime === "number"
|
|
2060
|
+
? Math.max(0, Date.now() - sessionTaskState.taskStartTime)
|
|
2061
|
+
: undefined,
|
|
2062
|
+
agent: getAgentDisplayName({
|
|
2063
|
+
subAgentOptions,
|
|
2064
|
+
agentId: route.agentId,
|
|
2065
|
+
agentsList: cfg.agents?.list,
|
|
2066
|
+
}),
|
|
2067
|
+
};
|
|
2068
|
+
|
|
2069
|
+
const strategy = createReplyStrategy({
|
|
2070
|
+
config: strategyConfig,
|
|
2071
|
+
card: currentAICard,
|
|
2072
|
+
useCardMode: replyMode === "card",
|
|
2073
|
+
to,
|
|
2074
|
+
sessionWebhook,
|
|
2075
|
+
senderId,
|
|
2076
|
+
isDirect,
|
|
2077
|
+
accountId,
|
|
2078
|
+
storePath: accountStorePath,
|
|
2079
|
+
sessionKey: route.sessionKey,
|
|
2080
|
+
sessionAgentId: route.agentId,
|
|
2081
|
+
disableBlockStreaming: shouldDisableBlockStreamingForReplyMode({
|
|
2082
|
+
replyMode,
|
|
2083
|
+
sessionKey: route.sessionKey,
|
|
2084
|
+
reasoningLevel: sessionReasoningLevel,
|
|
2085
|
+
log,
|
|
2086
|
+
}),
|
|
2087
|
+
groupId,
|
|
2088
|
+
log,
|
|
2089
|
+
replyQuotedRef,
|
|
2090
|
+
deliverMedia: deliverMediaAttachments,
|
|
2091
|
+
isStopRequested: isCurrentCardStopRequested,
|
|
2092
|
+
inboundText: rawInboundText,
|
|
2093
|
+
taskMeta,
|
|
2094
|
+
});
|
|
2095
|
+
|
|
2096
|
+
try {
|
|
2097
|
+
let deliveredFinalCount = 0;
|
|
2098
|
+
const dispatchResult = await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
2099
|
+
ctx,
|
|
2100
|
+
cfg,
|
|
2101
|
+
dispatcherOptions: {
|
|
2102
|
+
responsePrefix: subAgentOptions?.responsePrefix || "",
|
|
2103
|
+
deliver: async (payload: ReplyStreamPayload, info?: ReplyChunkInfo) => {
|
|
2104
|
+
if (isCurrentCardStopRequested()) {
|
|
2105
|
+
log?.debug?.(
|
|
2106
|
+
"[DingTalk][CardStop] Ignoring reply delivery because stop was already requested",
|
|
2107
|
+
);
|
|
2108
|
+
return;
|
|
1827
2109
|
}
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
mediaUrls,
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
2110
|
+
try {
|
|
2111
|
+
if (info?.kind === "final") {
|
|
2112
|
+
deliveredFinalCount += 1;
|
|
2113
|
+
}
|
|
2114
|
+
const inlineReplyPayload = parseInlineReplyPayloadText(payload.text);
|
|
2115
|
+
const mediaUrls = extractMediaUrls(payload, inlineReplyPayload);
|
|
2116
|
+
const richPayload = payload as ReplyStreamPayload & { isReasoning?: boolean };
|
|
2117
|
+
await strategy.deliver({
|
|
2118
|
+
text: inlineReplyPayload.text,
|
|
2119
|
+
mediaUrls,
|
|
2120
|
+
audioAsVoice: extractSharedAudioAsVoice(payload, inlineReplyPayload),
|
|
2121
|
+
kind: (info?.kind as DeliverPayload["kind"]) || "block",
|
|
2122
|
+
isReasoning: richPayload.isReasoning === true,
|
|
2123
|
+
});
|
|
2124
|
+
} catch (err: unknown) {
|
|
2125
|
+
log?.error?.(`[DingTalk] Reply failed: ${getErrorMessage(err)}`);
|
|
2126
|
+
const responseData = getErrorResponseData(err);
|
|
2127
|
+
if (responseData !== undefined) {
|
|
2128
|
+
log?.error?.(formatDingTalkErrorPayloadLog("inbound.replyDeliver", responseData));
|
|
2129
|
+
}
|
|
2130
|
+
throw err;
|
|
1843
2131
|
}
|
|
1844
|
-
|
|
1845
|
-
}
|
|
2132
|
+
},
|
|
1846
2133
|
},
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
});
|
|
2134
|
+
replyOptions: strategy.getReplyOptions(),
|
|
2135
|
+
});
|
|
1850
2136
|
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
2137
|
+
const bufferedFinal =
|
|
2138
|
+
dispatchResult && typeof dispatchResult === "object" && "queuedFinal" in dispatchResult
|
|
2139
|
+
? (dispatchResult as { queuedFinal?: unknown }).queuedFinal
|
|
2140
|
+
: undefined;
|
|
2141
|
+
const finalCount =
|
|
2142
|
+
dispatchResult && typeof dispatchResult === "object" && "counts" in dispatchResult
|
|
2143
|
+
? (dispatchResult as { counts?: { final?: unknown } }).counts?.final
|
|
2144
|
+
: undefined;
|
|
2145
|
+
|
|
2146
|
+
log?.info?.(
|
|
2147
|
+
`[DingTalk][Dispatch] completed — deliveredFinalCount=${deliveredFinalCount} ` +
|
|
2148
|
+
`counts.final=${typeof finalCount === "number" ? finalCount : "n/a"} ` +
|
|
2149
|
+
`queuedFinalType=${typeof bufferedFinal}`,
|
|
2150
|
+
);
|
|
1859
2151
|
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
2152
|
+
const bufferedFinalPayload =
|
|
2153
|
+
typeof bufferedFinal === "string"
|
|
2154
|
+
? ({ text: bufferedFinal } satisfies ReplyStreamPayload)
|
|
2155
|
+
: bufferedFinal && typeof bufferedFinal === "object"
|
|
2156
|
+
? (bufferedFinal as ReplyStreamPayload)
|
|
2157
|
+
: undefined;
|
|
2158
|
+
|
|
2159
|
+
if (deliveredFinalCount === 0 && bufferedFinalPayload) {
|
|
2160
|
+
const inlineReplyPayload = parseInlineReplyPayloadText(bufferedFinalPayload.text);
|
|
2161
|
+
const mediaUrls = extractMediaUrls(bufferedFinalPayload, inlineReplyPayload);
|
|
2162
|
+
const hasBufferedText =
|
|
2163
|
+
typeof bufferedFinalPayload.text === "string" &&
|
|
2164
|
+
bufferedFinalPayload.text.trim().length > 0;
|
|
2165
|
+
if (hasBufferedText || mediaUrls.length > 0) {
|
|
2166
|
+
await strategy.deliver({
|
|
2167
|
+
text: inlineReplyPayload.text,
|
|
2168
|
+
mediaUrls,
|
|
2169
|
+
audioAsVoice: extractSharedAudioAsVoice(bufferedFinalPayload, inlineReplyPayload),
|
|
2170
|
+
kind: "final",
|
|
2171
|
+
isReasoning: bufferedFinalPayload.isReasoning === true,
|
|
2172
|
+
});
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
} catch (dispatchErr: unknown) {
|
|
2176
|
+
const error =
|
|
2177
|
+
dispatchErr instanceof Error ? dispatchErr : new Error(getErrorMessage(dispatchErr));
|
|
2178
|
+
await strategy.abort(error);
|
|
2179
|
+
throw dispatchErr;
|
|
2180
|
+
}
|
|
1865
2181
|
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
mediaUrls: extractMediaUrls(richBufferedPayload, inlineReplyPayload),
|
|
1875
|
-
audioAsVoice: extractSharedAudioAsVoice(richBufferedPayload, inlineReplyPayload),
|
|
1876
|
-
kind: "final",
|
|
1877
|
-
isReasoning: false,
|
|
1878
|
-
});
|
|
2182
|
+
await strategy.finalize();
|
|
2183
|
+
} finally {
|
|
2184
|
+
// Only remove the registry entry if no stop was requested. When a stop is
|
|
2185
|
+
// in progress, card-stop-handler may still be running async operations
|
|
2186
|
+
// (finalize card, hide button, gateway abort) that read the record.
|
|
2187
|
+
// In that case, let the 30-minute TTL sweep handle cleanup.
|
|
2188
|
+
if (currentOutTrackId && !isCardRunStopRequested(currentOutTrackId)) {
|
|
2189
|
+
removeCardRun(currentOutTrackId);
|
|
1879
2190
|
}
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
2191
|
+
await waitForDynamicAckDispose({
|
|
2192
|
+
dispose: () => dynamicAckReactionController.dispose(MIN_THINKING_REACTION_VISIBLE_MS),
|
|
2193
|
+
log,
|
|
2194
|
+
sessionKey: route.sessionKey,
|
|
2195
|
+
});
|
|
2196
|
+
releaseSessionLock();
|
|
1884
2197
|
}
|
|
1885
|
-
|
|
1886
|
-
await strategy.finalize();
|
|
1887
2198
|
} finally {
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
// (finalize card, hide button, gateway abort) that read the record.
|
|
1891
|
-
// In that case, let the 30-minute TTL sweep handle cleanup.
|
|
1892
|
-
if (currentOutTrackId && !isCardRunStopRequested(currentOutTrackId)) {
|
|
1893
|
-
removeCardRun(currentOutTrackId);
|
|
2199
|
+
if (cardFlightKey) {
|
|
2200
|
+
cardCreationInFlight.delete(cardFlightKey);
|
|
1894
2201
|
}
|
|
1895
|
-
await waitForDynamicAckDispose({
|
|
1896
|
-
dispose: () => dynamicAckReactionController.dispose(MIN_THINKING_REACTION_VISIBLE_MS),
|
|
1897
|
-
log,
|
|
1898
|
-
sessionKey: route.sessionKey,
|
|
1899
|
-
});
|
|
1900
|
-
releaseSessionLock();
|
|
1901
2202
|
}
|
|
1902
2203
|
}
|