fix(discord): download attachments at receipt time, not after the run queue (#96183)

* fix(discord): download attachments at receipt time, not after the run queue

Discord's CDN attachment URLs carry an expiring `ex` TTL. Media was
downloaded in processDiscordMessageInner, after the inbound run queue,
so messages delayed behind a busy run lost their attachments silently.
Resolve attachments/forwarded media during preflightDiscordMessage
instead, before the message is enqueued, and carry the result forward
on the context for process to reuse.

Fixes #96165

* refactor(discord): carry one prepared media snapshot

Co-authored-by: ZacharyYW <zachary.w.yuan123@gmail.com>

* fix(discord): guard bot media before download

* chore(discord): leave release notes to release flow

* docs(changelog): credit Discord attachment fix

* chore(changelog): leave attachment fix to release flow

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Zachary Yuan
2026-07-06 18:45:32 -04:00
committed by GitHub
parent 68c85effcd
commit 01f0b2340f
7 changed files with 204 additions and 146 deletions
@@ -72,6 +72,13 @@ describe("buildDiscordInboundJob", () => {
},
ownerId: "user-1",
},
preparedMedia: [
{
path: "/tmp/openclaw-discord-test/photo.png",
contentType: "image/png",
placeholder: "<media:image>",
},
],
});
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",
@@ -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: "<media:image>",
},
]);
});
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";
@@ -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,
});
}
@@ -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<typeof resolveAgentRoute>;
@@ -100,7 +102,6 @@ export type DiscordMessagePreflightContext = DiscordMessagePreflightSharedFields
threadBindings: DiscordThreadBindingLookup;
replyTypingFeedback?: DiscordReplyTypingFeedback;
discordRestFetch?: typeof fetch;
botLoopProtection?: ChannelBotLoopProtectionFacts;
};
export type DiscordMessagePreflightParams = DiscordMessagePreflightSharedFields & {
@@ -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: "<media:audio>",
messageText: "<media:audio>",
preflightAudioTranscript: "hello from discord voice",
discordRestFetch: fetchImpl,
mediaMaxBytes: 1024 * 1024,
preparedMedia: [
{
path: "/tmp/openclaw-discord-test/voice.ogg",
contentType: "audio/ogg",
placeholder: "<media:audio>",
},
],
});
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: "<media:image>",
},
],
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");
@@ -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)");
@@ -47,6 +47,7 @@ export async function createBaseDiscordMessageContext(
commandAuthorized: true,
baseText: "hi",
messageText: "hi",
preparedMedia: [],
wasMentioned: false,
shouldRequireMention: true,
canDetectMention: true,