@soimy/dingtalk 3.6.3 → 3.6.5
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 +12 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6715 -5686
- package/dist/index.js.map +4 -4
- package/dist/src/card/ask-user-question-context.d.ts +17 -0
- package/dist/src/card/ask-user-question-context.d.ts.map +1 -0
- package/dist/src/card/ask-user-question.d.ts +241 -0
- package/dist/src/card/ask-user-question.d.ts.map +1 -0
- package/dist/src/card/card-action-handler.d.ts +1 -0
- package/dist/src/card/card-action-handler.d.ts.map +1 -1
- package/dist/src/card/card-template.d.ts +5 -0
- package/dist/src/card/card-template.d.ts.map +1 -1
- package/dist/src/gateway/channel-gateway.d.ts.map +1 -1
- package/dist/src/inbound-handler.d.ts.map +1 -1
- package/dist/src/reply-strategy-card.d.ts.map +1 -1
- package/dist/src/targeting/agent-routing.d.ts +55 -16
- package/dist/src/targeting/agent-routing.d.ts.map +1 -1
- package/dist/src/types.d.ts +7 -0
- package/dist/src/types.d.ts.map +1 -1
- package/index.ts +18 -1
- package/openclaw.plugin.json +3 -0
- package/package.json +1 -1
- package/src/card/ask-user-question-context.ts +31 -0
- package/src/card/ask-user-question.ts +1068 -0
- package/src/card/card-action-handler.ts +14 -0
- package/src/card/card-template.ts +10 -0
- package/src/gateway/channel-gateway.ts +5 -5
- package/src/inbound-handler.ts +185 -42
- package/src/reply-strategy-card.ts +19 -0
- package/src/targeting/agent-routing.ts +151 -85
- package/src/types.ts +7 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
2
2
|
import type { CardCallbackAnalysis } from "../card-callback-service";
|
|
3
3
|
import type { DingTalkConfig, Logger } from "../types";
|
|
4
|
+
import { handleDingTalkAskUserCardCallback } from "./ask-user-question";
|
|
4
5
|
import { resolveCardRun } from "./card-run-registry";
|
|
5
6
|
import { stopCardRun } from "./card-stop-handler";
|
|
6
7
|
|
|
@@ -9,12 +10,25 @@ export interface CardActionResult {
|
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
export async function handleCardAction(params: {
|
|
13
|
+
payload: unknown;
|
|
12
14
|
analysis: CardCallbackAnalysis;
|
|
13
15
|
cfg: OpenClawConfig;
|
|
14
16
|
accountId: string;
|
|
15
17
|
config: DingTalkConfig;
|
|
16
18
|
log?: Logger;
|
|
17
19
|
}): Promise<CardActionResult> {
|
|
20
|
+
const askUserResult = await handleDingTalkAskUserCardCallback({
|
|
21
|
+
payload: params.payload,
|
|
22
|
+
cfg: params.cfg,
|
|
23
|
+
accountId: params.accountId,
|
|
24
|
+
config: params.config,
|
|
25
|
+
clickerUserId: params.analysis.userId,
|
|
26
|
+
log: params.log,
|
|
27
|
+
});
|
|
28
|
+
if (askUserResult.handled) {
|
|
29
|
+
return askUserResult;
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
if (params.analysis.actionId !== "btn_stop") {
|
|
19
33
|
return { handled: false };
|
|
20
34
|
}
|
|
@@ -8,6 +8,8 @@ export const BUILTIN_DINGTALK_CARD_TEMPLATE_ID =
|
|
|
8
8
|
export const BUILTIN_DINGTALK_CARD_CONTENT_KEY = "content";
|
|
9
9
|
export const BUILTIN_DINGTALK_CARD_BLOCK_LIST_KEY = "blockList";
|
|
10
10
|
export const BUILTIN_DINGTALK_CARD_COPY_CONTENT_KEY = "copy_content";
|
|
11
|
+
export const BUILTIN_DINGTALK_ASK_USER_CARD_TEMPLATE_ID =
|
|
12
|
+
"89d0c6fe-3822-44c8-950e-e950f562546d.schema";
|
|
11
13
|
|
|
12
14
|
export interface DingTalkCardTemplateContract {
|
|
13
15
|
templateId: string;
|
|
@@ -20,6 +22,10 @@ export interface DingTalkCardTemplateContract {
|
|
|
20
22
|
copyContentKey: string;
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
export interface DingTalkAskUserCardTemplateContract {
|
|
26
|
+
templateId: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
23
29
|
/** Frozen singleton — no allocation on every call. */
|
|
24
30
|
export const DINGTALK_CARD_TEMPLATE: Readonly<DingTalkCardTemplateContract> = Object.freeze({
|
|
25
31
|
templateId: BUILTIN_DINGTALK_CARD_TEMPLATE_ID,
|
|
@@ -29,3 +35,7 @@ export const DINGTALK_CARD_TEMPLATE: Readonly<DingTalkCardTemplateContract> = Ob
|
|
|
29
35
|
copyContentKey: BUILTIN_DINGTALK_CARD_COPY_CONTENT_KEY,
|
|
30
36
|
});
|
|
31
37
|
|
|
38
|
+
export const DINGTALK_ASK_USER_CARD_TEMPLATE: Readonly<DingTalkAskUserCardTemplateContract> =
|
|
39
|
+
Object.freeze({
|
|
40
|
+
templateId: BUILTIN_DINGTALK_ASK_USER_CARD_TEMPLATE_ID,
|
|
41
|
+
});
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { DWClient, TOPIC_CARD, TOPIC_ROBOT } from "dingtalk-stream";
|
|
2
2
|
import { analyzeCardCallback } from "../card-callback-service";
|
|
3
|
+
import { finalizeActiveCardsForAccount, recoverPendingCardsForAccount } from "../card-service";
|
|
3
4
|
import { handleCardAction } from "../card/card-action-handler";
|
|
4
|
-
import {
|
|
5
|
-
finalizeActiveCardsForAccount,
|
|
6
|
-
recoverPendingCardsForAccount,
|
|
7
|
-
} from "../card-service";
|
|
8
5
|
import { resolveRobotCode, resolveRuntimeConfig } from "../config";
|
|
9
6
|
import { ConnectionManager } from "../connection-manager";
|
|
10
7
|
import { isMessageProcessed, markMessageProcessed } from "../dedup";
|
|
@@ -381,6 +378,7 @@ export function createDingTalkGateway(): NonNullable<DingTalkChannelPlugin["gate
|
|
|
381
378
|
}
|
|
382
379
|
}
|
|
383
380
|
const actionResult = await handleCardAction({
|
|
381
|
+
payload,
|
|
384
382
|
analysis,
|
|
385
383
|
cfg,
|
|
386
384
|
accountId: account.accountId,
|
|
@@ -500,7 +498,9 @@ export function createDingTalkGateway(): NonNullable<DingTalkChannelPlugin["gate
|
|
|
500
498
|
lastStartAt: getCurrentTimestamp(),
|
|
501
499
|
lastError: null,
|
|
502
500
|
});
|
|
503
|
-
pluginLog?.info?.(
|
|
501
|
+
pluginLog?.info?.(
|
|
502
|
+
`[${account.accountId}] DingTalk Stream client connected successfully`,
|
|
503
|
+
);
|
|
504
504
|
await nativeStopPromise;
|
|
505
505
|
}
|
|
506
506
|
} catch (err: any) {
|
package/src/inbound-handler.ts
CHANGED
|
@@ -7,9 +7,19 @@ import { classifyAckReactionEmoji } from "./ack-reaction-classifier";
|
|
|
7
7
|
import { attachNativeAckReaction } from "./ack-reaction-service";
|
|
8
8
|
import { createDynamicAckReactionController } from "./ack-reaction/dynamic-ack-reaction-controller";
|
|
9
9
|
import { getAccessToken } from "./auth";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
createAICard,
|
|
12
|
+
commitAICardBlocks,
|
|
13
|
+
isCardInTerminalState,
|
|
14
|
+
recallAICardMessage,
|
|
15
|
+
} from "./card-service";
|
|
16
|
+
import {
|
|
17
|
+
getDingTalkQuestionContext,
|
|
18
|
+
withDingTalkQuestionContext,
|
|
19
|
+
} from "./card/ask-user-question-context";
|
|
11
20
|
import { isCardRunStopRequested, registerCardRun, removeCardRun } from "./card/card-run-registry";
|
|
12
21
|
import { renderStatusLine } from "./card/statusline-renderer";
|
|
22
|
+
import { dispatchDingTalkCardStopCommand } from "./command/card-stop-command";
|
|
13
23
|
import { handleInboundCommandDispatch } from "./command/inbound-command-dispatch-service";
|
|
14
24
|
import {
|
|
15
25
|
resolveAckReactionSetting,
|
|
@@ -60,8 +70,9 @@ import { getSessionState, initSessionState } from "./session-state";
|
|
|
60
70
|
import { getAgentDisplayName } from "./targeting/agent-name-matcher";
|
|
61
71
|
import {
|
|
62
72
|
buildAgentSessionKey,
|
|
63
|
-
resolveSubAgentRoute,
|
|
64
73
|
dispatchSubAgents,
|
|
74
|
+
resolveMessageTarget,
|
|
75
|
+
sendUnmatchedAgentNotice,
|
|
65
76
|
} from "./targeting/agent-routing";
|
|
66
77
|
import { formatGroupMembers, noteGroupMember } from "./targeting/group-members-store";
|
|
67
78
|
import {
|
|
@@ -547,6 +558,23 @@ export async function downloadMedia(
|
|
|
547
558
|
}
|
|
548
559
|
|
|
549
560
|
export async function handleDingTalkMessage(params: HandleDingTalkMessageParams): Promise<void> {
|
|
561
|
+
// Keep context creation inside this public inbound entry. Ask-user synthetic
|
|
562
|
+
// reinjections call handleDingTalkMessage directly, so moving this wrapper to
|
|
563
|
+
// gateway callbacks would lose per-message isolation for reinjected answers.
|
|
564
|
+
return withDingTalkQuestionContext(
|
|
565
|
+
{
|
|
566
|
+
cfg: params.cfg,
|
|
567
|
+
accountId: params.accountId,
|
|
568
|
+
data: params.data,
|
|
569
|
+
sessionWebhook: params.sessionWebhook,
|
|
570
|
+
log: params.log,
|
|
571
|
+
dingtalkConfig: params.dingtalkConfig,
|
|
572
|
+
},
|
|
573
|
+
() => handleDingTalkMessageInner(params),
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams): Promise<void> {
|
|
550
578
|
const {
|
|
551
579
|
cfg,
|
|
552
580
|
accountId,
|
|
@@ -580,8 +608,10 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
580
608
|
// for use in card quoteContent which should show the user's original message.
|
|
581
609
|
const rawInboundText = extractedContent.text.trim();
|
|
582
610
|
|
|
583
|
-
// Add context hint for sub-agent mode, stripping quoted prefix to avoid protocol noise
|
|
584
|
-
|
|
611
|
+
// Add context hint for sub-agent content mode, stripping quoted prefix to avoid protocol noise
|
|
612
|
+
// in agent context. Skipped for targeted slash commands (`commandText` set): the hint would
|
|
613
|
+
// pollute RawBody, and the command never reaches the agent's LLM anyway.
|
|
614
|
+
if (subAgentOptions && !subAgentOptions.commandText) {
|
|
585
615
|
const cleanText = extractedContent.text.replace(/^\[引用[^\]]*\]\s*/, "");
|
|
586
616
|
const contextHint = `[你被 @ 为"${subAgentOptions.matchedName}"]\n\n`;
|
|
587
617
|
extractedContent.text = contextHint + cleanText;
|
|
@@ -772,40 +802,85 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
772
802
|
config: dingtalkConfig,
|
|
773
803
|
});
|
|
774
804
|
|
|
775
|
-
|
|
776
|
-
|
|
805
|
+
// Single routing decision for this message. Skipped for recursive sub-agent
|
|
806
|
+
// calls, where routing is already fixed by subAgentOptions.
|
|
807
|
+
const messageTarget = subAgentOptions
|
|
808
|
+
? null
|
|
809
|
+
: resolveMessageTarget({ extractedContent, cfg, isGroup });
|
|
810
|
+
|
|
811
|
+
let route: { agentId: string; sessionKey: string; mainSessionKey: string };
|
|
812
|
+
if (subAgentOptions) {
|
|
813
|
+
// Recursive sub-agent dispatch (content or targeted command): route to the
|
|
814
|
+
// agent's own session. A missing host helper throws here and is caught by
|
|
815
|
+
// dispatchSubAgents in the parent call.
|
|
816
|
+
route = {
|
|
817
|
+
agentId: subAgentOptions.agentId,
|
|
818
|
+
sessionKey: buildAgentSessionKey({
|
|
819
|
+
rt,
|
|
820
|
+
cfg,
|
|
821
|
+
accountId,
|
|
777
822
|
agentId: subAgentOptions.agentId,
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
823
|
+
peerKind: sessionPeer.kind,
|
|
824
|
+
peerId: sessionPeer.peerId,
|
|
825
|
+
}),
|
|
826
|
+
mainSessionKey: "",
|
|
827
|
+
};
|
|
828
|
+
} else {
|
|
829
|
+
// Default route. For subagent-content / subagent-command targets the
|
|
830
|
+
// dispatch below re-enters this function with subAgentOptions set, so this
|
|
831
|
+
// route is only consumed when the target is "default".
|
|
832
|
+
route = rt.channel.routing.resolveAgentRoute({
|
|
833
|
+
cfg,
|
|
834
|
+
channel: "dingtalk",
|
|
835
|
+
accountId,
|
|
836
|
+
peer: { kind: sessionPeer.kind, id: sessionPeer.peerId },
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
const questionContext = getDingTalkQuestionContext();
|
|
840
|
+
if (questionContext) {
|
|
841
|
+
questionContext.questionScopeKey = `${accountId}:${route.sessionKey}:${senderId}`;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// @Sub-Agent routing: dispatch @mention-targeted messages to their agent(s).
|
|
845
|
+
// Both content (`@agent <message>`) and commands (`@agent /new`) re-enter
|
|
846
|
+
// handleDingTalkMessage via dispatchSubAgents with subAgentOptions set, so the
|
|
847
|
+
// agent session key, helper-missing fallback, and recursion live in one path.
|
|
848
|
+
if (messageTarget && messageTarget.kind !== "default") {
|
|
849
|
+
if (messageTarget.kind === "subagent-command") {
|
|
850
|
+
await dispatchSubAgents({
|
|
851
|
+
matchedAgents: [messageTarget.agent],
|
|
852
|
+
commandText: messageTarget.commandText,
|
|
789
853
|
cfg,
|
|
790
|
-
channel: "dingtalk",
|
|
791
854
|
accountId,
|
|
792
|
-
|
|
855
|
+
data,
|
|
856
|
+
dingtalkConfig,
|
|
857
|
+
sessionWebhook,
|
|
858
|
+
extractedContent,
|
|
859
|
+
handleMessage: handleDingTalkMessage,
|
|
860
|
+
downloadMedia,
|
|
861
|
+
log,
|
|
793
862
|
});
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
794
865
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
866
|
+
if (messageTarget.hasInvalidAgentNames) {
|
|
867
|
+
await sendUnmatchedAgentNotice({
|
|
868
|
+
unmatchedNames: messageTarget.unmatchedNames,
|
|
869
|
+
isGroup,
|
|
870
|
+
senderId,
|
|
871
|
+
dingtalkConfig,
|
|
872
|
+
sessionWebhook,
|
|
873
|
+
log,
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
if (messageTarget.matchedAgents.length > 0) {
|
|
877
|
+
log?.info?.(
|
|
878
|
+
`[DingTalk] Sub-agent resolve: matched=${messageTarget.matchedAgents
|
|
879
|
+
.map((a) => a.agentId)
|
|
880
|
+
.join(",")} unmatched=${messageTarget.unmatchedNames.join(",")}`,
|
|
881
|
+
);
|
|
807
882
|
await dispatchSubAgents({
|
|
808
|
-
|
|
883
|
+
matchedAgents: messageTarget.matchedAgents,
|
|
809
884
|
cfg,
|
|
810
885
|
accountId,
|
|
811
886
|
data,
|
|
@@ -818,6 +893,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
818
893
|
});
|
|
819
894
|
return;
|
|
820
895
|
}
|
|
896
|
+
// Only invalid agent names and no match: fall through to the default route.
|
|
821
897
|
}
|
|
822
898
|
|
|
823
899
|
// Route resolved before media download for session context and routing metadata.
|
|
@@ -832,7 +908,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
832
908
|
dingtalkConfig,
|
|
833
909
|
senderId,
|
|
834
910
|
isDirect,
|
|
835
|
-
extractedText: extractedContent.text,
|
|
911
|
+
extractedText: subAgentOptions?.commandText ?? extractedContent.text,
|
|
836
912
|
messageType: extractedContent.messageType,
|
|
837
913
|
data: {
|
|
838
914
|
conversationId: data.conversationId,
|
|
@@ -885,8 +961,52 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
885
961
|
// sending a separate plain-text message.
|
|
886
962
|
let useCardMode = dingtalkConfig.messageType === "card";
|
|
887
963
|
let currentAICard: import("./types").AICardInstance | undefined;
|
|
964
|
+
let questionCardTookOver = false;
|
|
888
965
|
|
|
889
966
|
let cardFlightKey: string | undefined;
|
|
967
|
+
if (questionContext) {
|
|
968
|
+
questionContext.onQuestionCardSent = async ({ questionId, outTrackId }) => {
|
|
969
|
+
questionCardTookOver = true;
|
|
970
|
+
if (cardFlightKey) {
|
|
971
|
+
cardCreationInFlight.delete(cardFlightKey);
|
|
972
|
+
cardFlightKey = undefined;
|
|
973
|
+
}
|
|
974
|
+
try {
|
|
975
|
+
await dispatchDingTalkCardStopCommand({
|
|
976
|
+
cfg,
|
|
977
|
+
accountId,
|
|
978
|
+
agentId: route.agentId,
|
|
979
|
+
targetSessionKey: route.sessionKey,
|
|
980
|
+
clickerUserId: senderId || "unknown",
|
|
981
|
+
log,
|
|
982
|
+
});
|
|
983
|
+
log?.info?.(
|
|
984
|
+
`[DingTalk][AskUser] Dispatched targeted stop after question card sent question=${questionId} outTrackId=${outTrackId} targetSessionKey=${route.sessionKey}`,
|
|
985
|
+
);
|
|
986
|
+
} catch (err) {
|
|
987
|
+
log?.warn?.(
|
|
988
|
+
`[DingTalk][AskUser] Question card sent, but targeted stop failed question=${questionId} outTrackId=${outTrackId} targetSessionKey=${route.sessionKey}: ${err instanceof Error ? err.message : String(err)}`,
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
if (!currentAICard) {
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
if (isCardInTerminalState(currentAICard.state)) {
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
const recalled = await recallAICardMessage(currentAICard, log);
|
|
998
|
+
if (!recalled) {
|
|
999
|
+
log?.warn?.(
|
|
1000
|
+
`[DingTalk][AskUser] Question card sent, but AI card recall failed; normal replies remain suppressed question=${questionId} outTrackId=${outTrackId}`,
|
|
1001
|
+
);
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
log?.info?.(
|
|
1005
|
+
`[DingTalk][AskUser] Recalled empty AI card after question card sent question=${questionId} outTrackId=${outTrackId}`,
|
|
1006
|
+
);
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
|
|
890
1010
|
if (useCardMode && !isBtwBypass) {
|
|
891
1011
|
const key = `${accountId}:${to}`;
|
|
892
1012
|
if (cardCreationInFlight.has(key)) {
|
|
@@ -1497,6 +1617,10 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1497
1617
|
const inboundText = attachmentExtractedText
|
|
1498
1618
|
? `${inboundBody.trimEnd()}\n\n${attachmentExtractedText}`
|
|
1499
1619
|
: inboundBody;
|
|
1620
|
+
// Targeted slash commands (`@agent /new`) pass the @mention-stripped command
|
|
1621
|
+
// text as CommandBody so the framework command layer recognizes it, while
|
|
1622
|
+
// RawBody keeps the user's original input for audit/quote display.
|
|
1623
|
+
const commandBody = subAgentOptions?.commandText ?? inboundText;
|
|
1500
1624
|
const learningEnabled = isLearningEnabled(dingtalkConfig);
|
|
1501
1625
|
const learningContextBlock = buildLearningContextBlock({
|
|
1502
1626
|
enabled: learningEnabled,
|
|
@@ -1546,7 +1670,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1546
1670
|
const ctx = rt.channel.reply.finalizeInboundContext({
|
|
1547
1671
|
Body: body,
|
|
1548
1672
|
RawBody: inboundText,
|
|
1549
|
-
CommandBody:
|
|
1673
|
+
CommandBody: commandBody,
|
|
1550
1674
|
QuotedRef: quotedRef,
|
|
1551
1675
|
QuotedRefJson: quotedRef ? JSON.stringify(quotedRef) : undefined,
|
|
1552
1676
|
ReplyToId: quotedRuntimeContext?.replyToId,
|
|
@@ -2114,11 +2238,18 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
2114
2238
|
const inlineReplyPayload = parseInlineReplyPayloadText(payload.text);
|
|
2115
2239
|
const mediaUrls = extractMediaUrls(payload, inlineReplyPayload);
|
|
2116
2240
|
const richPayload = payload as ReplyStreamPayload & { isReasoning?: boolean };
|
|
2241
|
+
const replyKind = (info?.kind as DeliverPayload["kind"]) || "block";
|
|
2242
|
+
if (questionCardTookOver) {
|
|
2243
|
+
log?.info?.(
|
|
2244
|
+
`[DingTalk][AskUser] Suppressed ${replyKind} reply after question card took over`,
|
|
2245
|
+
);
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2117
2248
|
await strategy.deliver({
|
|
2118
2249
|
text: inlineReplyPayload.text,
|
|
2119
2250
|
mediaUrls,
|
|
2120
2251
|
audioAsVoice: extractSharedAudioAsVoice(payload, inlineReplyPayload),
|
|
2121
|
-
kind:
|
|
2252
|
+
kind: replyKind,
|
|
2122
2253
|
isReasoning: richPayload.isReasoning === true,
|
|
2123
2254
|
});
|
|
2124
2255
|
} catch (err: unknown) {
|
|
@@ -2163,13 +2294,19 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
2163
2294
|
typeof bufferedFinalPayload.text === "string" &&
|
|
2164
2295
|
bufferedFinalPayload.text.trim().length > 0;
|
|
2165
2296
|
if (hasBufferedText || mediaUrls.length > 0) {
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2297
|
+
if (questionCardTookOver) {
|
|
2298
|
+
log?.info?.(
|
|
2299
|
+
"[DingTalk][AskUser] Suppressed buffered final after question card took over",
|
|
2300
|
+
);
|
|
2301
|
+
} else {
|
|
2302
|
+
await strategy.deliver({
|
|
2303
|
+
text: inlineReplyPayload.text,
|
|
2304
|
+
mediaUrls,
|
|
2305
|
+
audioAsVoice: extractSharedAudioAsVoice(bufferedFinalPayload, inlineReplyPayload),
|
|
2306
|
+
kind: "final",
|
|
2307
|
+
isReasoning: bufferedFinalPayload.isReasoning === true,
|
|
2308
|
+
});
|
|
2309
|
+
}
|
|
2173
2310
|
}
|
|
2174
2311
|
}
|
|
2175
2312
|
} catch (dispatchErr: unknown) {
|
|
@@ -2179,6 +2316,12 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
2179
2316
|
throw dispatchErr;
|
|
2180
2317
|
}
|
|
2181
2318
|
|
|
2319
|
+
if (questionCardTookOver) {
|
|
2320
|
+
log?.info?.(
|
|
2321
|
+
"[DingTalk][AskUser] Skipping normal AI card finalize after question card took over",
|
|
2322
|
+
);
|
|
2323
|
+
return;
|
|
2324
|
+
}
|
|
2182
2325
|
await strategy.finalize();
|
|
2183
2326
|
} finally {
|
|
2184
2327
|
// Only remove the registry entry if no stop was requested. When a stop is
|
|
@@ -133,6 +133,8 @@ export function createCardReplyStrategy(
|
|
|
133
133
|
let latestReasoningSnapshot = "";
|
|
134
134
|
/** Non-image media attachments deferred for out-of-card delivery. */
|
|
135
135
|
let pendingNonImageMedia: DeferredMedia[] = [];
|
|
136
|
+
/** URLs already processed in this card session — prevents duplicate upload/send when the same mediaUrl appears in both a non-final and final deliver. */
|
|
137
|
+
const processedMediaUrls = new Set<string>();
|
|
136
138
|
|
|
137
139
|
const getRenderedTimeline = (options: { preferFinalAnswer?: boolean } = {}): string => {
|
|
138
140
|
const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ? EMPTY_FINAL_REPLY : undefined);
|
|
@@ -345,6 +347,12 @@ export function createCardReplyStrategy(
|
|
|
345
347
|
continue;
|
|
346
348
|
}
|
|
347
349
|
|
|
350
|
+
if (processedMediaUrls.has(candidate.url.trim())) {
|
|
351
|
+
const placeholder = buildImagePlaceholderText({ alt: candidate.alt, url: candidate.url });
|
|
352
|
+
nextText = `${nextText.slice(0, candidate.start)}${placeholder}${nextText.slice(candidate.end)}`;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
|
|
348
356
|
let prepared: Awaited<ReturnType<typeof prepareMediaInput>> | undefined;
|
|
349
357
|
try {
|
|
350
358
|
prepared = await prepareMediaInput(candidate.url, log, config.mediaUrlAllowlist);
|
|
@@ -361,6 +369,7 @@ export function createCardReplyStrategy(
|
|
|
361
369
|
continue;
|
|
362
370
|
}
|
|
363
371
|
|
|
372
|
+
processedMediaUrls.add(candidate.url.trim());
|
|
364
373
|
const placeholder = buildImagePlaceholderText({ alt: candidate.alt, url: candidate.url });
|
|
365
374
|
const blockText = candidate.alt.trim() || placeholder.replace(/^见下图/, "").trim() || "图片";
|
|
366
375
|
successfulReroutes.push({
|
|
@@ -477,6 +486,10 @@ export function createCardReplyStrategy(
|
|
|
477
486
|
// Inline media upload → image blocks in card; defer non-image attachments
|
|
478
487
|
if (payload.mediaUrls.length > 0) {
|
|
479
488
|
for (const url of payload.mediaUrls) {
|
|
489
|
+
const normalizedUrl = url.trim();
|
|
490
|
+
if (processedMediaUrls.has(normalizedUrl)) {
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
480
493
|
try {
|
|
481
494
|
const prepared = await prepareMediaInput(url, log, config.mediaUrlAllowlist);
|
|
482
495
|
const mediaType = resolveOutboundMediaType({ mediaPath: prepared.path, asVoice: false });
|
|
@@ -492,6 +505,7 @@ export function createCardReplyStrategy(
|
|
|
492
505
|
const result = await uploadMedia(config, prepared.path, "image", log);
|
|
493
506
|
await prepared.cleanup?.();
|
|
494
507
|
if (result?.mediaId) {
|
|
508
|
+
processedMediaUrls.add(normalizedUrl);
|
|
495
509
|
await controller.appendImageBlock(result.mediaId);
|
|
496
510
|
}
|
|
497
511
|
} catch (err: unknown) {
|
|
@@ -555,6 +569,10 @@ export function createCardReplyStrategy(
|
|
|
555
569
|
// ---- block: only handle reasoning/media (other text blocks are unused) ----
|
|
556
570
|
if (payload.mediaUrls.length > 0) {
|
|
557
571
|
for (const url of payload.mediaUrls) {
|
|
572
|
+
const normalizedUrl = url.trim();
|
|
573
|
+
if (processedMediaUrls.has(normalizedUrl)) {
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
558
576
|
try {
|
|
559
577
|
const prepared = await prepareMediaInput(url, log, config.mediaUrlAllowlist);
|
|
560
578
|
const mediaType = resolveOutboundMediaType({ mediaPath: prepared.path, asVoice: false });
|
|
@@ -567,6 +585,7 @@ export function createCardReplyStrategy(
|
|
|
567
585
|
const result = await uploadMedia(config, prepared.path, "image", log);
|
|
568
586
|
await prepared.cleanup?.();
|
|
569
587
|
if (result?.mediaId) {
|
|
588
|
+
processedMediaUrls.add(normalizedUrl);
|
|
570
589
|
await controller.appendImageBlock(result.mediaId);
|
|
571
590
|
}
|
|
572
591
|
} catch (err: unknown) {
|