fix(discord): honor agent media roots in replies

This commit is contained in:
Shadow
2026-03-03 11:10:26 -06:00
parent 548b15d8e0
commit 0eef7a367d
5 changed files with 52 additions and 0 deletions

View File

@@ -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. - 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/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/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/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. - Discord/voice messages: request upload slots with JSON fetch calls so voice message uploads no longer fail with content-type errors. Thanks @thewilloftheshadow.

View File

@@ -34,6 +34,7 @@ import type { DiscordAccountConfig } from "../../config/types.discord.js";
import { logVerbose } from "../../globals.js"; import { logVerbose } from "../../globals.js";
import { enqueueSystemEvent } from "../../infra/system-events.js"; import { enqueueSystemEvent } from "../../infra/system-events.js";
import { logDebug, logError } from "../../logger.js"; import { logDebug, logError } from "../../logger.js";
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js";
import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js"; import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js";
@@ -976,6 +977,7 @@ async function dispatchDiscordComponentEvent(params: {
fallbackLimit: 2000, fallbackLimit: 2000,
}); });
const token = ctx.token ?? ""; const token = ctx.token ?? "";
const mediaLocalRoots = getAgentScopedMediaLocalRoots(ctx.cfg, agentId);
const replyToMode = const replyToMode =
ctx.discordConfig?.replyToMode ?? ctx.cfg.channels?.discord?.replyToMode ?? "off"; ctx.discordConfig?.replyToMode ?? ctx.cfg.channels?.discord?.replyToMode ?? "off";
const replyReference = createReplyReferencePlanner({ const replyReference = createReplyReferencePlanner({
@@ -1005,6 +1007,7 @@ async function dispatchDiscordComponentEvent(params: {
maxLinesPerMessage: ctx.discordConfig?.maxLinesPerMessage, maxLinesPerMessage: ctx.discordConfig?.maxLinesPerMessage,
tableMode, tableMode,
chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId), chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId),
mediaLocalRoots,
}); });
replyReference.markSent(); replyReference.markSent();
}, },

View File

@@ -27,6 +27,7 @@ import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
import { convertMarkdownTables } from "../../markdown/tables.js"; import { convertMarkdownTables } from "../../markdown/tables.js";
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
import { buildAgentSessionKey } from "../../routing/resolve-route.js"; import { buildAgentSessionKey } from "../../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../../routing/session-key.js"; import { resolveThreadSessionKeys } from "../../routing/session-key.js";
import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js"; import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js";
@@ -128,6 +129,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
accountId, accountId,
}); });
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
const shouldAckReaction = () => const shouldAckReaction = () =>
Boolean( Boolean(
ackReaction && ackReaction &&
@@ -668,6 +670,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
chunkMode, chunkMode,
sessionKey: ctxPayload.SessionKey, sessionKey: ctxPayload.SessionKey,
threadBindings, threadBindings,
mediaLocalRoots,
}); });
replyReference.markSent(); replyReference.markSent();
}, },

View File

@@ -135,6 +135,45 @@ describe("deliverDiscordReply", () => {
expect(sendMessageDiscordMock).not.toHaveBeenCalled(); 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 () => { it("uses replyToId only for the first chunk when replyToMode is first", async () => {
await deliverDiscordReply({ await deliverDiscordReply({
replies: [ replies: [

View File

@@ -192,6 +192,7 @@ async function sendAdditionalDiscordMedia(params: {
rest?: RequestClient; rest?: RequestClient;
accountId?: string; accountId?: string;
mediaUrls: string[]; mediaUrls: string[];
mediaLocalRoots?: readonly string[];
resolveReplyTo: () => string | undefined; resolveReplyTo: () => string | undefined;
retryConfig: ResolvedRetryConfig; retryConfig: ResolvedRetryConfig;
}) { }) {
@@ -204,6 +205,7 @@ async function sendAdditionalDiscordMedia(params: {
rest: params.rest, rest: params.rest,
mediaUrl, mediaUrl,
accountId: params.accountId, accountId: params.accountId,
mediaLocalRoots: params.mediaLocalRoots,
replyTo, replyTo,
}), }),
params.retryConfig, params.retryConfig,
@@ -226,6 +228,7 @@ export async function deliverDiscordReply(params: {
chunkMode?: ChunkMode; chunkMode?: ChunkMode;
sessionKey?: string; sessionKey?: string;
threadBindings?: DiscordThreadBindingLookup; threadBindings?: DiscordThreadBindingLookup;
mediaLocalRoots?: readonly string[];
}) { }) {
const chunkLimit = Math.min(params.textLimit, 2000); const chunkLimit = Math.min(params.textLimit, 2000);
const replyTo = params.replyToId?.trim() || undefined; const replyTo = params.replyToId?.trim() || undefined;
@@ -341,6 +344,7 @@ export async function deliverDiscordReply(params: {
rest: params.rest, rest: params.rest,
accountId: params.accountId, accountId: params.accountId,
mediaUrls: mediaList.slice(1), mediaUrls: mediaList.slice(1),
mediaLocalRoots: params.mediaLocalRoots,
resolveReplyTo, resolveReplyTo,
retryConfig, retryConfig,
}); });
@@ -353,6 +357,7 @@ export async function deliverDiscordReply(params: {
rest: params.rest, rest: params.rest,
mediaUrl: firstMedia, mediaUrl: firstMedia,
accountId: params.accountId, accountId: params.accountId,
mediaLocalRoots: params.mediaLocalRoots,
replyTo, replyTo,
}); });
deliveredAny = true; deliveredAny = true;
@@ -362,6 +367,7 @@ export async function deliverDiscordReply(params: {
rest: params.rest, rest: params.rest,
accountId: params.accountId, accountId: params.accountId,
mediaUrls: mediaList.slice(1), mediaUrls: mediaList.slice(1),
mediaLocalRoots: params.mediaLocalRoots,
resolveReplyTo, resolveReplyTo,
retryConfig, retryConfig,
}); });