fix(discord): advertise upload-file message action

This commit is contained in:
Peter Steinberger
2026-05-02 07:20:36 +01:00
parent b2c8dd69d7
commit fc4da581b3
13 changed files with 269 additions and 27 deletions

View File

@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
- Gateway/watch: keep colored subsystem log prefixes in the managed tmux pane even when the parent shell exports `NO_COLOR`, while preserving explicit `FORCE_COLOR=0` opt-out. Thanks @vincentkoc.
- Agents/compaction: submit a non-empty runtime-event marker for pre-compaction memory flush turns, so strict Anthropic providers no longer reject the silent flush as an empty user message. Fixes #75305. Thanks @sableassistant3777-source.
- Plugin SDK: re-export `isPrivateIpAddress` from `plugin-sdk/ssrf-runtime`, restoring source-checkout builds for SearXNG and Firecrawl private-network guards. Thanks @vincentkoc.
- Discord/message actions: advertise `upload-file` and route it through Discord's send runtime with agent-scoped media reads, so agents can discover and send file attachments. Fixes #60652 and supersedes #60808, #61087, and #61100. Thanks @claw-io, @efe-arv, @joelnishanth, and @sjhddh.
- CLI/directory: report unsupported directory operations for installed channel plugins instead of prompting to reinstall the plugin when it lacks a directory adapter. Fixes #75770. Thanks @lawong888.
- Web search/SearXNG: show the JSON API `search.formats` prerequisite during SearXNG setup before prompting for the base URL. Supersedes #65592. Thanks @evanpaul14.
- Web search/SearXNG: pass through `img_src` image URLs from SearXNG image-category results. Supersedes #61416. Thanks @sghael.

View File

