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.
- 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.

View File

@@ -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();
},

View File

@@ -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();
},

View File

@@ -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: [

View File

@@ -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,
});