@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.
@@ -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?.(`[${account.accountId}] DingTalk Stream client connected successfully`);
501
+ pluginLog?.info?.(
502
+ `[${account.accountId}] DingTalk Stream client connected successfully`,
503
+ );
504
504
  await nativeStopPromise;
505
505
  }
506
506
  } catch (err: any) {
@@ -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 { createAICard, commitAICardBlocks, isCardInTerminalState } from "./card-service";
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 in agent context.
584
- if (subAgentOptions) {
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
- const route = subAgentOptions
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
- sessionKey: buildAgentSessionKey({
779
- rt,
780
- cfg,
781
- accountId,
782
- agentId: subAgentOptions.agentId,
783
- peerKind: sessionPeer.kind,
784
- peerId: sessionPeer.peerId,
785
- }),
786
- mainSessionKey: "",
787
- }
788
- : rt.channel.routing.resolveAgentRoute({
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
- peer: { kind: sessionPeer.kind, id: sessionPeer.peerId },
855
+ data,
856
+ dingtalkConfig,
857
+ sessionWebhook,
858
+ extractedContent,
859
+ handleMessage: handleDingTalkMessage,
860
+ downloadMedia,
861
+ log,
793
862
  });
863
+ return;
864
+ }
794
865
 
795
- // @Sub-Agent routing: resolve @mentions to agents (skip in recursive sub-agent calls)
796
- if (!subAgentOptions) {
797
- const subAgentRoute = await resolveSubAgentRoute({
798
- extractedContent,
799
- cfg,
800
- isGroup,
801
- dingtalkConfig,
802
- sessionWebhook,
803
- senderId,
804
- log,
805
- });
806
- if (subAgentRoute) {
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
- ...subAgentRoute,
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: inboundText,
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: (info?.kind as DeliverPayload["kind"]) || "block",
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
- await strategy.deliver({
2167
- text: inlineReplyPayload.text,
2168
- mediaUrls,
2169
- audioAsVoice: extractSharedAudioAsVoice(bufferedFinalPayload, inlineReplyPayload),
2170
- kind: "final",
2171
- isReasoning: bufferedFinalPayload.isReasoning === true,
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) {