diff --git a/extensions/discord/src/monitor/inbound-job.test.ts b/extensions/discord/src/monitor/inbound-job.test.ts index e2b4882796b1..ef4bfdb46e9f 100644 --- a/extensions/discord/src/monitor/inbound-job.test.ts +++ b/extensions/discord/src/monitor/inbound-job.test.ts @@ -72,6 +72,13 @@ describe("buildDiscordInboundJob", () => { }, ownerId: "user-1", }, + preparedMedia: [ + { + path: "/tmp/openclaw-discord-test/photo.png", + contentType: "image/png", + placeholder: "", + }, + ], }); const job = buildDiscordInboundJob(ctx); @@ -95,6 +102,7 @@ describe("buildDiscordInboundJob", () => { ownerId: "user-1", }); const serializedPayload = jsonRoundTrip(job.payload); + expect(serializedPayload.preparedMedia).toEqual(ctx.preparedMedia); expect(serializedPayload.threadChannel).toEqual({ id: "thread-1", name: "codex", diff --git a/extensions/discord/src/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts index fa0841149083..208845dfdde8 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -569,6 +569,46 @@ describe("preflightDiscordMessage", () => { expect(preflight.preflightAudioTranscript).toBe("hello openclaw from dm audio"); }); + it("downloads attachments during preflight, before the message reaches the run queue", async () => { + // Regression for #96165: Discord CDN attachment URLs expire. Downloading + // must happen at receipt time (preflight), not after a possible run-queue + // delay, or queued messages lose their media. + const result = await runDmPreflight({ + channelId: "dm-channel-image-1", + message: createDiscordMessage({ + id: "m-dm-image-1", + channelId: "dm-channel-image-1", + content: "look at this", + attachments: [ + { + id: "att-dm-image-1", + url: "https://cdn.discordapp.com/attachments/1/photo.png?ex=expired", + content_type: "image/png", + filename: "photo.png", + }, + ], + author: { + id: "user-1", + bot: false, + username: "alice", + }, + }), + discordConfig: { + dmPolicy: "open", + } as DiscordConfig, + }); + + expect(saveRemoteMediaMock).toHaveBeenCalledTimes(1); + const preflight = expectPreflightResult(result); + expect(preflight.preparedMedia).toEqual([ + { + path: "/tmp/openclaw-discord-test/photo.png", + contentType: "image/png", + placeholder: "", + }, + ]); + }); + it("keeps no-guild messages direct when channel lookup is unavailable", async () => { const result = await runUnresolvedDmPreflight({ cfg: { @@ -654,7 +694,7 @@ describe("preflightDiscordMessage", () => { ).toBe("default"); }); - it("passes bot-loop protection facts for accepted bot-authored Discord messages (#58789)", async () => { + it("suppresses repeated bot messages before downloading attachments (#58789)", async () => { const channelId = "channel-bot-loop"; const guildId = "guild-bot-loop"; const senderBotId = "relay-bot-1"; @@ -675,7 +715,7 @@ describe("preflightDiscordMessage", () => { allowBots: true, botLoopProtection: { enabled: true, - maxEventsPerWindow: 3, + maxEventsPerWindow: 1, cooldownSeconds: 60, }, } as DiscordConfig, @@ -689,58 +729,77 @@ describe("preflightDiscordMessage", () => { }), ); - expect(expectPreflightResult(result).botLoopProtection).toEqual({ - scopeId: "default", - conversationId: channelId, - senderId: senderBotId, - receiverId: "openclaw-bot", - config: { - enabled: true, - maxEventsPerWindow: 3, - cooldownSeconds: 60, - }, - defaultsConfig: undefined, - defaultEnabled: true, - nowMs: Date.parse(messageTimestamp), + expect(result).not.toBeNull(); + + const repeatedMessage = createDiscordMessage({ + id: "m-loop-2", + channelId, + content: "more chatter <@openclaw-bot>", + mentionedUsers: [{ id: "openclaw-bot" }], + attachments: [ + { + id: "att-loop", + url: "https://cdn.discordapp.com/attachments/1/loop.png", + content_type: "image/png", + filename: "loop.png", + }, + ], + author: { id: senderBotId, bot: true, username: "Relay" }, + timestamp: "2026-05-13T05:00:00.001Z", }); + + expect( + await runGuildPreflight({ + channelId, + guildId, + message: repeatedMessage, + discordConfig: { + allowBots: true, + botLoopProtection: { + enabled: true, + maxEventsPerWindow: 1, + cooldownSeconds: 60, + }, + } as DiscordConfig, + }), + ).toBeNull(); + expect(saveRemoteMediaMock).not.toHaveBeenCalled(); }); it("passes generic channel defaults for Discord bot loop budgets", async () => { const channelId = "channel-bot-loop-defaults"; const guildId = "guild-bot-loop-defaults"; const discordConfig = { allowBots: true } as DiscordConfig; - const message = createDiscordMessage({ - id: "m-loop-default-1", - channelId, - content: "relay <@openclaw-bot>", - mentionedUsers: [{ id: "openclaw-bot" }], - author: { id: "relay-bot-defaults", bot: true, username: "Relay" }, - }); - const result = await runGuildPreflight({ - channelId, - guildId, - message, - discordConfig, - cfg: { - ...DEFAULT_PREFLIGHT_CFG, - channels: { - defaults: { - botLoopProtection: { - maxEventsPerWindow: 1, - cooldownSeconds: 60, + const runBotMessage = async (id: string) => + await runGuildPreflight({ + channelId, + guildId, + message: createDiscordMessage({ + id, + channelId, + content: "relay <@openclaw-bot>", + mentionedUsers: [{ id: "openclaw-bot" }], + author: { id: "relay-bot-defaults", bot: true, username: "Relay" }, + }), + discordConfig, + cfg: { + ...DEFAULT_PREFLIGHT_CFG, + channels: { + defaults: { + botLoopProtection: { + maxEventsPerWindow: 1, + cooldownSeconds: 60, + }, }, }, }, - }, - }); + }); - expect(expectPreflightResult(result).botLoopProtection?.defaultsConfig).toEqual({ - maxEventsPerWindow: 1, - cooldownSeconds: 60, - }); + expect(await runBotMessage("m-loop-default-1")).not.toBeNull(); + expect(await runBotMessage("m-loop-default-2")).toBeNull(); }); - it("does not prepare loop-guard facts for bot messages that later preflight gates drop (#58789)", async () => { + it("does not count bot messages that earlier preflight gates drop (#58789)", async () => { const channelId = "channel-bot-loop-dropped"; const guildId = "guild-bot-loop-dropped"; const senderBotId = "relay-bot-dropped"; diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index 4f50847997bf..41a4916c6d80 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -5,6 +5,7 @@ import { buildMentionRegexes, classifyChannelInboundEvent, logInboundDrop, + recordChannelBotPairLoopAndCheckSuppression, resolveInboundMentionDecision, resolveUnmentionedGroupInboundPolicy, recordDroppedChannelInboundHistory, @@ -64,9 +65,14 @@ import { resolveDiscordChannelInfo, resolveDiscordMessageChannelId, resolveDiscordMessageText, + resolveForwardedMediaList, resolveMediaList, } from "./message-utils.js"; import { resolveDiscordSenderIdentity, resolveDiscordWebhookId } from "./sender-identity.js"; +import { + DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS, + DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS, +} from "./timeouts.js"; export type { DiscordMessagePreflightContext, @@ -768,6 +774,38 @@ export async function preflightDiscordMessage( nowMs: resolveTimestampMs(message.timestamp), } : undefined; + if (botLoopProtection) { + const botLoopResult = recordChannelBotPairLoopAndCheckSuppression(botLoopProtection); + if (botLoopResult.suppressed) { + logVerbose( + `discord: bot-to-bot loop detected before media download, suppressing for ${Math.max(0, Math.ceil((botLoopResult.cooldownUntilMs - Date.now()) / 1000))}s`, + ); + return null; + } + } + + // Discord CDN attachment URLs expire; download now (receipt time) instead + // of after the run queue, which may delay processing past the URL TTL. + const mediaResolveOptions = { + fetchImpl: params.discordRestFetch, + ssrfPolicy: params.cfg.browser?.ssrfPolicy, + readIdleTimeoutMs: DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS, + totalTimeoutMs: DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS, + abortSignal: params.abortSignal, + }; + const preparedMedia = await resolveMediaList(message, params.mediaMaxBytes, mediaResolveOptions); + if (isPreflightAborted(params.abortSignal)) { + return null; + } + const forwardedMedia = await resolveForwardedMediaList( + message, + params.mediaMaxBytes, + mediaResolveOptions, + ); + if (isPreflightAborted(params.abortSignal)) { + return null; + } + preparedMedia.push(...forwardedMedia); logDebug( `[discord-preflight] success: route=${effectiveRoute.agentId} sessionKey=${effectiveRoute.sessionKey}`, @@ -791,6 +829,7 @@ export async function preflightDiscordMessage( baseText, messageText, ...(preflightTranscript !== undefined ? { preflightAudioTranscript: preflightTranscript } : {}), + preparedMedia, wasMentioned, route: effectiveRoute, threadBinding, @@ -821,6 +860,5 @@ export async function preflightDiscordMessage( inboundEventKind, canDetectMention, historyEntry, - botLoopProtection, }); } diff --git a/extensions/discord/src/monitor/message-handler.preflight.types.ts b/extensions/discord/src/monitor/message-handler.preflight.types.ts index 28016bd372bf..9ddf776c5e95 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.types.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.types.ts @@ -1,13 +1,12 @@ // Discord type declarations define plugin contracts. import type { InboundEventKind } from "openclaw/plugin-sdk/channel-inbound"; -import type { ChannelBotLoopProtectionFacts } from "openclaw/plugin-sdk/channel-inbound"; import type { OpenClawConfig, ReplyToMode } from "openclaw/plugin-sdk/config-contracts"; import type { SessionBindingRecord } from "openclaw/plugin-sdk/conversation-runtime"; import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import type { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import type { ChannelType, Client, User } from "../internal/discord.js"; import type { DiscordChannelConfigResolved, DiscordGuildEntryResolved } from "./allow-list.js"; -import type { DiscordChannelInfo } from "./message-utils.js"; +import type { DiscordChannelInfo, DiscordMediaInfo } from "./message-utils.js"; import type { DiscordThreadBindingLookup } from "./reply-delivery.js"; import type { DiscordReplyTypingFeedback } from "./reply-typing-feedback.js"; import type { DiscordSenderIdentity } from "./sender-identity.js"; @@ -60,6 +59,9 @@ export type DiscordMessagePreflightContext = DiscordMessagePreflightSharedFields baseText: string; messageText: string; preflightAudioTranscript?: string; + // Keep one required receipt-time snapshot: queued processing must never + // fall back to Discord's expiring attachment URLs. + preparedMedia: DiscordMediaInfo[]; wasMentioned: boolean; route: ReturnType; @@ -100,7 +102,6 @@ export type DiscordMessagePreflightContext = DiscordMessagePreflightSharedFields threadBindings: DiscordThreadBindingLookup; replyTypingFeedback?: DiscordReplyTypingFeedback; discordRestFetch?: typeof fetch; - botLoopProtection?: ChannelBotLoopProtectionFacts; }; export type DiscordMessagePreflightParams = DiscordMessagePreflightSharedFields & { diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 9fe9060b48f4..08cdfe022dc3 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -1,9 +1,5 @@ // Discord tests cover message handler.process plugin behavior. import { DEFAULT_EMOJIS, DEFAULT_TIMING } from "openclaw/plugin-sdk/channel-feedback"; -import { - recordChannelBotPairLoopAndCheckSuppression, - type ChannelBotLoopProtectionFacts, -} from "openclaw/plugin-sdk/channel-inbound"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime"; import { setReplyPayloadMetadata } from "openclaw/plugin-sdk/reply-payload-testing"; import * as runtimeEnvModule from "openclaw/plugin-sdk/runtime-env"; @@ -754,55 +750,6 @@ function expectFreshFinalText(text: string) { } describe("processDiscordMessage ack reactions", () => { - it("drops bot-loop-suppressed messages before Discord side effects", async () => { - const botLoopProtection: ChannelBotLoopProtectionFacts = { - scopeId: "discord-process-side-effect-test", - conversationId: "c-loop-side-effects", - senderId: "bot-a", - receiverId: "bot-b", - config: { - maxEventsPerWindow: 1, - windowSeconds: 60, - cooldownSeconds: 60, - }, - defaultEnabled: true, - nowMs: 10_000, - }; - expect(recordChannelBotPairLoopAndCheckSuppression(botLoopProtection)).toEqual({ - suppressed: false, - }); - const observer = { onReplyPlanResolved: vi.fn() }; - const ctx = await createAutomaticSourceDeliveryContext({ - messageChannelId: botLoopProtection.conversationId, - message: { - id: "m-loop-side-effects", - channelId: botLoopProtection.conversationId, - timestamp: new Date().toISOString(), - attachments: [ - { - id: "att-loop", - url: "https://cdn.discordapp.test/loop.png", - contentType: "image/png", - filename: "loop.png", - size: 16, - }, - ], - }, - botLoopProtection: { - ...botLoopProtection, - nowMs: 10_001, - }, - }); - - await processDiscordMessage(ctx, observer); - - expect(observer.onReplyPlanResolved).not.toHaveBeenCalled(); - expect(createDiscordRestClientSpy).not.toHaveBeenCalled(); - expect(sendMocks.reactMessageDiscord).not.toHaveBeenCalled(); - expect(recordInboundSession).not.toHaveBeenCalled(); - expect(dispatchInboundMessage).not.toHaveBeenCalled(); - }); - it("skips ack reactions for group-mentions when mentions are not required", async () => { const ctx = await createBaseContext({ shouldRequireMention: false, @@ -1323,12 +1270,6 @@ describe("processDiscordMessage ack reactions", () => { describe("processDiscordMessage session routing", () => { it("carries preflight audio transcript into dispatch context and marks media transcribed", async () => { - const fetchImpl = vi.fn( - async () => - new Response(new Uint8Array([1, 2, 3, 4]), { - headers: { "content-type": "audio/ogg" }, - }), - ); const ctx = await createBaseContext({ message: { id: "m-audio-preflight", @@ -1347,8 +1288,13 @@ describe("processDiscordMessage session routing", () => { baseText: "", messageText: "", preflightAudioTranscript: "hello from discord voice", - discordRestFetch: fetchImpl, - mediaMaxBytes: 1024 * 1024, + preparedMedia: [ + { + path: "/tmp/openclaw-discord-test/voice.ogg", + contentType: "audio/ogg", + placeholder: "", + }, + ], }); await runProcessDiscordMessage(ctx); @@ -1361,6 +1307,50 @@ describe("processDiscordMessage session routing", () => { }); }); + it("uses prepared media instead of re-downloading after the run queue", async () => { + // Regression for #96165: Discord CDN attachment URLs expire, so process + // must not re-fetch attachments preflight already downloaded at receipt + // time. A throwing fetchImpl here proves no re-fetch happens. + const fetchImpl = vi.fn(async () => { + throw new Error("attachment should not be re-fetched after preflight downloaded it"); + }); + const ctx = await createBaseContext({ + message: { + id: "m-preflight-media", + channelId: "c1", + content: "look", + timestamp: new Date().toISOString(), + attachments: [ + { + id: "att-preflight-media", + url: "https://cdn.discordapp.com/attachments/1/photo.png?ex=expired", + content_type: "image/png", + filename: "photo.png", + }, + ], + }, + baseText: "look", + messageText: "look", + preparedMedia: [ + { + path: "/tmp/openclaw-discord-test/photo.png", + contentType: "image/png", + placeholder: "", + }, + ], + discordRestFetch: fetchImpl, + }); + + await runProcessDiscordMessage(ctx); + + expect(fetchImpl).not.toHaveBeenCalled(); + expectRecordFields(requireRecord(getLastDispatchCtx(), "dispatch context"), { + MediaPath: "/tmp/openclaw-discord-test/photo.png", + MediaType: "image/png", + MediaPaths: ["/tmp/openclaw-discord-test/photo.png"], + }); + }); + it("does not attach referenced reply media when reply context is hidden", async () => { const fetchImpl = vi.fn(async () => { throw new Error("hidden reply media should not be fetched"); diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index 226b5e571b69..60d85a0971c1 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -11,7 +11,6 @@ import { import { dispatchChannelInboundReply, hasFinalInboundReplyDispatch, - recordChannelBotPairLoopAndCheckSuppression, } from "openclaw/plugin-sdk/channel-inbound"; import { createChannelMessageReplyPipeline, @@ -59,14 +58,9 @@ import { import { buildDiscordMessageProcessContext } from "./message-handler.context.js"; import { createDiscordDraftPreviewController } from "./message-handler.draft-preview.js"; import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js"; -import { resolveForwardedMediaList, resolveMediaList } from "./message-utils.js"; import { deliverDiscordReply } from "./reply-delivery.js"; import { sanitizeDiscordFrontChannelReplyPayloads } from "./reply-safety.js"; import { createDiscordReplyTypingFeedback } from "./reply-typing-feedback.js"; -import { - DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS, - DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS, -} from "./timeouts.js"; const loadReplyRuntime = createLazyRuntimeModule(() => import("openclaw/plugin-sdk/reply-runtime")); const TARGETED_ONLY_ALLOWED_MENTIONS = { @@ -162,7 +156,6 @@ async function processDiscordMessageInner( runtime, guildHistories, historyLimit, - mediaMaxBytes, textLimit, replyToMode, ackReactionScope, @@ -179,45 +172,13 @@ async function processDiscordMessageInner( channelConfig, threadBindings, route, - discordRestFetch, abortSignal, - botLoopProtection, replyTypingFeedback, + preparedMedia: mediaList, } = ctx; if (isProcessAborted(abortSignal)) { return; } - if (botLoopProtection) { - const botLoopResult = recordChannelBotPairLoopAndCheckSuppression(botLoopProtection); - if (botLoopResult.suppressed) { - logVerbose( - `discord: bot-to-bot loop detected before dispatch setup, suppressing for ${Math.max(0, Math.ceil((botLoopResult.cooldownUntilMs - Date.now()) / 1000))}s`, - ); - return; - } - } - - const ssrfPolicy = cfg.browser?.ssrfPolicy; - const mediaResolveOptions = { - fetchImpl: discordRestFetch, - ssrfPolicy, - readIdleTimeoutMs: DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS, - totalTimeoutMs: DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS, - abortSignal, - }; - const mediaList = await resolveMediaList(message, mediaMaxBytes, mediaResolveOptions); - if (isProcessAborted(abortSignal)) { - return; - } - const forwardedMediaList = await resolveForwardedMediaList( - message, - mediaMaxBytes, - mediaResolveOptions, - ); - if (isProcessAborted(abortSignal)) { - return; - } - mediaList.push(...forwardedMediaList); const text = messageText; if (!text) { logVerbose("discord: drop message " + message.id + " (empty content)"); diff --git a/extensions/discord/src/monitor/message-handler.test-harness.ts b/extensions/discord/src/monitor/message-handler.test-harness.ts index 67d52e4d691a..960589a97bfc 100644 --- a/extensions/discord/src/monitor/message-handler.test-harness.ts +++ b/extensions/discord/src/monitor/message-handler.test-harness.ts @@ -47,6 +47,7 @@ export async function createBaseDiscordMessageContext( commandAuthorized: true, baseText: "hi", messageText: "hi", + preparedMedia: [], wasMentioned: false, shouldRequireMention: true, canDetectMention: true,