From 0eef7a367dc371980cdc8d5cdf2d637aa6882b00 Mon Sep 17 00:00:00 2001 From: Shadow Date: Tue, 3 Mar 2026 11:10:26 -0600 Subject: [PATCH] fix(discord): honor agent media roots in replies --- CHANGELOG.md | 1 + src/discord/monitor/agent-components.ts | 3 ++ .../monitor/message-handler.process.ts | 3 ++ src/discord/monitor/reply-delivery.test.ts | 39 +++++++++++++++++++ src/discord/monitor/reply-delivery.ts | 6 +++ 5 files changed, 52 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 089cdd98ece..5392265ae5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Docs/tool-loop detection config keys: align `docs/tools/loop-detection.md` examples and field names with the current `tools.loopDetection` schema to prevent copy-paste validation failures from outdated keys. (#33182) Thanks @Mylszd. - Discord/inbound debouncer: skip bot-own MESSAGE_CREATE events before they reach the debounce queue to avoid self-triggered slowdowns in busy servers. Thanks @thewilloftheshadow. +- Discord/Agent-scoped media roots: pass `mediaLocalRoots` through Discord monitor reply delivery (message + component interaction paths) so local media attachments honor per-agent workspace roots instead of falling back to default global roots. Thanks @thewilloftheshadow. - Discord/presence defaults: send an online presence update on ready when no custom presence is configured so bots no longer appear offline by default. Thanks @thewilloftheshadow. - Discord/typing cleanup: stop typing indicators after silent/NO_REPLY runs by marking the run complete before dispatch idle cleanup. Thanks @thewilloftheshadow. - Discord/voice messages: request upload slots with JSON fetch calls so voice message uploads no longer fail with content-type errors. Thanks @thewilloftheshadow. diff --git a/src/discord/monitor/agent-components.ts b/src/discord/monitor/agent-components.ts index a6bceae7ff5..ecf7325338a 100644 --- a/src/discord/monitor/agent-components.ts +++ b/src/discord/monitor/agent-components.ts @@ -34,6 +34,7 @@ import type { DiscordAccountConfig } from "../../config/types.discord.js"; import { logVerbose } from "../../globals.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { logDebug, logError } from "../../logger.js"; +import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js"; import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; @@ -976,6 +977,7 @@ async function dispatchDiscordComponentEvent(params: { fallbackLimit: 2000, }); const token = ctx.token ?? ""; + const mediaLocalRoots = getAgentScopedMediaLocalRoots(ctx.cfg, agentId); const replyToMode = ctx.discordConfig?.replyToMode ?? ctx.cfg.channels?.discord?.replyToMode ?? "off"; const replyReference = createReplyReferencePlanner({ @@ -1005,6 +1007,7 @@ async function dispatchDiscordComponentEvent(params: { maxLinesPerMessage: ctx.discordConfig?.maxLinesPerMessage, tableMode, chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId), + mediaLocalRoots, }); replyReference.markSent(); }, diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 34473614b38..81d8df5fbed 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -27,6 +27,7 @@ import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; import { convertMarkdownTables } from "../../markdown/tables.js"; +import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { buildAgentSessionKey } from "../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../routing/session-key.js"; import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js"; @@ -128,6 +129,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) accountId, }); const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; + const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId); const shouldAckReaction = () => Boolean( ackReaction && @@ -668,6 +670,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) chunkMode, sessionKey: ctxPayload.SessionKey, threadBindings, + mediaLocalRoots, }); replyReference.markSent(); }, diff --git a/src/discord/monitor/reply-delivery.test.ts b/src/discord/monitor/reply-delivery.test.ts index ff8e94db2ea..3274a669cf2 100644 --- a/src/discord/monitor/reply-delivery.test.ts +++ b/src/discord/monitor/reply-delivery.test.ts @@ -135,6 +135,45 @@ describe("deliverDiscordReply", () => { expect(sendMessageDiscordMock).not.toHaveBeenCalled(); }); + it("passes mediaLocalRoots through media sends", async () => { + const mediaLocalRoots = ["/tmp/workspace-agent"] as const; + await deliverDiscordReply({ + replies: [ + { + text: "Media reply", + mediaUrls: ["https://example.com/first.png", "https://example.com/second.png"], + }, + ], + target: "channel:654", + token: "token", + runtime, + textLimit: 2000, + mediaLocalRoots, + }); + + expect(sendMessageDiscordMock).toHaveBeenCalledTimes(2); + expect(sendMessageDiscordMock).toHaveBeenNthCalledWith( + 1, + "channel:654", + "Media reply", + expect.objectContaining({ + token: "token", + mediaUrl: "https://example.com/first.png", + mediaLocalRoots, + }), + ); + expect(sendMessageDiscordMock).toHaveBeenNthCalledWith( + 2, + "channel:654", + "", + expect.objectContaining({ + token: "token", + mediaUrl: "https://example.com/second.png", + mediaLocalRoots, + }), + ); + }); + it("uses replyToId only for the first chunk when replyToMode is first", async () => { await deliverDiscordReply({ replies: [ diff --git a/src/discord/monitor/reply-delivery.ts b/src/discord/monitor/reply-delivery.ts index 71444fdb68d..11fc1733ef1 100644 --- a/src/discord/monitor/reply-delivery.ts +++ b/src/discord/monitor/reply-delivery.ts @@ -192,6 +192,7 @@ async function sendAdditionalDiscordMedia(params: { rest?: RequestClient; accountId?: string; mediaUrls: string[]; + mediaLocalRoots?: readonly string[]; resolveReplyTo: () => string | undefined; retryConfig: ResolvedRetryConfig; }) { @@ -204,6 +205,7 @@ async function sendAdditionalDiscordMedia(params: { rest: params.rest, mediaUrl, accountId: params.accountId, + mediaLocalRoots: params.mediaLocalRoots, replyTo, }), params.retryConfig, @@ -226,6 +228,7 @@ export async function deliverDiscordReply(params: { chunkMode?: ChunkMode; sessionKey?: string; threadBindings?: DiscordThreadBindingLookup; + mediaLocalRoots?: readonly string[]; }) { const chunkLimit = Math.min(params.textLimit, 2000); const replyTo = params.replyToId?.trim() || undefined; @@ -341,6 +344,7 @@ export async function deliverDiscordReply(params: { rest: params.rest, accountId: params.accountId, mediaUrls: mediaList.slice(1), + mediaLocalRoots: params.mediaLocalRoots, resolveReplyTo, retryConfig, }); @@ -353,6 +357,7 @@ export async function deliverDiscordReply(params: { rest: params.rest, mediaUrl: firstMedia, accountId: params.accountId, + mediaLocalRoots: params.mediaLocalRoots, replyTo, }); deliveredAny = true; @@ -362,6 +367,7 @@ export async function deliverDiscordReply(params: { rest: params.rest, accountId: params.accountId, mediaUrls: mediaList.slice(1), + mediaLocalRoots: params.mediaLocalRoots, resolveReplyTo, retryConfig, });