@soimy/dingtalk 3.5.2 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -23
- package/index.ts +7 -0
- package/openclaw.plugin.json +799 -0
- package/package.json +5 -5
- package/src/card/card-markdown-image-reroute.ts +106 -0
- package/src/card/card-run-registry.ts +54 -1
- package/src/card/card-stop-handler.ts +10 -20
- package/src/card/card-streaming-mode.ts +30 -0
- package/src/card/card-template.ts +14 -3
- package/src/card/reasoning-answer-split.ts +162 -0
- package/src/card/statusline-renderer.ts +94 -0
- package/src/card-draft-controller.ts +326 -54
- package/src/card-service.ts +479 -8
- package/src/channel.ts +19 -1062
- package/src/config-schema.ts +81 -38
- package/src/config.ts +142 -4
- package/src/device-registration.ts +245 -0
- package/src/gateway/channel-gateway.ts +636 -0
- package/src/inbound-handler.ts +489 -49
- package/src/media-utils.ts +169 -7
- package/src/message-utils.ts +153 -17
- package/src/messaging/btw-deliver.ts +85 -0
- package/src/messaging/channel-actions.ts +173 -0
- package/src/messaging/channel-outbound.ts +158 -0
- package/src/messaging/quoted-file-service.ts +9 -4
- package/src/onboarding.ts +323 -205
- package/src/platform/channel-status.ts +81 -0
- package/src/plugin-sdk-channel-actions-augment.ts +11 -0
- package/src/reply-strategy-card.ts +568 -44
- package/src/reply-strategy-markdown.ts +2 -2
- package/src/reply-strategy-types.ts +93 -0
- package/src/reply-strategy-with-reaction.ts +1 -1
- package/src/reply-strategy.ts +14 -56
- package/src/run-usage-store.ts +59 -0
- package/src/send-service.ts +225 -7
- package/src/session-state.ts +62 -0
- package/src/targeting/agent-name-matcher.ts +28 -0
- package/src/targeting/agent-routing.ts +44 -28
- package/src/types.ts +49 -117
- package/src/utils.ts +25 -0
package/src/inbound-handler.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { isAbortRequestText, isBtwRequestText } from "openclaw/plugin-sdk/reply-runtime";
|
|
2
4
|
import axios from "./http-client";
|
|
3
5
|
import { normalizeAllowFrom, isSenderAllowed, resolveGroupAccess } from "./access-control";
|
|
4
6
|
import { buildAgentSessionKey, resolveSubAgentRoute, dispatchSubAgents } from "./targeting/agent-routing";
|
|
7
|
+
import { getAgentDisplayName } from "./targeting/agent-name-matcher";
|
|
5
8
|
import { classifyAckReactionEmoji } from "./ack-reaction-classifier";
|
|
6
9
|
import { attachNativeAckReaction } from "./ack-reaction-service";
|
|
7
10
|
import { createDynamicAckReactionController } from "./ack-reaction/dynamic-ack-reaction-controller";
|
|
8
11
|
import { extractAttachmentText } from "./messaging/attachment-text-extractor";
|
|
9
12
|
import { getAccessToken } from "./auth";
|
|
10
|
-
import { createAICard,
|
|
13
|
+
import { createAICard, commitAICardBlocks, isCardInTerminalState } from "./card-service";
|
|
14
|
+
import { renderStatusLine } from "./card/statusline-renderer";
|
|
11
15
|
import { handleInboundCommandDispatch } from "./command/inbound-command-dispatch-service";
|
|
12
|
-
import { resolveAckReactionSetting, resolveGroupConfig, resolveRobotCode } from "./config";
|
|
16
|
+
import { resolveAckReactionSetting, resolveGroupConfig, resolveRelativePath, resolveRobotCode } from "./config";
|
|
13
17
|
import { AICardStatus } from "./types";
|
|
14
18
|
import {
|
|
15
19
|
isCardRunStopRequested,
|
|
@@ -29,6 +33,7 @@ import {
|
|
|
29
33
|
upsertInboundMessageContext,
|
|
30
34
|
} from "./message-context-store";
|
|
31
35
|
import { extractMessageContent } from "./message-utils";
|
|
36
|
+
import { deliverBtwReply, stripLeadingMentions } from "./messaging/btw-deliver";
|
|
32
37
|
import { resolveQuotedRuntimeContext } from "./messaging/quoted-context";
|
|
33
38
|
import {
|
|
34
39
|
buildInboundQuotedRef,
|
|
@@ -42,7 +47,7 @@ import {
|
|
|
42
47
|
} from "./proactive-risk-registry";
|
|
43
48
|
import { downloadGroupFile, getUnionIdByStaffId, resolveQuotedFile } from "./messaging/quoted-file-service";
|
|
44
49
|
import { createReplyStrategy } from "./reply-strategy";
|
|
45
|
-
import type { DeliverPayload } from "./reply-strategy";
|
|
50
|
+
import type { DeliverPayload } from "./reply-strategy-types";
|
|
46
51
|
import { getDingTalkRuntime } from "./runtime";
|
|
47
52
|
import { sendBySession, sendMessage, sendProactiveMedia } from "./send-service";
|
|
48
53
|
import { acquireSessionLock } from "./session-lock";
|
|
@@ -52,18 +57,43 @@ import {
|
|
|
52
57
|
setSessionPeerOverride,
|
|
53
58
|
} from "./session-peer-store";
|
|
54
59
|
import { resolveDingTalkSessionPeer } from "./session-routing";
|
|
60
|
+
import { getSessionState, initSessionState } from "./session-state";
|
|
55
61
|
import {
|
|
56
62
|
upsertObservedGroupTarget,
|
|
57
63
|
upsertObservedUserTarget,
|
|
58
64
|
} from "./targeting/target-directory-store";
|
|
59
65
|
import type { DingTalkConfig, HandleDingTalkMessageParams, Logger, MediaFile } from "./types";
|
|
60
|
-
import { formatDingTalkErrorPayloadLog, getErrorMessage, getErrorResponseData, maskSensitiveData } from "./utils";
|
|
61
|
-
import {
|
|
66
|
+
import { formatDingTalkErrorPayloadLog, getErrorMessage, getErrorResponseData, maskSensitiveData, parseBooleanLike } from "./utils";
|
|
67
|
+
import { parseInlineDirectives } from "openclaw/plugin-sdk/text-runtime";
|
|
62
68
|
|
|
63
69
|
const DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS = 24;
|
|
64
70
|
const MIN_THINKING_REACTION_VISIBLE_MS = 1200;
|
|
65
71
|
const MAX_DYNAMIC_ACK_DISPOSE_WAIT_MS = 500;
|
|
66
72
|
const ATTACHMENT_TEXT_PREFIX = "[附件内容摘录]";
|
|
73
|
+
const MEDIA_DIRECTIVE_PREFIX = "MEDIA:";
|
|
74
|
+
const STANDALONE_MEDIA_PATH_EXTENSIONS = new Set([
|
|
75
|
+
".jpg",
|
|
76
|
+
".jpeg",
|
|
77
|
+
".png",
|
|
78
|
+
".gif",
|
|
79
|
+
".bmp",
|
|
80
|
+
".ogg",
|
|
81
|
+
".amr",
|
|
82
|
+
".mp3",
|
|
83
|
+
".wav",
|
|
84
|
+
".mp4",
|
|
85
|
+
".avi",
|
|
86
|
+
".mov",
|
|
87
|
+
".doc",
|
|
88
|
+
".docx",
|
|
89
|
+
".xls",
|
|
90
|
+
".xlsx",
|
|
91
|
+
".ppt",
|
|
92
|
+
".pptx",
|
|
93
|
+
".zip",
|
|
94
|
+
".pdf",
|
|
95
|
+
".rar",
|
|
96
|
+
]);
|
|
67
97
|
const proactiveHintLastSentAt = new Map<string, number>();
|
|
68
98
|
const sessionReasoningLevelCache = new Map<string, {
|
|
69
99
|
updatedAt?: number;
|
|
@@ -71,6 +101,71 @@ const sessionReasoningLevelCache = new Map<string, {
|
|
|
71
101
|
}>();
|
|
72
102
|
type ReplyMode = "card" | "markdown";
|
|
73
103
|
|
|
104
|
+
function resolveQuotedContextAllowFrom(
|
|
105
|
+
config: DingTalkConfig,
|
|
106
|
+
groupId: string,
|
|
107
|
+
): string[] | undefined {
|
|
108
|
+
const groupConfig = resolveGroupConfig(config, groupId);
|
|
109
|
+
return groupConfig?.groupAllowFrom ?? config.groupAllowFrom;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resolveQuotedVisibilitySenderId(params: {
|
|
113
|
+
quotedSenderId?: string;
|
|
114
|
+
currentSenderId: string;
|
|
115
|
+
currentSenderOriginalId: string;
|
|
116
|
+
}): string | undefined {
|
|
117
|
+
const quotedSenderId = (params.quotedSenderId || "").trim();
|
|
118
|
+
if (!quotedSenderId) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
if (quotedSenderId === params.currentSenderOriginalId) {
|
|
122
|
+
return params.currentSenderId;
|
|
123
|
+
}
|
|
124
|
+
return quotedSenderId;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function filterQuotedRuntimeContext(params: {
|
|
128
|
+
context: ReturnType<typeof resolveQuotedRuntimeContext>;
|
|
129
|
+
config: DingTalkConfig;
|
|
130
|
+
isDirect: boolean;
|
|
131
|
+
groupId: string;
|
|
132
|
+
quotedSenderId?: string;
|
|
133
|
+
currentSenderId: string;
|
|
134
|
+
currentSenderOriginalId: string;
|
|
135
|
+
}): ReturnType<typeof resolveQuotedRuntimeContext> {
|
|
136
|
+
const { context, config, isDirect, groupId, quotedSenderId, currentSenderId, currentSenderOriginalId } = params;
|
|
137
|
+
if (!context || isDirect) {
|
|
138
|
+
return context;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const mode = config.contextVisibility || "all";
|
|
142
|
+
if (mode === "all") {
|
|
143
|
+
return context;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const allow = normalizeAllowFrom(resolveQuotedContextAllowFrom(config, groupId));
|
|
147
|
+
const senderId = resolveQuotedVisibilitySenderId({
|
|
148
|
+
quotedSenderId,
|
|
149
|
+
currentSenderId,
|
|
150
|
+
currentSenderOriginalId,
|
|
151
|
+
});
|
|
152
|
+
const senderAllowed =
|
|
153
|
+
allow.hasEntries && !!senderId
|
|
154
|
+
? isSenderAllowed({ allow, senderId })
|
|
155
|
+
: false;
|
|
156
|
+
|
|
157
|
+
if (senderAllowed) {
|
|
158
|
+
return context;
|
|
159
|
+
}
|
|
160
|
+
if (mode === "allowlist_quote") {
|
|
161
|
+
return {
|
|
162
|
+
...context,
|
|
163
|
+
untrustedContext: undefined,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
74
169
|
function readSessionReasoningLevel(params: {
|
|
75
170
|
storePath?: string;
|
|
76
171
|
sessionKey: string;
|
|
@@ -132,6 +227,29 @@ function shouldDisableBlockStreamingForReplyMode(params: {
|
|
|
132
227
|
return shouldDisable;
|
|
133
228
|
}
|
|
134
229
|
|
|
230
|
+
function resolveLegacyCardStreamReasoningForInternalUse(params: {
|
|
231
|
+
cfg: HandleDingTalkMessageParams["cfg"];
|
|
232
|
+
accountId: string;
|
|
233
|
+
}): boolean | undefined {
|
|
234
|
+
const dingtalk = (params.cfg?.channels?.dingtalk ?? null) as
|
|
235
|
+
| (Record<string, unknown> & {
|
|
236
|
+
cardStreamReasoning?: unknown;
|
|
237
|
+
accounts?: Record<string, Record<string, unknown> | undefined>;
|
|
238
|
+
})
|
|
239
|
+
| null;
|
|
240
|
+
if (!dingtalk) {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
const accountConfig = params.accountId ? dingtalk.accounts?.[params.accountId] : undefined;
|
|
244
|
+
if (typeof accountConfig?.cardStreamReasoning === "boolean") {
|
|
245
|
+
return accountConfig.cardStreamReasoning;
|
|
246
|
+
}
|
|
247
|
+
if (typeof dingtalk.cardStreamReasoning === "boolean") {
|
|
248
|
+
return dingtalk.cardStreamReasoning;
|
|
249
|
+
}
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
135
253
|
function resolvePinnedMainDmOwner(params: {
|
|
136
254
|
dmScope?: string;
|
|
137
255
|
allowFrom?: string[];
|
|
@@ -273,6 +391,8 @@ function buildGroupTurnContextPrompt(params: {
|
|
|
273
391
|
type ReplyStreamPayload = {
|
|
274
392
|
text?: string;
|
|
275
393
|
isReasoning?: boolean;
|
|
394
|
+
mediaUrl?: string;
|
|
395
|
+
mediaUrls?: string[];
|
|
276
396
|
};
|
|
277
397
|
|
|
278
398
|
type ReplyChunkInfo = {
|
|
@@ -290,6 +410,7 @@ export async function downloadMedia(
|
|
|
290
410
|
config: DingTalkConfig,
|
|
291
411
|
downloadCode: string,
|
|
292
412
|
log?: any,
|
|
413
|
+
originalFilename?: string,
|
|
293
414
|
): Promise<MediaFile | null> {
|
|
294
415
|
const rt = getDingTalkRuntime();
|
|
295
416
|
let downloadUrl: string | undefined;
|
|
@@ -363,9 +484,13 @@ export async function downloadMedia(
|
|
|
363
484
|
|
|
364
485
|
const maxBytes =
|
|
365
486
|
config.mediaMaxMb && config.mediaMaxMb > 0 ? config.mediaMaxMb * 1024 * 1024 : undefined;
|
|
366
|
-
const saved =
|
|
367
|
-
|
|
368
|
-
|
|
487
|
+
const saved = await rt.channel.media.saveMediaBuffer(
|
|
488
|
+
buffer,
|
|
489
|
+
contentType,
|
|
490
|
+
"inbound",
|
|
491
|
+
maxBytes,
|
|
492
|
+
originalFilename,
|
|
493
|
+
);
|
|
369
494
|
log?.debug?.(`[DingTalk] Media saved: ${saved.path}`);
|
|
370
495
|
return { path: saved.path, mimeType: saved.contentType ?? contentType };
|
|
371
496
|
} catch (err: any) {
|
|
@@ -415,6 +540,10 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
415
540
|
return;
|
|
416
541
|
}
|
|
417
542
|
|
|
543
|
+
// Preserve raw inbound text before any rewriting (e.g., sub-agent context hint)
|
|
544
|
+
// for use in card quoteContent which should show the user's original message.
|
|
545
|
+
const rawInboundText = extractedContent.text.trim();
|
|
546
|
+
|
|
418
547
|
// Add context hint for sub-agent mode, stripping quoted prefix to avoid protocol noise in agent context.
|
|
419
548
|
if (subAgentOptions) {
|
|
420
549
|
const cleanText = extractedContent.text.replace(/^\[引用[^\]]*\]\s*/, "");
|
|
@@ -689,21 +818,51 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
689
818
|
if (commandHandled) {
|
|
690
819
|
return;
|
|
691
820
|
}
|
|
821
|
+
|
|
822
|
+
const journalTTLDays = dingtalkConfig.journalTTLDays ?? DEFAULT_MESSAGE_CONTEXT_TTL_DAYS;
|
|
823
|
+
const quotedRef = buildInboundQuotedRef(data, extractedContent);
|
|
824
|
+
const replyQuotedRef = createReplyQuotedRef(data.msgId);
|
|
825
|
+
const content = extractedContent;
|
|
826
|
+
const isBtwBypass = isBtwRequestText(stripLeadingMentions(content.text).trim());
|
|
827
|
+
const taskInfoConversationId = groupId || to;
|
|
828
|
+
const sessionTaskState = initSessionState(accountId, taskInfoConversationId);
|
|
829
|
+
const initialStatusLine = renderStatusLine({
|
|
830
|
+
model: sessionTaskState.model,
|
|
831
|
+
effort: sessionTaskState.effort,
|
|
832
|
+
agent: getAgentDisplayName({
|
|
833
|
+
subAgentOptions,
|
|
834
|
+
agentId: route.agentId,
|
|
835
|
+
agentsList: cfg.agents?.list,
|
|
836
|
+
}),
|
|
837
|
+
}, dingtalkConfig) || undefined;
|
|
838
|
+
|
|
692
839
|
// 3) Select response mode (card vs markdown).
|
|
693
840
|
// Card creation runs BEFORE media download so the user sees immediate visual
|
|
694
841
|
// feedback while large files are still being downloaded.
|
|
842
|
+
// /btw is gated out here (`!isBtwBypass`) because it must never create a card —
|
|
843
|
+
// it has its own bypass dispatch later that returns before reaching the main
|
|
844
|
+
// run. Abort (/stop) is intentionally NOT gated: in card mode the existing
|
|
845
|
+
// abort branch finalizes the card with the abort confirmation text instead of
|
|
846
|
+
// sending a separate plain-text message.
|
|
695
847
|
let useCardMode = dingtalkConfig.messageType === "card";
|
|
696
848
|
let currentAICard: import("./types").AICardInstance | undefined;
|
|
697
849
|
|
|
698
|
-
if (useCardMode) {
|
|
850
|
+
if (useCardMode && !isBtwBypass) {
|
|
699
851
|
try {
|
|
700
852
|
log?.debug?.(
|
|
701
853
|
`[DingTalk][AICard] conversationType=${data.conversationType}, conversationId=${to}`,
|
|
702
854
|
);
|
|
855
|
+
// quoteContent always shows the inbound message text so the user can
|
|
856
|
+
// identify which of their messages this card is replying to.
|
|
857
|
+
// Use rawInboundText ( preserved before sub-agent rewriting) to avoid
|
|
858
|
+
// showing internal routing context like "[你被 @ 为...]" in the card UI.
|
|
859
|
+
const inboundQuoteText = rawInboundText.slice(0, 200);
|
|
703
860
|
const aiCard = await createAICard(dingtalkConfig, to, log, {
|
|
704
861
|
accountId,
|
|
705
862
|
storePath: accountStorePath,
|
|
706
863
|
contextConversationId: groupId,
|
|
864
|
+
quoteContent: inboundQuoteText,
|
|
865
|
+
statusLine: initialStatusLine,
|
|
707
866
|
});
|
|
708
867
|
if (aiCard) {
|
|
709
868
|
currentAICard = aiCard;
|
|
@@ -729,11 +888,6 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
729
888
|
);
|
|
730
889
|
}
|
|
731
890
|
}
|
|
732
|
-
|
|
733
|
-
const journalTTLDays = dingtalkConfig.journalTTLDays ?? DEFAULT_MESSAGE_CONTEXT_TTL_DAYS;
|
|
734
|
-
const quotedRef = buildInboundQuotedRef(data, extractedContent);
|
|
735
|
-
const replyQuotedRef = createReplyQuotedRef(data.msgId);
|
|
736
|
-
const content = extractedContent;
|
|
737
891
|
const hasLegacyQuoteContent =
|
|
738
892
|
typeof data.content?.quoteContent === "string" && data.content.quoteContent.trim().length > 0;
|
|
739
893
|
|
|
@@ -771,6 +925,8 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
771
925
|
messageType: content.messageType,
|
|
772
926
|
text: content.text,
|
|
773
927
|
quotedRef,
|
|
928
|
+
senderId,
|
|
929
|
+
senderName,
|
|
774
930
|
createdAt: data.createAt,
|
|
775
931
|
ttlMs: ttlDaysToMs(journalTTLDays),
|
|
776
932
|
ttlReferenceMs: data.createAt,
|
|
@@ -795,7 +951,12 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
795
951
|
mediaType = preDownloadedMedia.mediaType;
|
|
796
952
|
} else if (content.mediaPath && robotCode) {
|
|
797
953
|
// Download media only if not pre-downloaded
|
|
798
|
-
const media = await downloadMedia(
|
|
954
|
+
const media = await downloadMedia(
|
|
955
|
+
dingtalkConfig,
|
|
956
|
+
content.mediaPath,
|
|
957
|
+
log,
|
|
958
|
+
attachmentContextFileName,
|
|
959
|
+
);
|
|
799
960
|
if (media) {
|
|
800
961
|
mediaPath = media.path;
|
|
801
962
|
mediaType = media.mimeType;
|
|
@@ -816,6 +977,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
816
977
|
spaceId: data.content?.spaceId,
|
|
817
978
|
fileId: data.content?.fileId,
|
|
818
979
|
},
|
|
980
|
+
attachmentFileName: attachmentContextFileName,
|
|
819
981
|
ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
|
|
820
982
|
topic: null,
|
|
821
983
|
});
|
|
@@ -840,6 +1002,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
840
1002
|
spaceId: content.docSpaceId,
|
|
841
1003
|
fileId: content.docFileId,
|
|
842
1004
|
},
|
|
1005
|
+
attachmentFileName: attachmentContextFileName,
|
|
843
1006
|
ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
|
|
844
1007
|
topic: null,
|
|
845
1008
|
});
|
|
@@ -853,6 +1016,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
853
1016
|
content.docFileId,
|
|
854
1017
|
unionId,
|
|
855
1018
|
log,
|
|
1019
|
+
attachmentContextFileName,
|
|
856
1020
|
);
|
|
857
1021
|
if (docMedia) {
|
|
858
1022
|
mediaPath = docMedia.path;
|
|
@@ -871,22 +1035,30 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
871
1035
|
quotedRef,
|
|
872
1036
|
log,
|
|
873
1037
|
});
|
|
874
|
-
const quotedRuntimeContext =
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
1038
|
+
const quotedRuntimeContext = filterQuotedRuntimeContext({
|
|
1039
|
+
context: resolveQuotedRuntimeContext({
|
|
1040
|
+
storePath: accountStorePath,
|
|
1041
|
+
accountId,
|
|
1042
|
+
conversationId: data.conversationId,
|
|
1043
|
+
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
|
+
log,
|
|
1055
|
+
}),
|
|
1056
|
+
config: dingtalkConfig,
|
|
1057
|
+
isDirect,
|
|
1058
|
+
groupId,
|
|
1059
|
+
quotedSenderId: quotedRecord?.senderId || content.quoted?.previewSenderId,
|
|
1060
|
+
currentSenderId: senderId,
|
|
1061
|
+
currentSenderOriginalId: senderOriginalId,
|
|
890
1062
|
});
|
|
891
1063
|
|
|
892
1064
|
// Try downloading a quoted file from cached downloadCode/spaceId+fileId.
|
|
@@ -899,13 +1071,14 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
899
1071
|
fileId?: string;
|
|
900
1072
|
};
|
|
901
1073
|
} | null,
|
|
1074
|
+
originalFilename?: string,
|
|
902
1075
|
): Promise<MediaFile | null> => {
|
|
903
1076
|
if (!record?.media) {
|
|
904
1077
|
return null;
|
|
905
1078
|
}
|
|
906
1079
|
let media: MediaFile | null = null;
|
|
907
1080
|
if (record.media.downloadCode) {
|
|
908
|
-
media = await downloadMedia(dingtalkConfig, record.media.downloadCode, log);
|
|
1081
|
+
media = await downloadMedia(dingtalkConfig, record.media.downloadCode, log, originalFilename);
|
|
909
1082
|
if (media) {
|
|
910
1083
|
log?.debug?.(
|
|
911
1084
|
`[DingTalk][QuotedRef] Recovered quoted media from cached downloadCode ` +
|
|
@@ -922,6 +1095,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
922
1095
|
record.media.fileId,
|
|
923
1096
|
unionId,
|
|
924
1097
|
log,
|
|
1098
|
+
originalFilename,
|
|
925
1099
|
);
|
|
926
1100
|
if (media) {
|
|
927
1101
|
log?.debug?.(
|
|
@@ -938,9 +1112,15 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
938
1112
|
|
|
939
1113
|
// Quoted picture: download via existing downloadMedia.
|
|
940
1114
|
if (!mediaPath && content.quoted?.mediaDownloadCode && robotCode) {
|
|
1115
|
+
const quotedOriginalFilename = content.quoted.previewFileName;
|
|
941
1116
|
const media =
|
|
942
|
-
(await tryDownloadFromRecord(quotedRecord)) ||
|
|
943
|
-
(await downloadMedia(
|
|
1117
|
+
(await tryDownloadFromRecord(quotedRecord, quotedOriginalFilename)) ||
|
|
1118
|
+
(await downloadMedia(
|
|
1119
|
+
dingtalkConfig,
|
|
1120
|
+
content.quoted.mediaDownloadCode,
|
|
1121
|
+
log,
|
|
1122
|
+
quotedOriginalFilename,
|
|
1123
|
+
));
|
|
944
1124
|
if (media) {
|
|
945
1125
|
if (!quotedRecord) {
|
|
946
1126
|
log?.debug?.(
|
|
@@ -965,7 +1145,12 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
965
1145
|
|
|
966
1146
|
// Step 0: Direct download via downloadCode from quoted payload (file/audio/video msgType).
|
|
967
1147
|
if (!fileResolved && content.quoted.fileDownloadCode && robotCode) {
|
|
968
|
-
const media = await downloadMedia(
|
|
1148
|
+
const media = await downloadMedia(
|
|
1149
|
+
dingtalkConfig,
|
|
1150
|
+
content.quoted.fileDownloadCode,
|
|
1151
|
+
log,
|
|
1152
|
+
content.quoted.previewFileName,
|
|
1153
|
+
);
|
|
969
1154
|
if (media) {
|
|
970
1155
|
mediaPath = media.path;
|
|
971
1156
|
mediaType = media.mimeType;
|
|
@@ -982,7 +1167,10 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
982
1167
|
|
|
983
1168
|
// Step 1: Prefer quotedRef-backed record lookup, then msgId-based cache.
|
|
984
1169
|
if (!fileResolved) {
|
|
985
|
-
const cachedMedia = await tryDownloadFromRecord(
|
|
1170
|
+
const cachedMedia = await tryDownloadFromRecord(
|
|
1171
|
+
quotedRecord,
|
|
1172
|
+
quotedRecord?.attachmentFileName || content.quoted.previewFileName,
|
|
1173
|
+
);
|
|
986
1174
|
if (cachedMedia) {
|
|
987
1175
|
mediaPath = cachedMedia.path;
|
|
988
1176
|
mediaType = cachedMedia.mimeType;
|
|
@@ -1056,7 +1244,10 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1056
1244
|
if (!mediaPath && content.quoted?.isQuotedDocCard) {
|
|
1057
1245
|
let docResolved = false;
|
|
1058
1246
|
|
|
1059
|
-
const cachedDocMedia = await tryDownloadFromRecord(
|
|
1247
|
+
const cachedDocMedia = await tryDownloadFromRecord(
|
|
1248
|
+
quotedRecord,
|
|
1249
|
+
quotedRecord?.attachmentFileName || content.quoted?.previewFileName,
|
|
1250
|
+
);
|
|
1060
1251
|
if (cachedDocMedia) {
|
|
1061
1252
|
mediaPath = cachedDocMedia.path;
|
|
1062
1253
|
mediaType = cachedDocMedia.mimeType;
|
|
@@ -1280,7 +1471,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1280
1471
|
// "@Agent /stop" are correctly recognised as abort requests in both DM and group
|
|
1281
1472
|
// chats. In groups DingTalk usually strips @BotName at the protocol level, but
|
|
1282
1473
|
// in DMs with multi-agent routing the @mention prefix survives all the way here.
|
|
1283
|
-
const textForAbortCheck = inboundText
|
|
1474
|
+
const textForAbortCheck = stripLeadingMentions(inboundText).trim();
|
|
1284
1475
|
if (isAbortRequestText(textForAbortCheck)) {
|
|
1285
1476
|
log?.info?.(
|
|
1286
1477
|
`[DingTalk] Abort request detected, bypassing session lock for session=${route.sessionKey}`,
|
|
@@ -1332,9 +1523,19 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1332
1523
|
}
|
|
1333
1524
|
// Finalize the card that was created for this message before the abort check.
|
|
1334
1525
|
// Without this, the card stays in PROCESSING ("处理中...") indefinitely.
|
|
1526
|
+
// Use V2 finalize (commitAICardBlocks) for consistent state transition.
|
|
1335
1527
|
if (currentAICard && !isCardInTerminalState(currentAICard.state)) {
|
|
1336
1528
|
try {
|
|
1337
|
-
|
|
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}`);
|
|
1338
1539
|
} catch (cardErr) {
|
|
1339
1540
|
log?.warn?.(`[DingTalk] Abort card finalize failed: ${getErrorMessage(cardErr)}`);
|
|
1340
1541
|
currentAICard.state = AICardStatus.FAILED;
|
|
@@ -1343,6 +1544,61 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1343
1544
|
return;
|
|
1344
1545
|
}
|
|
1345
1546
|
|
|
1547
|
+
// ---- Pre-lock BTW: bypass session lock for /btw side questions ----
|
|
1548
|
+
// /btw runs an isolated, tool-less side query in openclaw without polluting
|
|
1549
|
+
// the main run's transcript. The dispatch must NOT acquire the session lock,
|
|
1550
|
+
// otherwise it would queue behind the in-flight main task and lose its "side
|
|
1551
|
+
// question" semantics.
|
|
1552
|
+
//
|
|
1553
|
+
// The `isBtwBypass` flag is computed once early (just after `content` is
|
|
1554
|
+
// resolved, just before `createAICard`). The same constant gates `createAICard` above and
|
|
1555
|
+
// drives this branch — single decision, two consequences. See the comment
|
|
1556
|
+
// at the flag's definition for why /btw uses pre-OCR `content.text` while
|
|
1557
|
+
// abort uses `inboundText`.
|
|
1558
|
+
if (isBtwBypass) {
|
|
1559
|
+
log?.info?.(
|
|
1560
|
+
`[DingTalk] BTW request detected, bypassing session lock for session=${route.sessionKey}`,
|
|
1561
|
+
);
|
|
1562
|
+
// Empty fallback (NOT "Unknown" like the file's main `senderName` variable):
|
|
1563
|
+
// when the nickname is missing we want the blockquote to render as
|
|
1564
|
+
// `> /btw <question>` rather than `> Unknown: /btw <question>`. Read locally
|
|
1565
|
+
// here so the existing `senderName` semantics elsewhere in the file are not
|
|
1566
|
+
// affected.
|
|
1567
|
+
const btwSenderName = data.senderNick || "";
|
|
1568
|
+
try {
|
|
1569
|
+
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1570
|
+
ctx,
|
|
1571
|
+
cfg,
|
|
1572
|
+
dispatcherOptions: {
|
|
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
|
+
},
|
|
1594
|
+
},
|
|
1595
|
+
});
|
|
1596
|
+
} catch (btwErr) {
|
|
1597
|
+
log?.warn?.(`[DingTalk] BTW dispatch failed: ${getErrorMessage(btwErr)}`);
|
|
1598
|
+
}
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1346
1602
|
const ackReaction =
|
|
1347
1603
|
typeof dingtalkConfig.ackReaction === "string"
|
|
1348
1604
|
? dingtalkConfig.ackReaction.trim()
|
|
@@ -1380,8 +1636,117 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1380
1636
|
}
|
|
1381
1637
|
}
|
|
1382
1638
|
|
|
1639
|
+
function parseInlineReplyPayloadText(text: unknown): {
|
|
1640
|
+
text?: string;
|
|
1641
|
+
mediaUrls: string[];
|
|
1642
|
+
audioAsVoice: boolean;
|
|
1643
|
+
} {
|
|
1644
|
+
if (typeof text !== "string") {
|
|
1645
|
+
return { text: undefined, mediaUrls: [], audioAsVoice: false };
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
const normalizedText = text.replace(/\r\n/g, "\n");
|
|
1649
|
+
const parsedInline = normalizedText.includes("[[")
|
|
1650
|
+
? parseInlineDirectives(normalizedText, {
|
|
1651
|
+
stripAudioTag: true,
|
|
1652
|
+
stripReplyTags: false,
|
|
1653
|
+
})
|
|
1654
|
+
: {
|
|
1655
|
+
text: normalizedText,
|
|
1656
|
+
audioAsVoice: false,
|
|
1657
|
+
replyToCurrent: false,
|
|
1658
|
+
hasAudioTag: false,
|
|
1659
|
+
hasReplyTag: false,
|
|
1660
|
+
};
|
|
1661
|
+
const mediaUrls: string[] = [];
|
|
1662
|
+
const contentLines: string[] = [];
|
|
1663
|
+
|
|
1664
|
+
for (const line of parsedInline.text.split("\n")) {
|
|
1665
|
+
const trimmed = line.trim();
|
|
1666
|
+
const mediaCandidate = trimmed.replace(/^(?:\[\[[^[\]]+\]\]\s*)+/, "");
|
|
1667
|
+
if (mediaCandidate.startsWith(MEDIA_DIRECTIVE_PREFIX)) {
|
|
1668
|
+
const mediaSource = mediaCandidate.slice(MEDIA_DIRECTIVE_PREFIX.length).trim();
|
|
1669
|
+
if (mediaSource) {
|
|
1670
|
+
mediaUrls.push(mediaSource);
|
|
1671
|
+
continue;
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
contentLines.push(line);
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
const cleanedText = contentLines.join("\n").trim();
|
|
1678
|
+
const inlineTextWasTransformed = parsedInline.text !== normalizedText;
|
|
1679
|
+
if (mediaUrls.length === 0) {
|
|
1680
|
+
const standaloneMediaSource = extractStandaloneMediaSource(cleanedText);
|
|
1681
|
+
if (standaloneMediaSource) {
|
|
1682
|
+
return {
|
|
1683
|
+
text: undefined,
|
|
1684
|
+
mediaUrls: [standaloneMediaSource],
|
|
1685
|
+
audioAsVoice: parsedInline.audioAsVoice,
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
return {
|
|
1691
|
+
// Keep ordinary text formatting stable except for newline normalization.
|
|
1692
|
+
// Once inline parsing actually strips directives/media lines, return the
|
|
1693
|
+
// cleaned body text instead of the original raw payload.
|
|
1694
|
+
text: mediaUrls.length > 0 || inlineTextWasTransformed ? cleanedText || undefined : normalizedText,
|
|
1695
|
+
mediaUrls,
|
|
1696
|
+
audioAsVoice: parsedInline.audioAsVoice,
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
function extractStandaloneMediaSource(text: string): string | undefined {
|
|
1701
|
+
const trimmed = text.trim();
|
|
1702
|
+
if (!trimmed || trimmed.includes("\n") || /\s/.test(trimmed)) {
|
|
1703
|
+
return undefined;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
const hasPathLikeShape =
|
|
1707
|
+
/^https?:\/\/\S+$/i.test(trimmed) ||
|
|
1708
|
+
trimmed.startsWith("~/") ||
|
|
1709
|
+
trimmed.startsWith("~\\") ||
|
|
1710
|
+
trimmed.startsWith("./") ||
|
|
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
|
+
}
|
|
1722
|
+
|
|
1723
|
+
return STANDALONE_MEDIA_PATH_EXTENSIONS.has(path.extname(trimmed).toLowerCase())
|
|
1724
|
+
? trimmed
|
|
1725
|
+
: undefined;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1383
1728
|
// ---- Shared media delivery helper ----
|
|
1384
|
-
|
|
1729
|
+
function extractSharedAudioAsVoice(
|
|
1730
|
+
payload: ReplyStreamPayload,
|
|
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;
|
|
1742
|
+
}
|
|
1743
|
+
if (parseBooleanLike(richPayload.asVoice) === true) {
|
|
1744
|
+
return true;
|
|
1745
|
+
}
|
|
1746
|
+
return inlineReplyPayload?.audioAsVoice === true;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
async function deliverMediaAttachments(urls: string[], options?: { audioAsVoice?: boolean }) {
|
|
1385
1750
|
for (const rawMediaUrl of urls) {
|
|
1386
1751
|
const preparedMedia = await prepareMediaInput(
|
|
1387
1752
|
rawMediaUrl,
|
|
@@ -1389,10 +1754,12 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1389
1754
|
dingtalkConfig.mediaUrlAllowlist,
|
|
1390
1755
|
);
|
|
1391
1756
|
try {
|
|
1392
|
-
const actualMediaPath = preparedMedia.
|
|
1757
|
+
const actualMediaPath = preparedMedia.cleanup
|
|
1758
|
+
? preparedMedia.path
|
|
1759
|
+
: resolveRelativePath(preparedMedia.path);
|
|
1393
1760
|
const outMediaType = resolveOutboundMediaType({
|
|
1394
1761
|
mediaPath: actualMediaPath,
|
|
1395
|
-
asVoice:
|
|
1762
|
+
asVoice: options?.audioAsVoice === true,
|
|
1396
1763
|
});
|
|
1397
1764
|
if (sessionWebhook) {
|
|
1398
1765
|
const sendResult = await sendMessage(dingtalkConfig, to, "", {
|
|
@@ -1433,18 +1800,24 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1433
1800
|
}
|
|
1434
1801
|
|
|
1435
1802
|
// ---- Extract mediaUrls from runtime payload ----
|
|
1436
|
-
function extractMediaUrls(
|
|
1803
|
+
function extractMediaUrls(
|
|
1804
|
+
payload: ReplyStreamPayload,
|
|
1805
|
+
inlineReplyPayload?: ReturnType<typeof parseInlineReplyPayloadText>,
|
|
1806
|
+
): string[] {
|
|
1437
1807
|
const richPayload = payload as typeof payload & {
|
|
1438
1808
|
mediaUrl?: string;
|
|
1439
1809
|
mediaUrls?: string[];
|
|
1440
1810
|
};
|
|
1441
|
-
|
|
1811
|
+
const explicitMediaUrls = Array.isArray(richPayload.mediaUrls)
|
|
1442
1812
|
? richPayload.mediaUrls.filter((entry: unknown) => typeof entry === "string" && entry.trim())
|
|
1443
1813
|
: richPayload.mediaUrl &&
|
|
1444
1814
|
typeof richPayload.mediaUrl === "string" &&
|
|
1445
1815
|
richPayload.mediaUrl.trim()
|
|
1446
1816
|
? [richPayload.mediaUrl]
|
|
1447
1817
|
: [];
|
|
1818
|
+
return explicitMediaUrls.length > 0
|
|
1819
|
+
? explicitMediaUrls
|
|
1820
|
+
: inlineReplyPayload?.mediaUrls ?? [];
|
|
1448
1821
|
}
|
|
1449
1822
|
|
|
1450
1823
|
// Serialize dispatchReply + card finalize per session to prevent the runtime
|
|
@@ -1503,8 +1876,30 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1503
1876
|
sessionUpdatedAt: previousTimestamp,
|
|
1504
1877
|
log,
|
|
1505
1878
|
});
|
|
1879
|
+
const legacyCardStreamReasoning = resolveLegacyCardStreamReasoningForInternalUse({
|
|
1880
|
+
cfg,
|
|
1881
|
+
accountId,
|
|
1882
|
+
});
|
|
1883
|
+
const strategyConfig =
|
|
1884
|
+
legacyCardStreamReasoning === undefined
|
|
1885
|
+
? dingtalkConfig
|
|
1886
|
+
: { ...dingtalkConfig, cardStreamReasoning: legacyCardStreamReasoning };
|
|
1887
|
+
const sessionTaskState = getSessionState(accountId, taskInfoConversationId);
|
|
1888
|
+
const taskMeta = {
|
|
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
|
+
};
|
|
1900
|
+
|
|
1506
1901
|
const strategy = createReplyStrategy({
|
|
1507
|
-
config:
|
|
1902
|
+
config: strategyConfig,
|
|
1508
1903
|
card: currentAICard,
|
|
1509
1904
|
useCardMode: replyMode === "card",
|
|
1510
1905
|
to,
|
|
@@ -1526,10 +1921,13 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1526
1921
|
replyQuotedRef,
|
|
1527
1922
|
deliverMedia: deliverMediaAttachments,
|
|
1528
1923
|
isStopRequested: isCurrentCardStopRequested,
|
|
1924
|
+
inboundText: rawInboundText,
|
|
1925
|
+
taskMeta,
|
|
1529
1926
|
});
|
|
1530
1927
|
|
|
1531
1928
|
try {
|
|
1532
|
-
|
|
1929
|
+
let deliveredFinalCount = 0;
|
|
1930
|
+
const dispatchResult = await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1533
1931
|
ctx,
|
|
1534
1932
|
cfg,
|
|
1535
1933
|
dispatcherOptions: {
|
|
@@ -1538,13 +1936,18 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1538
1936
|
if (isCurrentCardStopRequested()) {
|
|
1539
1937
|
log?.debug?.("[DingTalk][CardStop] Ignoring reply delivery because stop was already requested");
|
|
1540
1938
|
return;
|
|
1541
|
-
|
|
1939
|
+
}
|
|
1542
1940
|
try {
|
|
1543
|
-
|
|
1941
|
+
if (info?.kind === "final") {
|
|
1942
|
+
deliveredFinalCount += 1;
|
|
1943
|
+
}
|
|
1944
|
+
const inlineReplyPayload = parseInlineReplyPayloadText(payload.text);
|
|
1945
|
+
const mediaUrls = extractMediaUrls(payload, inlineReplyPayload);
|
|
1544
1946
|
const richPayload = payload as ReplyStreamPayload & { isReasoning?: boolean };
|
|
1545
1947
|
await strategy.deliver({
|
|
1546
|
-
text:
|
|
1948
|
+
text: inlineReplyPayload.text,
|
|
1547
1949
|
mediaUrls,
|
|
1950
|
+
audioAsVoice: extractSharedAudioAsVoice(payload, inlineReplyPayload),
|
|
1548
1951
|
kind: (info?.kind as DeliverPayload["kind"]) || "block",
|
|
1549
1952
|
isReasoning: richPayload.isReasoning === true,
|
|
1550
1953
|
});
|
|
@@ -1560,6 +1963,43 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1560
1963
|
},
|
|
1561
1964
|
replyOptions: strategy.getReplyOptions(),
|
|
1562
1965
|
});
|
|
1966
|
+
|
|
1967
|
+
const bufferedFinal =
|
|
1968
|
+
dispatchResult && typeof dispatchResult === "object" && "queuedFinal" in dispatchResult
|
|
1969
|
+
? (dispatchResult as { queuedFinal?: unknown }).queuedFinal
|
|
1970
|
+
: undefined;
|
|
1971
|
+
const finalCount =
|
|
1972
|
+
dispatchResult && typeof dispatchResult === "object" && "counts" in dispatchResult
|
|
1973
|
+
? (dispatchResult as { counts?: { final?: unknown } }).counts?.final
|
|
1974
|
+
: undefined;
|
|
1975
|
+
|
|
1976
|
+
log?.info?.(
|
|
1977
|
+
`[DingTalk][Dispatch] completed — deliveredFinalCount=${deliveredFinalCount} ` +
|
|
1978
|
+
`counts.final=${typeof finalCount === "number" ? finalCount : "n/a"} ` +
|
|
1979
|
+
`queuedFinalType=${typeof bufferedFinal}`,
|
|
1980
|
+
);
|
|
1981
|
+
|
|
1982
|
+
const bufferedFinalPayload =
|
|
1983
|
+
typeof bufferedFinal === "string"
|
|
1984
|
+
? ({ text: bufferedFinal } satisfies ReplyStreamPayload)
|
|
1985
|
+
: bufferedFinal && typeof bufferedFinal === "object"
|
|
1986
|
+
? (bufferedFinal as ReplyStreamPayload)
|
|
1987
|
+
: undefined;
|
|
1988
|
+
|
|
1989
|
+
if (deliveredFinalCount === 0 && bufferedFinalPayload) {
|
|
1990
|
+
const inlineReplyPayload = parseInlineReplyPayloadText(bufferedFinalPayload.text);
|
|
1991
|
+
const mediaUrls = extractMediaUrls(bufferedFinalPayload, inlineReplyPayload);
|
|
1992
|
+
const hasBufferedText = typeof bufferedFinalPayload.text === "string" && bufferedFinalPayload.text.trim().length > 0;
|
|
1993
|
+
if (hasBufferedText || mediaUrls.length > 0) {
|
|
1994
|
+
await strategy.deliver({
|
|
1995
|
+
text: inlineReplyPayload.text,
|
|
1996
|
+
mediaUrls,
|
|
1997
|
+
audioAsVoice: extractSharedAudioAsVoice(bufferedFinalPayload, inlineReplyPayload),
|
|
1998
|
+
kind: "final",
|
|
1999
|
+
isReasoning: bufferedFinalPayload.isReasoning === true,
|
|
2000
|
+
});
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
1563
2003
|
} catch (dispatchErr: unknown) {
|
|
1564
2004
|
const error = dispatchErr instanceof Error ? dispatchErr : new Error(getErrorMessage(dispatchErr));
|
|
1565
2005
|
await strategy.abort(error);
|