@@ -125,6 +125,97 @@ describe("handleDiscordMessageAction", () => {
);
});
it("maps upload-file to Discord sendMessage with media read context", async () => {
const mediaReadFile = vi.fn(async () => Buffer.from("image"));
const mediaAccess = {
localRoots: ["/tmp/agent-root"],
readFile: mediaReadFile,
};
await handleDiscordMessageAction({
action: "upload-file",
params: {
target: "channel:123",
filePath: "/tmp/agent-root/image.png",
message: "caption",
filename: "image.png",
replyTo: "message-1",
silent: true,
__sessionKey: "session-1",
__agentId: "agent-1",
},
cfg: {
channels: { discord: { token: "tok" } },
} as OpenClawConfig,
mediaAccess,
mediaLocalRoots: ["/tmp/agent-root"],
mediaReadFile,
});
expect(handleDiscordActionMock).toHaveBeenCalledWith(
expect.objectContaining({
action: "sendMessage",
to: "channel:123",
content: "caption",
mediaUrl: "/tmp/agent-root/image.png",
filename: "image.png",
replyTo: "message-1",
silent: true,
__sessionKey: "session-1",
__agentId: "agent-1",
}),
expect.any(Object),
{
mediaAccess,
mediaLocalRoots: ["/tmp/agent-root"],
mediaReadFile,
},
);
});
it("falls back to Discord toolContext.currentChannelId for upload-file", async () => {
await handleDiscordMessageAction({
action: "upload-file",
params: {
path: "/tmp/agent-root/image.png",
},
cfg: {
channels: { discord: { token: "tok" } },
} as OpenClawConfig,
toolContext: {
currentChannelProvider: "discord",
currentChannelId: "channel:123",
},
});
expect(handleDiscordActionMock).toHaveBeenCalledWith(
expect.objectContaining({
action: "sendMessage",
to: "channel:123",
content: "",
mediaUrl: "/tmp/agent-root/image.png",
}),
expect.any(Object),
expect.any(Object),
);
});
it("requires a file path for upload-file", async () => {
await expect(
handleDiscordMessageAction({
action: "upload-file",
params: {
to: "channel:123",
},
cfg: {
channels: { discord: { token: "tok" } },
} as OpenClawConfig,
}),
).rejects.toThrow(/upload-file requires filePath, path, or media/i);
expect(handleDiscordActionMock).not.toHaveBeenCalled();
});
it("does not use another provider's current target for Discord sends", async () => {
await expect(
handleDiscordMessageAction({

View File

@@ -66,15 +66,19 @@ export async function handleDiscordMessageAction(
return target;
};
const resolveChannelId = () => resolveDiscordChannelId(readTarget());
if (action === "send") {
const to =
const readSendTarget = () => {
const target =
readStringParam(params, "to") ??
readStringParam(params, "target") ??
readCurrentDiscordTarget(ctx.toolContext);
if (!to) {
if (!target) {
throw new Error("Discord channel target is required (use channel:<id>).");
}
return target;
};
if (action === "send") {
const to = readSendTarget();
const asVoice = readBooleanParam(params, "asVoice") === true;
const rawComponents =
buildDiscordPresentationComponents(normalizeMessagePresentation(params.presentation)) ??
@@ -83,15 +87,15 @@ export async function handleDiscordMessageAction(
Boolean(rawComponents) &&
(typeof rawComponents === "function" || typeof rawComponents === "object");
const components = hasComponents ? rawComponents : undefined;
const content = readStringParam(params, "message", {
required: !asVoice && !hasComponents,
allowEmpty: true,
});
// Support media, path, and filePath for media URL
const mediaUrl =
readStringParam(params, "media", { trim: false }) ??
readStringParam(params, "path", { trim: false }) ??
readStringParam(params, "filePath", { trim: false });
const content = readStringParam(params, "message", {
required: !asVoice && !hasComponents && !mediaUrl,
allowEmpty: true,
});
const filename = readStringParam(params, "filename");
const replyTo = readStringParam(params, "replyTo");
const rawEmbeds = params.embeds;
@@ -104,7 +108,7 @@ export async function handleDiscordMessageAction(
action: "sendMessage",
accountId: accountId ?? undefined,
to,
content,
content: content ?? "",
mediaUrl: mediaUrl ?? undefined,
filename: filename ?? undefined,
replyTo: replyTo ?? undefined,
@@ -120,6 +124,41 @@ export async function handleDiscordMessageAction(
);
}
if (action === "upload-file") {
const to = readSendTarget();
const mediaUrl =
readStringParam(params, "filePath", { trim: false }) ??
readStringParam(params, "path", { trim: false }) ??
readStringParam(params, "media", { trim: false });
if (!mediaUrl) {
throw new Error("upload-file requires filePath, path, or media.");
}
const content =
readStringParam(params, "message", { allowEmpty: true }) ??
readStringParam(params, "content", { allowEmpty: true });
const filename = readStringParam(params, "filename");
const replyTo = readStringParam(params, "replyTo");
const silent = readBooleanParam(params, "silent") === true;
const sessionKey = readStringParam(params, "__sessionKey");
const agentId = readStringParam(params, "__agentId");
return await handleDiscordAction(
{
action: "sendMessage",
accountId: accountId ?? undefined,
to,
content: content ?? "",
mediaUrl,
filename: filename ?? undefined,
replyTo: replyTo ?? undefined,
silent,
__sessionKey: sessionKey ?? undefined,
__agentId: agentId ?? undefined,
},
cfg,
actionOptions,
);
}
if (action === "poll") {
const to = readStringParam(params, "to", { required: true });
const question = readStringParam(params, "pollQuestion", {

View File

@@ -78,14 +78,14 @@ export async function handleDiscordMessageSendAction(ctx: DiscordMessagingAction
Array.isArray(rawComponents) || typeof rawComponents === "function"
? (rawComponents as DiscordSendComponents)
: undefined;
const content = readStringParam(ctx.params, "content", {
required: !asVoice && !componentSpec && !components,
allowEmpty: true,
});
const mediaUrl =
readStringParam(ctx.params, "mediaUrl", { trim: false }) ??
readStringParam(ctx.params, "path", { trim: false }) ??
readStringParam(ctx.params, "filePath", { trim: false });
const content = readStringParam(ctx.params, "content", {
required: !asVoice && !componentSpec && !components && !mediaUrl,
allowEmpty: true,
});
const filename = readStringParam(ctx.params, "filename");
const replyTo = readStringParam(ctx.params, "replyTo");
const rawEmbeds = ctx.params.embeds;
@@ -117,6 +117,9 @@ export async function handleDiscordMessageSendAction(ctx: DiscordMessagingAction
agentId: agentId ?? undefined,
mediaUrl: mediaUrl ?? undefined,
filename: filename ?? undefined,
mediaAccess: ctx.options?.mediaAccess,
mediaLocalRoots: ctx.options?.mediaLocalRoots,
mediaReadFile: ctx.options?.mediaReadFile,
},
);
return jsonResult({ ok: true, result, components: true });
@@ -144,6 +147,7 @@ export async function handleDiscordMessageSendAction(ctx: DiscordMessagingAction
const result = await discordMessagingActionRuntime.sendMessageDiscord(to, content ?? "", {
...ctx.withOpts(),
mediaAccess: ctx.options?.mediaAccess,
mediaUrl,
filename: filename ?? undefined,
mediaLocalRoots: ctx.options?.mediaLocalRoots,

View File

@@ -12,6 +12,11 @@ import { discordMessagingActionRuntime } from "./runtime.messaging.runtime.js";
import { createDiscordActionOptions } from "./runtime.shared.js";
export type DiscordMessagingActionOptions = {
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
workspaceDir?: string;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
};

View File

@@ -93,6 +93,11 @@ function handleMessagingAction(
isActionEnabled: (key: keyof DiscordActionConfig) => boolean,
cfg: OpenClawConfig = DISCORD_TEST_CFG,
options?: {
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
workspaceDir?: string;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
},
@@ -463,6 +468,8 @@ describe("handleDiscordMessagingAction", () => {
it("forwards trusted mediaLocalRoots into sendMessageDiscord", async () => {
sendMessageDiscord.mockClear();
const mediaReadFile = vi.fn(async () => Buffer.from("image"));
const mediaAccess = { localRoots: ["/tmp/agent-root"], readFile: mediaReadFile };
await handleMessagingAction(
"sendMessage",
{
@@ -472,11 +479,35 @@ describe("handleDiscordMessagingAction", () => {
},
enableAllActions,
DISCORD_TEST_CFG,
{ mediaLocalRoots: ["/tmp/agent-root"] },
{ mediaAccess, mediaLocalRoots: ["/tmp/agent-root"], mediaReadFile },
);
expect(sendMessageDiscord).toHaveBeenCalledWith(
"channel:123",
"hello",
expect.objectContaining({
mediaAccess,
mediaUrl: "/tmp/image.png",
mediaLocalRoots: ["/tmp/agent-root"],
mediaReadFile,
}),
);
});
it("allows media-only message sends", async () => {
sendMessageDiscord.mockClear();
await handleMessagingAction(
"sendMessage",
{
to: "channel:123",
mediaUrl: "/tmp/image.png",
},
enableAllActions,
DISCORD_TEST_CFG,
{ mediaLocalRoots: ["/tmp/agent-root"] },
);
expect(sendMessageDiscord).toHaveBeenCalledWith(
"channel:123",
"",
expect.objectContaining({
mediaUrl: "/tmp/image.png",
mediaLocalRoots: ["/tmp/agent-root"],

View File

@@ -58,7 +58,13 @@ export async function handleDiscordAction(
params: Record<string, unknown>,
cfg: OpenClawConfig,
options?: {
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
workspaceDir?: string;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
},
): Promise<AgentToolResult<unknown>> {
const action = readStringParam(params, "action", { required: true });

View File

@@ -55,7 +55,15 @@ describe("discordMessageActions", () => {
expect(discovery?.capabilities).toEqual(["presentation"]);
expect(discovery?.schema).toBeUndefined();
expect(discovery?.actions).toEqual(
expect.arrayContaining(["send", "poll", "react", "reactions", "emoji-list", "permissions"]),
expect.arrayContaining([
"send",
"upload-file",
"poll",
"react",
"reactions",
"emoji-list",
"permissions",
]),
);
expect(discovery?.actions).not.toContain("channel-create");
expect(discovery?.actions).not.toContain("role-add");
@@ -144,13 +152,35 @@ describe("discordMessageActions", () => {
});
expect(defaultDiscovery?.actions).toEqual(expect.arrayContaining(["send", "poll"]));
expect(defaultDiscovery?.actions).toContain("upload-file");
expect(defaultDiscovery?.actions).not.toContain("react");
expect(workDiscovery?.actions).toEqual(
expect.arrayContaining(["send", "react", "reactions", "emoji-list"]),
expect.arrayContaining(["send", "upload-file", "react", "reactions", "emoji-list"]),
);
expect(workDiscovery?.actions).not.toContain("poll");
});
it("hides upload-file when Discord message actions are disabled", () => {
const discovery = discordMessageActions.describeMessageTool?.({
cfg: {
channels: {
discord: {
token: "Bot token-main",
actions: {
messages: false,
},
},
},
} as OpenClawConfig,
});
expect(discovery?.actions).toContain("send");
expect(discovery?.actions).not.toContain("upload-file");
expect(discovery?.actions).not.toContain("read");
expect(discovery?.actions).not.toContain("edit");
expect(discovery?.actions).not.toContain("delete");
});
it("does not expose Discord-native message tool schema", () => {
const discovery = discordMessageActions.describeMessageTool?.({
cfg: {
@@ -170,7 +200,7 @@ describe("discordMessageActions", () => {
);
});
it.each(["send", "edit", "delete", "react", "pin", "poll"])(
it.each(["send", "upload-file", "edit", "delete", "react", "pin", "poll"])(
"routes %s actions through local execution mode",
(action) => {
expect(discordMessageActions.resolveExecutionMode?.({ action: action as never })).toBe(
@@ -210,6 +240,11 @@ describe("discordMessageActions", () => {
const toolContext: ChannelMessageActionContext["toolContext"] = {
currentChannelProvider: "discord",
};
const mediaReadFile = vi.fn(async () => Buffer.from("image"));
const mediaAccess: NonNullable<ChannelMessageActionContext["mediaAccess"]> = {
localRoots: ["/tmp/media"],
readFile: mediaReadFile,
};
const mediaLocalRoots = ["/tmp/media"];
await discordMessageActions.handleAction?.({
@@ -220,7 +255,9 @@ describe("discordMessageActions", () => {
accountId: "ops",
requesterSenderId: "user-1",
toolContext,
mediaAccess,
mediaLocalRoots,
mediaReadFile,
});
expect(handleDiscordMessageActionMock).toHaveBeenCalledWith({
@@ -230,7 +267,9 @@ describe("discordMessageActions", () => {
accountId: "ops",
requesterSenderId: "user-1",
toolContext,
mediaAccess,
mediaLocalRoots,
mediaReadFile,
});
});
});

View File

@@ -86,6 +86,7 @@ function describeDiscordMessageTool({
actions.add("emoji-list");
}
if (discovery.isEnabled("messages")) {
actions.add("upload-file");
actions.add("read");
actions.add("edit");
actions.add("delete");
@@ -181,7 +182,9 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
accountId,
requesterSenderId,
toolContext,
mediaAccess,
mediaLocalRoots,
mediaReadFile,
}) => {
return await (
await loadDiscordChannelActionsRuntime()
@@ -192,7 +195,9 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
accountId,
requesterSenderId,
toolContext,
mediaAccess,
mediaLocalRoots,
mediaReadFile,
});
},
};

View File

@@ -1,6 +1,7 @@
import { ChannelType } from "discord-api-types/v10";
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import type { OutboundMediaAccess } from "openclaw/plugin-sdk/media-runtime";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import type { ChunkMode } from "openclaw/plugin-sdk/reply-chunking";
import { resolveDiscordAccount } from "./accounts.js";
@@ -154,10 +155,7 @@ type DiscordComponentSendOpts = {
sessionKey?: string;
agentId?: string;
mediaUrl?: string;
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
};
mediaAccess?: OutboundMediaAccess;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
filename?: string;

View File

@@ -2,7 +2,7 @@ import { ChannelType } from "discord-api-types/v10";
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import type { PollInput } from "openclaw/plugin-sdk/media-runtime";
import type { OutboundMediaAccess, PollInput } from "openclaw/plugin-sdk/media-runtime";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import { resolveChunkMode, type ChunkMode } from "openclaw/plugin-sdk/reply-chunking";
import type { RetryConfig } from "openclaw/plugin-sdk/retry-runtime";
@@ -35,10 +35,7 @@ type DiscordSendOpts = {
accountId?: string;
mediaUrl?: string;
filename?: string;
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
};
mediaAccess?: OutboundMediaAccess;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
verbose?: boolean;
@@ -225,6 +222,7 @@ export async function sendMessageDiscord(
mediaCaption ?? "",
opts.mediaUrl,
opts.filename,
opts.mediaAccess,
opts.mediaLocalRoots,
opts.mediaReadFile,
mediaMaxBytes,
@@ -292,6 +290,7 @@ export async function sendMessageDiscord(
textWithMentions,
opts.mediaUrl,
opts.filename,
opts.mediaAccess,
opts.mediaLocalRoots,
opts.mediaReadFile,
mediaMaxBytes,

View File

@@ -444,6 +444,28 @@ describe("sendMessageDiscord", () => {
);
});
it("passes mediaAccess workspaceDir when loading relative media attachments", async () => {
const { rest, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "msg", channel_id: "789" });
await sendMessageDiscord("channel:789", "", {
rest,
token: "t",
cfg: DISCORD_TEST_CFG,
mediaUrl: "chart.png",
mediaAccess: {
workspaceDir: "/tmp/agent-workspace",
},
});
expect(loadWebMedia).toHaveBeenCalledWith(
"chart.png",
expect.objectContaining({
workspaceDir: "/tmp/agent-workspace",
}),
);
});
it("prefers the caller-provided filename for media attachments", async () => {
const { rest, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "msg", channel_id: "789" });

View File

@@ -7,6 +7,7 @@ import { extensionForMime } from "openclaw/plugin-sdk/media-runtime";
import {
normalizePollDurationHours,
normalizePollInput,
type OutboundMediaAccess,
type PollInput,
} from "openclaw/plugin-sdk/media-runtime";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
@@ -345,6 +346,7 @@ async function sendDiscordMedia(
text: string,
mediaUrl: string,
filename: string | undefined,
mediaAccess: OutboundMediaAccess | undefined,
mediaLocalRoots: readonly string[] | undefined,
mediaReadFile: ((filePath: string) => Promise<Buffer>) | undefined,
maxBytes: number | undefined,
@@ -359,7 +361,7 @@ async function sendDiscordMedia(
) {
const media = await loadWebMedia(
mediaUrl,
buildOutboundMediaLoadOptions({ maxBytes, mediaLocalRoots, mediaReadFile }),
buildOutboundMediaLoadOptions({ maxBytes, mediaAccess, mediaLocalRoots, mediaReadFile }),
);
const requestedFileName = filename?.trim();
const resolvedFileName =