@soimy/dingtalk 3.6.0 → 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/openclaw.plugin.json +49 -5
- package/package.json +1 -1
- package/src/auth.ts +5 -2
- package/src/card-service.ts +41 -16
- package/src/channel.ts +5 -2
- package/src/config-schema.ts +2 -1
- package/src/config.ts +24 -67
- package/src/gateway/channel-gateway.ts +9 -8
- package/src/inbound-handler.ts +1239 -1061
- package/src/message-context-store.ts +183 -85
- package/src/messaging/channel-actions.ts +2 -1
- package/src/onboarding.ts +15 -6
- package/src/path-utils.ts +49 -0
- package/src/secret-input.ts +216 -0
- package/src/targeting/agent-routing.ts +30 -5
- package/src/types.ts +25 -10
package/src/inbound-handler.ts
CHANGED
|
@@ -1,30 +1,24 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { isAbortRequestText, isBtwRequestText } from "openclaw/plugin-sdk/reply-runtime";
|
|
4
|
-
import
|
|
4
|
+
import { parseInlineDirectives } from "openclaw/plugin-sdk/text-runtime";
|
|
5
5
|
import { normalizeAllowFrom, isSenderAllowed, resolveGroupAccess } from "./access-control";
|
|
6
|
-
import { buildAgentSessionKey, resolveSubAgentRoute, dispatchSubAgents } from "./targeting/agent-routing";
|
|
7
|
-
import { getAgentDisplayName } from "./targeting/agent-name-matcher";
|
|
8
6
|
import { classifyAckReactionEmoji } from "./ack-reaction-classifier";
|
|
9
7
|
import { attachNativeAckReaction } from "./ack-reaction-service";
|
|
10
8
|
import { createDynamicAckReactionController } from "./ack-reaction/dynamic-ack-reaction-controller";
|
|
11
|
-
import { extractAttachmentText } from "./messaging/attachment-text-extractor";
|
|
12
9
|
import { getAccessToken } from "./auth";
|
|
13
10
|
import { createAICard, commitAICardBlocks, isCardInTerminalState } from "./card-service";
|
|
11
|
+
import { isCardRunStopRequested, registerCardRun, removeCardRun } from "./card/card-run-registry";
|
|
14
12
|
import { renderStatusLine } from "./card/statusline-renderer";
|
|
15
13
|
import { handleInboundCommandDispatch } from "./command/inbound-command-dispatch-service";
|
|
16
|
-
import { resolveAckReactionSetting, resolveGroupConfig, resolveRelativePath, resolveRobotCode } from "./config";
|
|
17
|
-
import { AICardStatus } from "./types";
|
|
18
|
-
import {
|
|
19
|
-
isCardRunStopRequested,
|
|
20
|
-
registerCardRun,
|
|
21
|
-
removeCardRun,
|
|
22
|
-
} from "./card/card-run-registry";
|
|
23
14
|
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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";
|
|
28
22
|
import { setCurrentLogger } from "./logger-context";
|
|
29
23
|
import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
|
|
30
24
|
import {
|
|
@@ -33,8 +27,14 @@ import {
|
|
|
33
27
|
upsertInboundMessageContext,
|
|
34
28
|
} from "./message-context-store";
|
|
35
29
|
import { extractMessageContent } from "./message-utils";
|
|
30
|
+
import { extractAttachmentText } from "./messaging/attachment-text-extractor";
|
|
36
31
|
import { deliverBtwReply, stripLeadingMentions } from "./messaging/btw-deliver";
|
|
37
32
|
import { resolveQuotedRuntimeContext } from "./messaging/quoted-context";
|
|
33
|
+
import {
|
|
34
|
+
downloadGroupFile,
|
|
35
|
+
getUnionIdByStaffId,
|
|
36
|
+
resolveQuotedFile,
|
|
37
|
+
} from "./messaging/quoted-file-service";
|
|
38
38
|
import {
|
|
39
39
|
buildInboundQuotedRef,
|
|
40
40
|
createReplyQuotedRef,
|
|
@@ -45,7 +45,6 @@ import {
|
|
|
45
45
|
clearProactiveRiskObservationsForTest,
|
|
46
46
|
getProactiveRiskObservationForAny,
|
|
47
47
|
} from "./proactive-risk-registry";
|
|
48
|
-
import { downloadGroupFile, getUnionIdByStaffId, resolveQuotedFile } from "./messaging/quoted-file-service";
|
|
49
48
|
import { createReplyStrategy } from "./reply-strategy";
|
|
50
49
|
import type { DeliverPayload } from "./reply-strategy-types";
|
|
51
50
|
import { getDingTalkRuntime } from "./runtime";
|
|
@@ -58,13 +57,26 @@ import {
|
|
|
58
57
|
} from "./session-peer-store";
|
|
59
58
|
import { resolveDingTalkSessionPeer } from "./session-routing";
|
|
60
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";
|
|
61
67
|
import {
|
|
62
68
|
upsertObservedGroupTarget,
|
|
63
69
|
upsertObservedUserTarget,
|
|
64
70
|
} from "./targeting/target-directory-store";
|
|
71
|
+
import { AICardStatus } from "./types";
|
|
65
72
|
import type { DingTalkConfig, HandleDingTalkMessageParams, Logger, MediaFile } from "./types";
|
|
66
|
-
import {
|
|
67
|
-
|
|
73
|
+
import {
|
|
74
|
+
formatDingTalkErrorPayloadLog,
|
|
75
|
+
getErrorMessage,
|
|
76
|
+
getErrorResponseData,
|
|
77
|
+
maskSensitiveData,
|
|
78
|
+
parseBooleanLike,
|
|
79
|
+
} from "./utils";
|
|
68
80
|
|
|
69
81
|
const DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS = 24;
|
|
70
82
|
const MIN_THINKING_REACTION_VISIBLE_MS = 1200;
|
|
@@ -95,10 +107,19 @@ const STANDALONE_MEDIA_PATH_EXTENSIONS = new Set([
|
|
|
95
107
|
".rar",
|
|
96
108
|
]);
|
|
97
109
|
const proactiveHintLastSentAt = new Map<string, number>();
|
|
98
|
-
const sessionReasoningLevelCache = new Map<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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>();
|
|
102
123
|
type ReplyMode = "card" | "markdown";
|
|
103
124
|
|
|
104
125
|
function resolveQuotedContextAllowFrom(
|
|
@@ -133,7 +154,15 @@ function filterQuotedRuntimeContext(params: {
|
|
|
133
154
|
currentSenderId: string;
|
|
134
155
|
currentSenderOriginalId: string;
|
|
135
156
|
}): ReturnType<typeof resolveQuotedRuntimeContext> {
|
|
136
|
-
const {
|
|
157
|
+
const {
|
|
158
|
+
context,
|
|
159
|
+
config,
|
|
160
|
+
isDirect,
|
|
161
|
+
groupId,
|
|
162
|
+
quotedSenderId,
|
|
163
|
+
currentSenderId,
|
|
164
|
+
currentSenderOriginalId,
|
|
165
|
+
} = params;
|
|
137
166
|
if (!context || isDirect) {
|
|
138
167
|
return context;
|
|
139
168
|
}
|
|
@@ -150,9 +179,7 @@ function filterQuotedRuntimeContext(params: {
|
|
|
150
179
|
currentSenderOriginalId,
|
|
151
180
|
});
|
|
152
181
|
const senderAllowed =
|
|
153
|
-
allow.hasEntries && !!senderId
|
|
154
|
-
? isSenderAllowed({ allow, senderId })
|
|
155
|
-
: false;
|
|
182
|
+
allow.hasEntries && !!senderId ? isSenderAllowed({ allow, senderId }) : false;
|
|
156
183
|
|
|
157
184
|
if (senderAllowed) {
|
|
158
185
|
return context;
|
|
@@ -178,9 +205,9 @@ function readSessionReasoningLevel(params: {
|
|
|
178
205
|
const cacheKey = `${params.storePath}:${params.sessionKey}`;
|
|
179
206
|
const cached = sessionReasoningLevelCache.get(cacheKey);
|
|
180
207
|
if (
|
|
181
|
-
cached
|
|
182
|
-
|
|
183
|
-
|
|
208
|
+
cached &&
|
|
209
|
+
params.sessionUpdatedAt !== undefined &&
|
|
210
|
+
cached.updatedAt === params.sessionUpdatedAt
|
|
184
211
|
) {
|
|
185
212
|
return cached.reasoningLevel;
|
|
186
213
|
}
|
|
@@ -520,7 +547,16 @@ export async function downloadMedia(
|
|
|
520
547
|
}
|
|
521
548
|
|
|
522
549
|
export async function handleDingTalkMessage(params: HandleDingTalkMessageParams): Promise<void> {
|
|
523
|
-
const {
|
|
550
|
+
const {
|
|
551
|
+
cfg,
|
|
552
|
+
accountId,
|
|
553
|
+
data,
|
|
554
|
+
sessionWebhook,
|
|
555
|
+
log,
|
|
556
|
+
dingtalkConfig,
|
|
557
|
+
subAgentOptions,
|
|
558
|
+
preDownloadedMedia,
|
|
559
|
+
} = params;
|
|
524
560
|
const rt = getDingTalkRuntime();
|
|
525
561
|
|
|
526
562
|
// Save logger globally so shared services can log consistently without threading log everywhere.
|
|
@@ -649,31 +685,32 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
649
685
|
if (groupAccess.legacyFallback) {
|
|
650
686
|
log?.info?.(
|
|
651
687
|
`[DingTalk] DEPRECATED: groupPolicy=allowlist is using "allowFrom" for group access control. ` +
|
|
652
|
-
|
|
688
|
+
`Please migrate to "groups" (group ID allowlist) or "groupAllowFrom" (sender allowlist).`,
|
|
653
689
|
);
|
|
654
690
|
}
|
|
655
691
|
|
|
656
692
|
if (!groupAccess.allowed) {
|
|
657
693
|
if (groupAccess.reason === "disabled") {
|
|
658
|
-
log?.debug?.(
|
|
694
|
+
log?.debug?.(
|
|
695
|
+
`[DingTalk] Group disabled: all group messages dropped (groupPolicy=disabled)`,
|
|
696
|
+
);
|
|
659
697
|
return;
|
|
660
698
|
}
|
|
661
699
|
|
|
662
|
-
const denyMessage =
|
|
663
|
-
|
|
664
|
-
|
|
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添加到允许列表中。`;
|
|
665
704
|
|
|
666
705
|
log?.debug?.(
|
|
667
706
|
`[DingTalk] Group blocked: conversationId=${groupId} senderId=${senderId} reason=${groupAccess.reason}`,
|
|
668
707
|
);
|
|
669
708
|
|
|
670
709
|
try {
|
|
671
|
-
await sendBySession(
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
{ log, atUserId: senderId },
|
|
676
|
-
);
|
|
710
|
+
await sendBySession(dingtalkConfig, sessionWebhook, denyMessage, {
|
|
711
|
+
log,
|
|
712
|
+
atUserId: senderId,
|
|
713
|
+
});
|
|
677
714
|
} catch (err: any) {
|
|
678
715
|
log?.debug?.(`[DingTalk] Failed to send group access denied message: ${err.message}`);
|
|
679
716
|
if (err?.response?.data !== undefined) {
|
|
@@ -686,9 +723,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
686
723
|
return;
|
|
687
724
|
}
|
|
688
725
|
|
|
689
|
-
log?.debug?.(
|
|
690
|
-
`[DingTalk] Group authorized: conversationId=${groupId} senderId=${senderId}`,
|
|
691
|
-
);
|
|
726
|
+
log?.debug?.(`[DingTalk] Group authorized: conversationId=${groupId} senderId=${senderId}`);
|
|
692
727
|
}
|
|
693
728
|
|
|
694
729
|
// Calculate account store path and session peer (for session alias feature)
|
|
@@ -826,15 +861,19 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
826
861
|
const isBtwBypass = isBtwRequestText(stripLeadingMentions(content.text).trim());
|
|
827
862
|
const taskInfoConversationId = groupId || to;
|
|
828
863
|
const sessionTaskState = initSessionState(accountId, taskInfoConversationId);
|
|
829
|
-
const initialStatusLine =
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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;
|
|
838
877
|
|
|
839
878
|
// 3) Select response mode (card vs markdown).
|
|
840
879
|
// Card creation runs BEFORE media download so the user sees immediate visual
|
|
@@ -847,6 +886,20 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
847
886
|
let useCardMode = dingtalkConfig.messageType === "card";
|
|
848
887
|
let currentAICard: import("./types").AICardInstance | undefined;
|
|
849
888
|
|
|
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
|
+
|
|
850
903
|
if (useCardMode && !isBtwBypass) {
|
|
851
904
|
try {
|
|
852
905
|
log?.debug?.(
|
|
@@ -877,1149 +930,1274 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
877
930
|
}
|
|
878
931
|
} else {
|
|
879
932
|
useCardMode = false;
|
|
933
|
+
if (cardFlightKey) {
|
|
934
|
+
cardCreationInFlight.delete(cardFlightKey);
|
|
935
|
+
cardFlightKey = undefined;
|
|
936
|
+
}
|
|
880
937
|
log?.warn?.(
|
|
881
938
|
"[DingTalk] Failed to create AI card (returned null), fallback to text/markdown.",
|
|
882
939
|
);
|
|
883
940
|
}
|
|
884
941
|
} catch (err: any) {
|
|
885
942
|
useCardMode = false;
|
|
943
|
+
if (cardFlightKey) {
|
|
944
|
+
cardCreationInFlight.delete(cardFlightKey);
|
|
945
|
+
cardFlightKey = undefined;
|
|
946
|
+
}
|
|
886
947
|
log?.warn?.(
|
|
887
948
|
`[DingTalk] Failed to create AI card: ${err.message}, fallback to text/markdown.`,
|
|
888
949
|
);
|
|
889
950
|
}
|
|
890
951
|
}
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
if (hasLegacyQuoteContent && !quotedRef) {
|
|
895
|
-
log?.debug?.(
|
|
896
|
-
`[DingTalk] Legacy quoteContent present without resolvable quotedRef: ` +
|
|
897
|
-
`conversationType=${data.conversationType} conversationId=${data.conversationId} ` +
|
|
898
|
-
`msgId=${data.msgId} originalMsgId=${data.originalMsgId || "(none)"}`,
|
|
899
|
-
);
|
|
900
|
-
}
|
|
901
|
-
if (quotedRef) {
|
|
902
|
-
log?.debug?.(
|
|
903
|
-
`[DingTalk][QuotedRef] Built inbound quotedRef msgId=${data.msgId} scope=${groupId} ` +
|
|
904
|
-
`quotedRef=${JSON.stringify(quotedRef)}`,
|
|
905
|
-
);
|
|
906
|
-
} else if (
|
|
907
|
-
data.text?.isReplyMsg ||
|
|
908
|
-
data.originalMsgId ||
|
|
909
|
-
data.originalProcessQueryKey ||
|
|
910
|
-
content.quoted
|
|
911
|
-
) {
|
|
912
|
-
log?.debug?.(
|
|
913
|
-
`[DingTalk][QuotedRef] Reply metadata present without resolvable quotedRef ` +
|
|
914
|
-
`msgId=${data.msgId} scope=${groupId} originalMsgId=${data.originalMsgId || "(none)"} ` +
|
|
915
|
-
`originalProcessQueryKey=${data.originalProcessQueryKey || "(none)"}`,
|
|
916
|
-
);
|
|
917
|
-
}
|
|
918
|
-
|
|
952
|
+
// Outer try/finally guarantees cardFlightKey cleanup even when the handler
|
|
953
|
+
// returns or throws between card creation and session-lock release.
|
|
919
954
|
try {
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
accountId,
|
|
923
|
-
conversationId: groupId,
|
|
924
|
-
msgId: data.msgId,
|
|
925
|
-
messageType: content.messageType,
|
|
926
|
-
text: content.text,
|
|
927
|
-
quotedRef,
|
|
928
|
-
senderId,
|
|
929
|
-
senderName,
|
|
930
|
-
createdAt: data.createAt,
|
|
931
|
-
ttlMs: ttlDaysToMs(journalTTLDays),
|
|
932
|
-
ttlReferenceMs: data.createAt,
|
|
933
|
-
cleanupCreatedAtTtlDays: journalTTLDays,
|
|
934
|
-
topic: null,
|
|
935
|
-
});
|
|
936
|
-
} catch (err) {
|
|
937
|
-
log?.warn?.(`[DingTalk] Message context inbound append failed: ${String(err)}`);
|
|
938
|
-
}
|
|
955
|
+
const hasLegacyQuoteContent =
|
|
956
|
+
typeof data.content?.quoteContent === "string" && data.content.quoteContent.trim().length > 0;
|
|
939
957
|
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
content.
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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
|
+
);
|
|
963
981
|
}
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
// Cache downloadCode (+ spaceId/fileId) for quoted file lookups (DM + group).
|
|
967
|
-
if (content.mediaPath && data.msgId) {
|
|
968
|
-
upsertInboundMessageContext({
|
|
969
|
-
storePath: accountStorePath,
|
|
970
|
-
accountId,
|
|
971
|
-
conversationId: data.conversationId,
|
|
972
|
-
msgId: data.msgId,
|
|
973
|
-
createdAt: data.createAt,
|
|
974
|
-
messageType: content.messageType,
|
|
975
|
-
media: {
|
|
976
|
-
downloadCode: content.mediaPath,
|
|
977
|
-
spaceId: data.content?.spaceId,
|
|
978
|
-
fileId: data.content?.fileId,
|
|
979
|
-
},
|
|
980
|
-
attachmentFileName: attachmentContextFileName,
|
|
981
|
-
ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
|
|
982
|
-
topic: null,
|
|
983
|
-
});
|
|
984
|
-
}
|
|
985
982
|
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
|
|
1007
|
-
topic: null,
|
|
1008
|
-
});
|
|
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
|
+
}
|
|
1009
1003
|
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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(
|
|
1014
1039
|
dingtalkConfig,
|
|
1015
|
-
|
|
1016
|
-
content.docFileId,
|
|
1017
|
-
unionId,
|
|
1040
|
+
downloadCode,
|
|
1018
1041
|
log,
|
|
1019
1042
|
attachmentContextFileName,
|
|
1020
1043
|
);
|
|
1021
|
-
if (
|
|
1022
|
-
mediaPath
|
|
1023
|
-
|
|
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);
|
|
1024
1051
|
}
|
|
1025
|
-
} catch (err: any) {
|
|
1026
|
-
log?.warn?.(`[DingTalk] Doc card download failed: ${err.message}`);
|
|
1027
1052
|
}
|
|
1028
1053
|
}
|
|
1029
|
-
}
|
|
1030
1054
|
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
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({
|
|
1040
1130
|
storePath: accountStorePath,
|
|
1041
1131
|
accountId,
|
|
1042
1132
|
conversationId: data.conversationId,
|
|
1043
1133
|
quotedRef,
|
|
1044
|
-
firstRecord: quotedRecord,
|
|
1045
|
-
firstPreview:
|
|
1046
|
-
content.quoted?.previewText ||
|
|
1047
|
-
content.quoted?.previewMessageType
|
|
1048
|
-
? {
|
|
1049
|
-
text: content.quoted.previewText,
|
|
1050
|
-
messageType: content.quoted.previewMessageType,
|
|
1051
|
-
senderId: content.quoted.previewSenderId,
|
|
1052
|
-
}
|
|
1053
|
-
: undefined,
|
|
1054
1134
|
log,
|
|
1055
|
-
})
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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
|
+
});
|
|
1063
1160
|
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
}
|
|
1079
|
-
let media: MediaFile | null = null;
|
|
1080
|
-
if (record.media.downloadCode) {
|
|
1081
|
-
media = await downloadMedia(dingtalkConfig, record.media.downloadCode, log, originalFilename);
|
|
1082
|
-
if (media) {
|
|
1083
|
-
log?.debug?.(
|
|
1084
|
-
`[DingTalk][QuotedRef] Recovered quoted media from cached downloadCode ` +
|
|
1085
|
-
`recordMsgId=${record.msgId || "(none)"} scope=${data.conversationId}`,
|
|
1086
|
-
);
|
|
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;
|
|
1087
1175
|
}
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
const unionId = await getUnionIdByStaffId(dingtalkConfig, data.senderStaffId, log);
|
|
1092
|
-
media = await downloadGroupFile(
|
|
1176
|
+
let media: MediaFile | null = null;
|
|
1177
|
+
if (record.media.downloadCode) {
|
|
1178
|
+
media = await downloadMedia(
|
|
1093
1179
|
dingtalkConfig,
|
|
1094
|
-
record.media.
|
|
1095
|
-
record.media.fileId,
|
|
1096
|
-
unionId,
|
|
1180
|
+
record.media.downloadCode,
|
|
1097
1181
|
log,
|
|
1098
1182
|
originalFilename,
|
|
1099
1183
|
);
|
|
1100
1184
|
if (media) {
|
|
1101
1185
|
log?.debug?.(
|
|
1102
|
-
`[DingTalk][QuotedRef] Recovered quoted media from cached
|
|
1186
|
+
`[DingTalk][QuotedRef] Recovered quoted media from cached downloadCode ` +
|
|
1103
1187
|
`recordMsgId=${record.msgId || "(none)"} scope=${data.conversationId}`,
|
|
1104
1188
|
);
|
|
1105
1189
|
}
|
|
1106
|
-
} catch (err: any) {
|
|
1107
|
-
log?.warn?.(`[DingTalk] spaceId+fileId fallback failed: ${err.message}`);
|
|
1108
1190
|
}
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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
|
+
};
|
|
1112
1214
|
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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";
|
|
1126
1240
|
log?.debug?.(
|
|
1127
|
-
`[DingTalk][QuotedRef] Recovered
|
|
1241
|
+
`[DingTalk][QuotedRef] Recovered ${recovered.length} images from cached multi-image richText ` +
|
|
1242
|
+
`recordMsgId=${quotedRecord.msgId || "(none)"} scope=${data.conversationId}`,
|
|
1128
1243
|
);
|
|
1129
1244
|
}
|
|
1130
|
-
mediaPath = media.path;
|
|
1131
|
-
mediaType = media.mimeType;
|
|
1132
|
-
attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
|
|
1133
|
-
attachmentContextCreatedAt = quotedRecord?.createdAt || data.createAt;
|
|
1134
|
-
attachmentContextMessageType = quotedRecord?.messageType || content.quoted.previewMessageType || "picture";
|
|
1135
|
-
attachmentContextFileName = content.quoted.previewFileName;
|
|
1136
|
-
} else {
|
|
1137
|
-
content.text = `[引用了一张图片,但下载失败]\n\n${content.text}`;
|
|
1138
1245
|
}
|
|
1139
|
-
}
|
|
1140
1246
|
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
content.quoted.previewFileName,
|
|
1153
|
-
);
|
|
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
|
+
));
|
|
1154
1258
|
if (media) {
|
|
1259
|
+
if (!quotedRecord) {
|
|
1260
|
+
log?.debug?.(
|
|
1261
|
+
`[DingTalk][QuotedRef] Recovered quoted image from inbound downloadCode fallback scope=${data.conversationId}`,
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1155
1264
|
mediaPath = media.path;
|
|
1156
1265
|
mediaType = media.mimeType;
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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";
|
|
1160
1272
|
attachmentContextFileName = content.quoted.previewFileName;
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1273
|
+
} else {
|
|
1274
|
+
content.text = `[引用了一张图片,但下载失败]\n\n${content.text}`;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
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
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
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,
|
|
1312
|
+
);
|
|
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;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
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
|
+
}
|
|
1372
|
+
|
|
1373
|
+
if (!fileResolved) {
|
|
1374
|
+
log?.warn?.(
|
|
1375
|
+
`[DingTalk] Quoted file unresolved: conversationType=${data.conversationType} conversationId=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
|
|
1164
1376
|
);
|
|
1377
|
+
const hint = isDirect
|
|
1378
|
+
? "[引用了一个文件,内容无法自动获取,请直接发送该文件]\n\n"
|
|
1379
|
+
: "[引用了一个文件,但无法获取内容]\n\n";
|
|
1380
|
+
content.text = `${hint}${content.text}`;
|
|
1165
1381
|
}
|
|
1166
1382
|
}
|
|
1167
1383
|
|
|
1168
|
-
//
|
|
1169
|
-
|
|
1170
|
-
|
|
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(
|
|
1171
1393
|
quotedRecord,
|
|
1172
|
-
quotedRecord?.attachmentFileName || content.quoted
|
|
1394
|
+
quotedRecord?.attachmentFileName || content.quoted?.previewFileName,
|
|
1173
1395
|
);
|
|
1174
|
-
if (
|
|
1175
|
-
mediaPath =
|
|
1176
|
-
mediaType =
|
|
1396
|
+
if (cachedDocMedia) {
|
|
1397
|
+
mediaPath = cachedDocMedia.path;
|
|
1398
|
+
mediaType = cachedDocMedia.mimeType;
|
|
1399
|
+
mediaPaths.push(cachedDocMedia.path);
|
|
1400
|
+
mediaTypes.push(cachedDocMedia.mimeType);
|
|
1177
1401
|
attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
|
|
1178
|
-
attachmentContextCreatedAt =
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
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;
|
|
1408
|
+
docResolved = true;
|
|
1182
1409
|
}
|
|
1183
|
-
}
|
|
1184
1410
|
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
`
|
|
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)"}`,
|
|
1207
1458
|
);
|
|
1208
|
-
|
|
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) {
|
|
1209
1475
|
upsertInboundMessageContext({
|
|
1210
1476
|
storePath: accountStorePath,
|
|
1211
1477
|
accountId,
|
|
1212
1478
|
conversationId: data.conversationId,
|
|
1213
|
-
msgId:
|
|
1214
|
-
createdAt:
|
|
1215
|
-
messageType:
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
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),
|
|
1222
1487
|
topic: null,
|
|
1223
1488
|
});
|
|
1489
|
+
attachmentExtractedText = `${ATTACHMENT_TEXT_PREFIX}\n${extracted.text}`;
|
|
1224
1490
|
}
|
|
1491
|
+
} catch (err: any) {
|
|
1492
|
+
log?.warn?.(`[DingTalk] Failed to extract attachment text: ${err.message}`);
|
|
1225
1493
|
}
|
|
1226
1494
|
}
|
|
1227
1495
|
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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
|
+
});
|
|
1238
1513
|
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
attachmentContextMessageType =
|
|
1257
|
-
quotedRecord?.messageType || content.quoted.previewMessageType || "interactiveCardFile";
|
|
1258
|
-
attachmentContextFileName = quotedRecord?.attachmentFileName || content.quoted.previewFileName;
|
|
1259
|
-
docResolved = true;
|
|
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);
|
|
1260
1531
|
}
|
|
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
|
+
});
|
|
1261
1545
|
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
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
|
+
});
|
|
1585
|
+
|
|
1586
|
+
await rt.channel.session.recordInboundSession({
|
|
1587
|
+
storePath,
|
|
1588
|
+
sessionKey: ctx.SessionKey || route.sessionKey,
|
|
1589
|
+
ctx,
|
|
1590
|
+
updateLastRoute: (() => {
|
|
1591
|
+
if (!isDirect) {
|
|
1592
|
+
return undefined;
|
|
1300
1593
|
}
|
|
1301
|
-
|
|
1302
|
-
|
|
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
|
+
});
|
|
1303
1615
|
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
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}`,
|
|
1307
1632
|
);
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
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
|
+
);
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
},
|
|
1673
|
+
},
|
|
1337
1674
|
});
|
|
1338
|
-
|
|
1675
|
+
} catch (abortErr) {
|
|
1676
|
+
log?.warn?.(`[DingTalk] Abort dispatch failed: ${getErrorMessage(abortErr)}`);
|
|
1339
1677
|
}
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
: inboundBody;
|
|
1349
|
-
const learningEnabled = isLearningEnabled(dingtalkConfig);
|
|
1350
|
-
const learningContextBlock = buildLearningContextBlock({
|
|
1351
|
-
enabled: learningEnabled,
|
|
1352
|
-
storePath: accountStorePath,
|
|
1353
|
-
accountId,
|
|
1354
|
-
targetId: data.conversationId,
|
|
1355
|
-
content,
|
|
1356
|
-
});
|
|
1357
|
-
const envelopeOptions = rt.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
1358
|
-
const previousTimestamp = rt.channel.session.readSessionUpdatedAt({
|
|
1359
|
-
storePath,
|
|
1360
|
-
sessionKey: route.sessionKey,
|
|
1361
|
-
});
|
|
1362
|
-
|
|
1363
|
-
const groupConfig = !isDirect ? resolveGroupConfig(dingtalkConfig, groupId) : undefined;
|
|
1364
|
-
// GroupSystemPrompt is injected every turn (not only first-turn intro).
|
|
1365
|
-
const groupSystemPromptParts = !isDirect
|
|
1366
|
-
? [
|
|
1367
|
-
buildGroupTurnContextPrompt({
|
|
1368
|
-
conversationId: groupId,
|
|
1369
|
-
senderDingtalkId: senderId,
|
|
1370
|
-
senderName,
|
|
1371
|
-
}),
|
|
1372
|
-
groupConfig?.systemPrompt?.trim(),
|
|
1373
|
-
]
|
|
1374
|
-
: [];
|
|
1375
|
-
const extraSystemPrompt =
|
|
1376
|
-
[...groupSystemPromptParts, learningContextBlock].filter(Boolean).join("\n\n") || undefined;
|
|
1377
|
-
|
|
1378
|
-
if (!isDirect) {
|
|
1379
|
-
noteGroupMember(storePath, groupId, senderId, senderName);
|
|
1380
|
-
}
|
|
1381
|
-
const groupMembers = !isDirect ? formatGroupMembers(storePath, groupId) : undefined;
|
|
1382
|
-
|
|
1383
|
-
const fromLabel = isDirect ? `${senderName} (${senderId})` : `${groupName} - ${senderName}`;
|
|
1384
|
-
const body = rt.channel.reply.formatInboundEnvelope({
|
|
1385
|
-
channel: "DingTalk",
|
|
1386
|
-
from: fromLabel,
|
|
1387
|
-
timestamp: data.createAt,
|
|
1388
|
-
body: inboundText,
|
|
1389
|
-
chatType: isDirect ? "direct" : "group",
|
|
1390
|
-
sender: { name: senderName, id: senderId },
|
|
1391
|
-
previousTimestamp,
|
|
1392
|
-
envelope: envelopeOptions,
|
|
1393
|
-
});
|
|
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);
|
|
1394
1686
|
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
ReplyToSender: quotedRuntimeContext?.replyToSender,
|
|
1404
|
-
ReplyToIsQuote: quotedRuntimeContext?.replyToIsQuote,
|
|
1405
|
-
UntrustedContext: quotedRuntimeContext?.untrustedContext
|
|
1406
|
-
? [quotedRuntimeContext.untrustedContext]
|
|
1407
|
-
: undefined,
|
|
1408
|
-
From: to,
|
|
1409
|
-
To: to,
|
|
1410
|
-
SessionKey: route.sessionKey,
|
|
1411
|
-
AccountId: accountId,
|
|
1412
|
-
ChatType: isDirect ? "direct" : "group",
|
|
1413
|
-
ConversationLabel: fromLabel,
|
|
1414
|
-
GroupSubject: isDirect ? undefined : groupName,
|
|
1415
|
-
SenderName: senderName,
|
|
1416
|
-
SenderId: senderId,
|
|
1417
|
-
Provider: "dingtalk",
|
|
1418
|
-
Surface: "dingtalk",
|
|
1419
|
-
MessageSid: data.msgId,
|
|
1420
|
-
Timestamp: data.createAt,
|
|
1421
|
-
MediaPath: mediaPath,
|
|
1422
|
-
MediaType: mediaType,
|
|
1423
|
-
MediaUrl: mediaPath,
|
|
1424
|
-
GroupMembers: groupMembers,
|
|
1425
|
-
GroupSystemPrompt: extraSystemPrompt,
|
|
1426
|
-
GroupChannel: isDirect ? undefined : route.sessionKey,
|
|
1427
|
-
CommandAuthorized: commandAuthorized,
|
|
1428
|
-
OriginatingChannel: "dingtalk",
|
|
1429
|
-
OriginatingTo: to,
|
|
1430
|
-
});
|
|
1687
|
+
await commitAICardBlocks(
|
|
1688
|
+
currentAICard,
|
|
1689
|
+
{
|
|
1690
|
+
blockListJson,
|
|
1691
|
+
content: abortText,
|
|
1692
|
+
},
|
|
1693
|
+
log,
|
|
1694
|
+
);
|
|
1431
1695
|
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
}
|
|
1440
|
-
const pinnedMainDmOwner = resolvePinnedMainDmOwner({
|
|
1441
|
-
dmScope: cfg.session?.dmScope,
|
|
1442
|
-
allowFrom: dingtalkConfig.allowFrom,
|
|
1443
|
-
});
|
|
1444
|
-
const senderRecipient = (senderOriginalId || senderId || "").trim().toLowerCase();
|
|
1445
|
-
if (
|
|
1446
|
-
pinnedMainDmOwner
|
|
1447
|
-
&& senderRecipient
|
|
1448
|
-
&& pinnedMainDmOwner.trim().toLowerCase() !== senderRecipient
|
|
1449
|
-
) {
|
|
1450
|
-
log?.debug?.(
|
|
1451
|
-
`[DingTalk] Skipping main-session last route update for ${senderRecipient} (pinned owner ${pinnedMainDmOwner})`,
|
|
1452
|
-
);
|
|
1453
|
-
return undefined;
|
|
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
|
+
}
|
|
1454
1703
|
}
|
|
1455
|
-
return
|
|
1456
|
-
}
|
|
1457
|
-
onRecordError: (err: unknown) => {
|
|
1458
|
-
log?.error?.(`[DingTalk] Failed to record inbound session: ${String(err)}`);
|
|
1459
|
-
},
|
|
1460
|
-
});
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1461
1706
|
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
if (currentAICard) {
|
|
1494
|
-
// Card mode: capture text — will be written to card after dispatch.
|
|
1495
|
-
abortConfirmationText = payload.text;
|
|
1496
|
-
} else {
|
|
1497
|
-
try {
|
|
1498
|
-
if (sessionWebhook) {
|
|
1499
|
-
await sendBySession(dingtalkConfig, sessionWebhook, payload.text, {
|
|
1500
|
-
log,
|
|
1501
|
-
accountId,
|
|
1502
|
-
storePath: accountStorePath,
|
|
1503
|
-
});
|
|
1504
|
-
} else {
|
|
1505
|
-
await sendMessage(dingtalkConfig, to, payload.text, {
|
|
1506
|
-
log,
|
|
1507
|
-
accountId,
|
|
1508
|
-
storePath: accountStorePath,
|
|
1509
|
-
conversationId: groupId,
|
|
1510
|
-
});
|
|
1511
|
-
}
|
|
1512
|
-
} catch (deliverErr) {
|
|
1513
|
-
log?.warn?.(
|
|
1514
|
-
`[DingTalk] Abort reply delivery failed: ${getErrorMessage(deliverErr)}`,
|
|
1515
|
-
);
|
|
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,
|
|
1731
|
+
cfg,
|
|
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;
|
|
1516
1738
|
}
|
|
1517
|
-
|
|
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
|
+
},
|
|
1518
1754
|
},
|
|
1519
|
-
}
|
|
1520
|
-
})
|
|
1521
|
-
|
|
1522
|
-
log?.warn?.(`[DingTalk] Abort dispatch failed: ${getErrorMessage(abortErr)}`);
|
|
1523
|
-
}
|
|
1524
|
-
// Finalize the card that was created for this message before the abort check.
|
|
1525
|
-
// Without this, the card stays in PROCESSING ("处理中...") indefinitely.
|
|
1526
|
-
// Use V2 finalize (commitAICardBlocks) for consistent state transition.
|
|
1527
|
-
if (currentAICard && !isCardInTerminalState(currentAICard.state)) {
|
|
1528
|
-
try {
|
|
1529
|
-
const abortText = abortConfirmationText ?? "已停止";
|
|
1530
|
-
const abortBlockList = [{ type: 0, markdown: abortText }];
|
|
1531
|
-
const blockListJson = JSON.stringify(abortBlockList);
|
|
1532
|
-
|
|
1533
|
-
await commitAICardBlocks(currentAICard, {
|
|
1534
|
-
blockListJson,
|
|
1535
|
-
content: abortText,
|
|
1536
|
-
}, log);
|
|
1537
|
-
|
|
1538
|
-
log?.debug?.(`[DingTalk] Abort card finalized via V2 API: card=${currentAICard.cardInstanceId}`);
|
|
1539
|
-
} catch (cardErr) {
|
|
1540
|
-
log?.warn?.(`[DingTalk] Abort card finalize failed: ${getErrorMessage(cardErr)}`);
|
|
1541
|
-
currentAICard.state = AICardStatus.FAILED;
|
|
1755
|
+
});
|
|
1756
|
+
} catch (btwErr) {
|
|
1757
|
+
log?.warn?.(`[DingTalk] BTW dispatch failed: ${getErrorMessage(btwErr)}`);
|
|
1542
1758
|
}
|
|
1759
|
+
return;
|
|
1543
1760
|
}
|
|
1544
|
-
return;
|
|
1545
|
-
}
|
|
1546
1761
|
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
responsePrefix: "",
|
|
1574
|
-
deliver: async (payload) => {
|
|
1575
|
-
if (!payload.text) {
|
|
1576
|
-
log?.debug?.(`[DingTalk] BTW deliver received non-text payload, skipping`);
|
|
1577
|
-
return;
|
|
1578
|
-
}
|
|
1579
|
-
await deliverBtwReply({
|
|
1580
|
-
config: dingtalkConfig,
|
|
1581
|
-
sessionWebhook,
|
|
1582
|
-
conversationId: groupId,
|
|
1583
|
-
to,
|
|
1584
|
-
senderName: btwSenderName,
|
|
1585
|
-
// Use pre-OCR content.text (consistent with isBtwBypass detection)
|
|
1586
|
-
// so the blockquote shows the user's typed body, not body + OCR.
|
|
1587
|
-
rawQuestion: content.text,
|
|
1588
|
-
replyText: payload.text,
|
|
1589
|
-
log,
|
|
1590
|
-
accountId,
|
|
1591
|
-
storePath: accountStorePath,
|
|
1592
|
-
});
|
|
1593
|
-
},
|
|
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,
|
|
1594
1788
|
},
|
|
1595
|
-
|
|
1596
|
-
} catch (btwErr) {
|
|
1597
|
-
log?.warn?.(`[DingTalk] BTW dispatch failed: ${getErrorMessage(btwErr)}`);
|
|
1598
|
-
}
|
|
1599
|
-
return;
|
|
1600
|
-
}
|
|
1601
|
-
|
|
1602
|
-
const ackReaction =
|
|
1603
|
-
typeof dingtalkConfig.ackReaction === "string"
|
|
1604
|
-
? dingtalkConfig.ackReaction.trim()
|
|
1605
|
-
: resolveAckReactionSetting({
|
|
1606
|
-
cfg,
|
|
1607
|
-
accountId,
|
|
1608
|
-
agentId: route.agentId,
|
|
1609
|
-
});
|
|
1610
|
-
const normalizedAckReaction = ackReaction === "off" ? "" : ackReaction;
|
|
1611
|
-
const resolvedAckReaction =
|
|
1612
|
-
normalizedAckReaction === "kaomoji"
|
|
1613
|
-
? classifyAckReactionEmoji(content.text).emoji
|
|
1614
|
-
: normalizedAckReaction === "emoji"
|
|
1615
|
-
? "🤔思考中"
|
|
1616
|
-
: normalizedAckReaction;
|
|
1617
|
-
const shouldAttachAckReaction = Boolean(resolvedAckReaction);
|
|
1618
|
-
let ackReactionAttached = false;
|
|
1619
|
-
let ackReactionAttachedAt = 0;
|
|
1620
|
-
|
|
1621
|
-
if (shouldAttachAckReaction) {
|
|
1622
|
-
ackReactionAttached = await attachNativeAckReaction(
|
|
1623
|
-
dingtalkConfig,
|
|
1624
|
-
{
|
|
1625
|
-
msgId: data.msgId,
|
|
1626
|
-
conversationId: groupId,
|
|
1627
|
-
reactionName: resolvedAckReaction,
|
|
1628
|
-
},
|
|
1629
|
-
log,
|
|
1630
|
-
);
|
|
1631
|
-
if (ackReactionAttached) {
|
|
1632
|
-
ackReactionAttachedAt = Date.now();
|
|
1633
|
-
log?.debug?.(
|
|
1634
|
-
`[DingTalk] Initial ack reaction attached mode=${normalizedAckReaction || "off"} reaction=${resolvedAckReaction}`,
|
|
1789
|
+
log,
|
|
1635
1790
|
);
|
|
1791
|
+
if (ackReactionAttached) {
|
|
1792
|
+
ackReactionAttachedAt = Date.now();
|
|
1793
|
+
log?.debug?.(
|
|
1794
|
+
`[DingTalk] Initial ack reaction attached mode=${normalizedAckReaction || "off"} reaction=${resolvedAckReaction}`,
|
|
1795
|
+
);
|
|
1796
|
+
}
|
|
1636
1797
|
}
|
|
1637
|
-
}
|
|
1638
1798
|
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
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 };
|
|
1806
|
+
}
|
|
1647
1807
|
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
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
|
+
}
|
|
1672
1833
|
}
|
|
1834
|
+
contentLines.push(line);
|
|
1673
1835
|
}
|
|
1674
|
-
contentLines.push(line);
|
|
1675
|
-
}
|
|
1676
1836
|
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
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
|
+
}
|
|
1687
1848
|
}
|
|
1688
|
-
}
|
|
1689
1849
|
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
const trimmed = text.trim();
|
|
1702
|
-
if (!trimmed || trimmed.includes("\n") || /\s/.test(trimmed)) {
|
|
1703
|
-
return undefined;
|
|
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
|
+
};
|
|
1704
1861
|
}
|
|
1705
1862
|
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
trimmed.
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
trimmed.startsWith(".\\") ||
|
|
1712
|
-
trimmed.startsWith("../") ||
|
|
1713
|
-
trimmed.startsWith("..\\") ||
|
|
1714
|
-
trimmed.startsWith("/") ||
|
|
1715
|
-
trimmed.startsWith("\\") ||
|
|
1716
|
-
/^[a-zA-Z]:[\\/]/.test(trimmed) ||
|
|
1717
|
-
trimmed.includes("/") ||
|
|
1718
|
-
trimmed.includes("\\");
|
|
1719
|
-
if (!hasPathLikeShape) {
|
|
1720
|
-
return undefined;
|
|
1721
|
-
}
|
|
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
|
+
}
|
|
1722
1868
|
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
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
|
+
}
|
|
1727
1885
|
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
inlineReplyPayload?: ReturnType<typeof parseInlineReplyPayloadText>,
|
|
1732
|
-
): boolean {
|
|
1733
|
-
// Normalize all reply-time voice hints into the shared `audioAsVoice`
|
|
1734
|
-
// semantic that strategies and media delivery use downstream.
|
|
1735
|
-
const richPayload = payload as ReplyStreamPayload & {
|
|
1736
|
-
audioAsVoice?: unknown;
|
|
1737
|
-
asVoice?: unknown;
|
|
1738
|
-
};
|
|
1739
|
-
const sharedValue = parseBooleanLike(richPayload.audioAsVoice);
|
|
1740
|
-
if (sharedValue !== undefined) {
|
|
1741
|
-
return sharedValue;
|
|
1886
|
+
return STANDALONE_MEDIA_PATH_EXTENSIONS.has(path.extname(trimmed).toLowerCase())
|
|
1887
|
+
? trimmed
|
|
1888
|
+
: undefined;
|
|
1742
1889
|
}
|
|
1743
|
-
|
|
1744
|
-
|
|
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;
|
|
1745
1910
|
}
|
|
1746
|
-
return inlineReplyPayload?.audioAsVoice === true;
|
|
1747
|
-
}
|
|
1748
1911
|
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
mediaPath: actualMediaPath,
|
|
1762
|
-
asVoice: options?.audioAsVoice === true,
|
|
1763
|
-
});
|
|
1764
|
-
if (sessionWebhook) {
|
|
1765
|
-
const sendResult = await sendMessage(dingtalkConfig, to, "", {
|
|
1766
|
-
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({
|
|
1767
1924
|
mediaPath: actualMediaPath,
|
|
1768
|
-
|
|
1769
|
-
log,
|
|
1770
|
-
accountId,
|
|
1771
|
-
storePath: accountStorePath,
|
|
1772
|
-
conversationId: groupId,
|
|
1773
|
-
quotedRef: replyQuotedRef,
|
|
1925
|
+
asVoice: options?.audioAsVoice === true,
|
|
1774
1926
|
});
|
|
1775
|
-
if (
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
dingtalkConfig,
|
|
1781
|
-
to,
|
|
1782
|
-
actualMediaPath,
|
|
1783
|
-
outMediaType,
|
|
1784
|
-
{
|
|
1785
|
-
accountId,
|
|
1927
|
+
if (sessionWebhook) {
|
|
1928
|
+
const sendResult = await sendMessage(dingtalkConfig, to, "", {
|
|
1929
|
+
sessionWebhook,
|
|
1930
|
+
mediaPath: actualMediaPath,
|
|
1931
|
+
mediaType: outMediaType,
|
|
1786
1932
|
log,
|
|
1933
|
+
accountId,
|
|
1787
1934
|
storePath: accountStorePath,
|
|
1788
1935
|
conversationId: groupId,
|
|
1789
1936
|
quotedRef: replyQuotedRef,
|
|
1790
|
-
}
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
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
|
+
}
|
|
1794
1958
|
}
|
|
1959
|
+
} finally {
|
|
1960
|
+
await preparedMedia.cleanup?.();
|
|
1795
1961
|
}
|
|
1796
|
-
} finally {
|
|
1797
|
-
await preparedMedia.cleanup?.();
|
|
1798
1962
|
}
|
|
1799
1963
|
}
|
|
1800
|
-
}
|
|
1801
1964
|
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
// Serialize dispatchReply + card finalize per session to prevent the runtime
|
|
1824
|
-
// from receiving concurrent dispatch calls on the same session key, which
|
|
1825
|
-
// causes empty replies for all but the first caller.
|
|
1826
|
-
// Each sub-agent call acquires its own lock since sub-agent sessions have
|
|
1827
|
-
// different session keys (different agentId), so no deadlock risk.
|
|
1828
|
-
const currentOutTrackId = currentAICard?.outTrackId;
|
|
1829
|
-
const shouldTrackDynamicAckReaction =
|
|
1830
|
-
(normalizedAckReaction === "emoji" || normalizedAckReaction === "kaomoji")
|
|
1831
|
-
&& shouldAttachAckReaction;
|
|
1832
|
-
const runtimeEvents = (rt as typeof rt & {
|
|
1833
|
-
events?: {
|
|
1834
|
-
onAgentEvent?: (listener: (event: unknown) => void) => (() => void);
|
|
1835
|
-
};
|
|
1836
|
-
}).events;
|
|
1837
|
-
const releaseSessionLock = await acquireSessionLock(route.sessionKey);
|
|
1838
|
-
const dynamicAckReactionController = createDynamicAckReactionController({
|
|
1839
|
-
enabled: shouldTrackDynamicAckReaction,
|
|
1840
|
-
initialReaction: resolvedAckReaction || "",
|
|
1841
|
-
initialAttached: ackReactionAttached,
|
|
1842
|
-
initialAttachedAt: ackReactionAttachedAt,
|
|
1843
|
-
dingtalkConfig,
|
|
1844
|
-
msgId: data.msgId,
|
|
1845
|
-
conversationId: groupId,
|
|
1846
|
-
sessionKey: route.sessionKey,
|
|
1847
|
-
log,
|
|
1848
|
-
runtimeEvents,
|
|
1849
|
-
onReactionDisposed: () => {
|
|
1850
|
-
ackReactionAttached = false;
|
|
1851
|
-
},
|
|
1852
|
-
});
|
|
1853
|
-
try {
|
|
1854
|
-
if (!ackReactionAttached && shouldAttachAckReaction) {
|
|
1855
|
-
log?.debug?.("[DingTalk] Native ack reaction unavailable; skipping fallback.");
|
|
1856
|
-
}
|
|
1857
|
-
const isCurrentCardStopRequested = () =>
|
|
1858
|
-
Boolean(
|
|
1859
|
-
currentAICard
|
|
1860
|
-
&& (
|
|
1861
|
-
currentAICard.state === AICardStatus.STOPPED
|
|
1862
|
-
|| (currentOutTrackId && isCardRunStopRequested(currentOutTrackId))
|
|
1863
|
-
),
|
|
1864
|
-
);
|
|
1865
|
-
|
|
1866
|
-
if (isCurrentCardStopRequested()) {
|
|
1867
|
-
log?.info?.("[DingTalk][CardStop] Skip dispatch because card was already stopped before session lock was acquired");
|
|
1868
|
-
return;
|
|
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 ?? []);
|
|
1869
1986
|
}
|
|
1870
1987
|
|
|
1871
|
-
//
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
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,
|
|
1875
2013
|
sessionKey: route.sessionKey,
|
|
1876
|
-
sessionUpdatedAt: previousTimestamp,
|
|
1877
2014
|
log,
|
|
2015
|
+
runtimeEvents,
|
|
2016
|
+
onReactionDisposed: () => {
|
|
2017
|
+
ackReactionAttached = false;
|
|
2018
|
+
},
|
|
1878
2019
|
});
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
model: sessionTaskState?.model,
|
|
1890
|
-
effort: sessionTaskState?.effort,
|
|
1891
|
-
elapsedMs: typeof sessionTaskState?.taskStartTime === "number"
|
|
1892
|
-
? Math.max(0, Date.now() - sessionTaskState.taskStartTime)
|
|
1893
|
-
: undefined,
|
|
1894
|
-
agent: getAgentDisplayName({
|
|
1895
|
-
subAgentOptions,
|
|
1896
|
-
agentId: route.agentId,
|
|
1897
|
-
agentsList: cfg.agents?.list,
|
|
1898
|
-
}),
|
|
1899
|
-
};
|
|
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
|
+
);
|
|
1900
2030
|
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
sessionAgentId: route.agentId,
|
|
1913
|
-
disableBlockStreaming: shouldDisableBlockStreamingForReplyMode({
|
|
1914
|
-
replyMode,
|
|
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,
|
|
1915
2042
|
sessionKey: route.sessionKey,
|
|
1916
|
-
|
|
2043
|
+
sessionUpdatedAt: previousTimestamp,
|
|
1917
2044
|
log,
|
|
1918
|
-
})
|
|
1919
|
-
|
|
1920
|
-
log,
|
|
1921
|
-
replyQuotedRef,
|
|
1922
|
-
deliverMedia: deliverMediaAttachments,
|
|
1923
|
-
isStopRequested: isCurrentCardStopRequested,
|
|
1924
|
-
inboundText: rawInboundText,
|
|
1925
|
-
taskMeta,
|
|
1926
|
-
});
|
|
1927
|
-
|
|
1928
|
-
try {
|
|
1929
|
-
let deliveredFinalCount = 0;
|
|
1930
|
-
const dispatchResult = await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1931
|
-
ctx,
|
|
2045
|
+
});
|
|
2046
|
+
const legacyCardStreamReasoning = resolveLegacyCardStreamReasoningForInternalUse({
|
|
1932
2047
|
cfg,
|
|
1933
|
-
|
|
1934
|
-
responsePrefix: subAgentOptions?.responsePrefix || "",
|
|
1935
|
-
deliver: async (payload: ReplyStreamPayload, info?: ReplyChunkInfo) => {
|
|
1936
|
-
if (isCurrentCardStopRequested()) {
|
|
1937
|
-
log?.debug?.("[DingTalk][CardStop] Ignoring reply delivery because stop was already requested");
|
|
1938
|
-
return;
|
|
1939
|
-
}
|
|
1940
|
-
try {
|
|
1941
|
-
if (info?.kind === "final") {
|
|
1942
|
-
deliveredFinalCount += 1;
|
|
1943
|
-
}
|
|
1944
|
-
const inlineReplyPayload = parseInlineReplyPayloadText(payload.text);
|
|
1945
|
-
const mediaUrls = extractMediaUrls(payload, inlineReplyPayload);
|
|
1946
|
-
const richPayload = payload as ReplyStreamPayload & { isReasoning?: boolean };
|
|
1947
|
-
await strategy.deliver({
|
|
1948
|
-
text: inlineReplyPayload.text,
|
|
1949
|
-
mediaUrls,
|
|
1950
|
-
audioAsVoice: extractSharedAudioAsVoice(payload, inlineReplyPayload),
|
|
1951
|
-
kind: (info?.kind as DeliverPayload["kind"]) || "block",
|
|
1952
|
-
isReasoning: richPayload.isReasoning === true,
|
|
1953
|
-
});
|
|
1954
|
-
} catch (err: unknown) {
|
|
1955
|
-
log?.error?.(`[DingTalk] Reply failed: ${getErrorMessage(err)}`);
|
|
1956
|
-
const responseData = getErrorResponseData(err);
|
|
1957
|
-
if (responseData !== undefined) {
|
|
1958
|
-
log?.error?.(formatDingTalkErrorPayloadLog("inbound.replyDeliver", responseData));
|
|
1959
|
-
}
|
|
1960
|
-
throw err;
|
|
1961
|
-
}
|
|
1962
|
-
},
|
|
1963
|
-
},
|
|
1964
|
-
replyOptions: strategy.getReplyOptions(),
|
|
2048
|
+
accountId,
|
|
1965
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
|
+
};
|
|
1966
2068
|
|
|
1967
|
-
const
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
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
|
+
});
|
|
1975
2095
|
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
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;
|
|
2109
|
+
}
|
|
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;
|
|
2131
|
+
}
|
|
2132
|
+
},
|
|
2133
|
+
},
|
|
2134
|
+
replyOptions: strategy.getReplyOptions(),
|
|
2135
|
+
});
|
|
1981
2136
|
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
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
|
|
1987
2144
|
: undefined;
|
|
1988
2145
|
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2146
|
+
log?.info?.(
|
|
2147
|
+
`[DingTalk][Dispatch] completed — deliveredFinalCount=${deliveredFinalCount} ` +
|
|
2148
|
+
`counts.final=${typeof finalCount === "number" ? finalCount : "n/a"} ` +
|
|
2149
|
+
`queuedFinalType=${typeof bufferedFinal}`,
|
|
2150
|
+
);
|
|
2151
|
+
|
|
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
|
+
}
|
|
2001
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;
|
|
2002
2180
|
}
|
|
2003
|
-
} catch (dispatchErr: unknown) {
|
|
2004
|
-
const error = dispatchErr instanceof Error ? dispatchErr : new Error(getErrorMessage(dispatchErr));
|
|
2005
|
-
await strategy.abort(error);
|
|
2006
|
-
throw dispatchErr;
|
|
2007
|
-
}
|
|
2008
2181
|
|
|
2009
|
-
|
|
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);
|
|
2190
|
+
}
|
|
2191
|
+
await waitForDynamicAckDispose({
|
|
2192
|
+
dispose: () => dynamicAckReactionController.dispose(MIN_THINKING_REACTION_VISIBLE_MS),
|
|
2193
|
+
log,
|
|
2194
|
+
sessionKey: route.sessionKey,
|
|
2195
|
+
});
|
|
2196
|
+
releaseSessionLock();
|
|
2197
|
+
}
|
|
2010
2198
|
} finally {
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
// (finalize card, hide button, gateway abort) that read the record.
|
|
2014
|
-
// In that case, let the 30-minute TTL sweep handle cleanup.
|
|
2015
|
-
if (currentOutTrackId && !isCardRunStopRequested(currentOutTrackId)) {
|
|
2016
|
-
removeCardRun(currentOutTrackId);
|
|
2199
|
+
if (cardFlightKey) {
|
|
2200
|
+
cardCreationInFlight.delete(cardFlightKey);
|
|
2017
2201
|
}
|
|
2018
|
-
await waitForDynamicAckDispose({
|
|
2019
|
-
dispose: () => dynamicAckReactionController.dispose(MIN_THINKING_REACTION_VISIBLE_MS),
|
|
2020
|
-
log,
|
|
2021
|
-
sessionKey: route.sessionKey,
|
|
2022
|
-
});
|
|
2023
|
-
releaseSessionLock();
|
|
2024
2202
|
}
|
|
2025
2203
|
}
|