feat: add cover image support to Discord event create (#60883)

* feat: add image param to Discord event create for cover art

* fix: pass trusted media roots to event cover image loader

* fix: solve lint error

* fix: add changelog entry for Discord event cover image support (#60883) (thanks @bittoby)

---------

Co-authored-by: Shadow <hi@shadowing.dev>
This commit is contained in:
BitToby
2026-04-07 13:40:39 -05:00
committed by GitHub
parent d78512b09d
commit 9edf9804b1
9 changed files with 43 additions and 2 deletions

View File

@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
- Agents/context engine: expose prompt-cache runtime context to context engines and keep current-turn prompt-cache usage aligned with the active attempt instead of stale prior-turn assistant state. (#62179) Thanks @jalehman.
- Tools/media: document per-provider music and video generation capabilities, and add shared live video-to-video sweep coverage for providers that support local reference clips.
- Compaction: add pluggable compaction provider registry so plugins can replace the built-in summarization pipeline. Configure via `agents.defaults.compaction.provider`; falls back to LLM summarization on provider failure. (#56224) Thanks @DhruvBhatia0.
- Discord/events: allow `event-create` to accept a cover image URL or local file path, load and validate PNG/JPG/GIF event cover media, and pass the encoded image payload through Discord admin action/runtime paths. (#60883) Thanks @bittoby.
### Fixes

View File

@@ -15,7 +15,7 @@ import {
type Ctx = Pick<
ChannelMessageActionContext,
"action" | "params" | "cfg" | "accountId" | "requesterSenderId"
"action" | "params" | "cfg" | "accountId" | "requesterSenderId" | "mediaLocalRoots"
>;
export async function tryHandleDiscordMessageActionGuildAdmin(params: {
@@ -336,6 +336,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
const channelId = readStringParam(actionParams, "channelId");
const location = readStringParam(actionParams, "location");
const entityType = readStringParam(actionParams, "eventType");
const image = readStringParam(actionParams, "image", { trim: false });
return await handleDiscordAction(
{
action: "eventCreate",
@@ -348,8 +349,10 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
channelId,
location,
entityType,
image,
},
cfg,
{ mediaLocalRoots: ctx.mediaLocalRoots },
);
}

View File

@@ -30,6 +30,7 @@ import {
setChannelPermissionDiscord,
uploadEmojiDiscord,
uploadStickerDiscord,
resolveEventCoverImage,
} from "../send.js";
import { readDiscordParentIdParam } from "./runtime.shared.js";
@@ -37,6 +38,7 @@ export const discordGuildActionRuntime = {
addRoleDiscord,
createChannelDiscord,
createScheduledEventDiscord,
resolveEventCoverImage,
deleteChannelDiscord,
editChannelDiscord,
fetchChannelInfoDiscord,
@@ -95,6 +97,7 @@ export async function handleDiscordGuildAction(
params: Record<string, unknown>,
isActionEnabled: ActionGate<DiscordActionConfig>,
cfg?: OpenClawConfig,
options?: { mediaLocalRoots?: readonly string[] },
): Promise<AgentToolResult<unknown>> {
const accountId = readStringParam(params, "accountId");
switch (action) {
@@ -299,8 +302,14 @@ export async function handleDiscordGuildAction(
const description = readStringParam(params, "description");
const channelId = readStringParam(params, "channelId");
const location = readStringParam(params, "location");
const imageUrl = readStringParam(params, "image", { trim: false });
const entityTypeRaw = readStringParam(params, "entityType");
const entityType = entityTypeRaw === "stage" ? 1 : entityTypeRaw === "external" ? 3 : 2;
const image = imageUrl
? await discordGuildActionRuntime.resolveEventCoverImage(imageUrl, {
localRoots: options?.mediaLocalRoots,
})
: undefined;
const payload = {
name,
description,
@@ -309,6 +318,7 @@ export async function handleDiscordGuildAction(
entity_type: entityType,
channel_id: channelId,
entity_metadata: entityType === 3 && location ? { location } : undefined,
image,
privacy_level: 2,
};
const event = accountId

View File

@@ -69,7 +69,7 @@ export async function handleDiscordAction(
return await handleDiscordMessagingAction(action, params, isActionEnabled, options, cfg);
}
if (guildActions.has(action)) {
return await handleDiscordGuildAction(action, params, isActionEnabled, cfg);
return await handleDiscordGuildAction(action, params, isActionEnabled, cfg, options);
}
if (moderationActions.has(action)) {
return await handleDiscordModerationAction(action, params, isActionEnabled);

View File

@@ -7,6 +7,7 @@ import type {
RESTPostAPIGuildScheduledEventJSONBody,
} from "discord-api-types/v10";
import { Routes } from "discord-api-types/v10";
import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media";
import { resolveDiscordRest } from "./send.shared.js";
import type {
DiscordModerationTarget,
@@ -14,6 +15,7 @@ import type {
DiscordRoleChange,
DiscordTimeoutTarget,
} from "./send.types.js";
import { DISCORD_MAX_EVENT_COVER_BYTES } from "./send.types.js";
export async function fetchMemberInfoDiscord(
guildId: string,
@@ -77,6 +79,25 @@ export async function listScheduledEventsDiscord(
return (await rest.get(Routes.guildScheduledEvents(guildId))) as APIGuildScheduledEvent[];
}
const ALLOWED_EVENT_COVER_TYPES = new Set(["image/png", "image/jpeg", "image/jpg", "image/gif"]);
// Loads an image from a URL or path and returns a data URI suitable for the Discord API.
export async function resolveEventCoverImage(
imageUrl: string,
opts?: { localRoots?: readonly string[] },
): Promise<string> {
const media = await loadWebMediaRaw(imageUrl, DISCORD_MAX_EVENT_COVER_BYTES, {
localRoots: opts?.localRoots,
});
const contentType = media.contentType?.toLowerCase();
if (!contentType || !ALLOWED_EVENT_COVER_TYPES.has(contentType)) {
throw new Error(
`Discord event cover images must be PNG, JPG, or GIF (got ${contentType ?? "unknown"})`,
);
}
return `data:${contentType};base64,${media.buffer.toString("base64")}`;
}
export async function createScheduledEventDiscord(
guildId: string,
payload: RESTPostAPIGuildScheduledEventJSONBody,

View File

@@ -15,6 +15,7 @@ export {
addRoleDiscord,
banMemberDiscord,
createScheduledEventDiscord,
resolveEventCoverImage,
fetchChannelInfoDiscord,
fetchMemberInfoDiscord,
fetchRoleInfoDiscord,

View File

@@ -22,6 +22,7 @@ export class DiscordSendError extends Error {
export const DISCORD_MAX_EMOJI_BYTES = 256 * 1024;
export const DISCORD_MAX_STICKER_BYTES = 512 * 1024;
export const DISCORD_MAX_EVENT_COVER_BYTES = 8 * 1024 * 1024;
export type DiscordSendResult = {
messageId: string;

View File

@@ -283,6 +283,9 @@ function buildEventSchema() {
endTime: Type.Optional(Type.String()),
desc: Type.Optional(Type.String()),
location: Type.Optional(Type.String()),
image: Type.Optional(
Type.String({ description: "Cover image URL or local file path for the event." }),
),
durationMin: Type.Optional(Type.Number()),
until: Type.Optional(Type.String()),
};

View File

@@ -109,6 +109,7 @@ export function registerMessageDiscordAdminCommands(message: Command, helpers: M
.option("--channel-id <id>", "Channel id")
.option("--location <text>", "Event location")
.option("--event-type <stage|external|voice>", "Event type")
.option("--image <url>", "Cover image URL or local file path")
.action(async (opts) => {
await helpers.runMessageAction("event-create", opts);
});