Reply: fix generated image delivery to Discord (#52489)

This commit is contained in:
scoootscooob
2026-03-22 15:18:16 -07:00
committed by GitHub
parent 6d34d62795
commit 24032dcc0e
10 changed files with 242 additions and 4 deletions

View File

@@ -386,6 +386,7 @@ export async function handleDiscordMessagingAction(
...cfgOptions,
...(accountId ? { accountId } : {}),
mediaUrl,
filename: filename ?? undefined,
mediaLocalRoots: options?.mediaLocalRoots,
replyTo,
components,

View File

@@ -395,6 +395,28 @@ describe("handleDiscordMessagingAction", () => {
);
});
it("forwards the optional filename into sendMessageDiscord", async () => {
sendMessageDiscord.mockClear();
await handleDiscordMessagingAction(
"sendMessage",
{
to: "channel:123",
content: "hello",
mediaUrl: "/tmp/generated-image",
filename: "image.png",
},
enableAllActions,
);
expect(sendMessageDiscord).toHaveBeenCalledWith(
"channel:123",
"hello",
expect.objectContaining({
mediaUrl: "/tmp/generated-image",
filename: "image.png",
}),
);
});
it("rejects voice messages that include content", async () => {
await expect(
handleDiscordMessagingAction(

View File

@@ -48,6 +48,7 @@ type DiscordSendOpts = {
token?: string;
accountId?: string;
mediaUrl?: string;
filename?: string;
mediaLocalRoots?: readonly string[];
verbose?: boolean;
rest?: RequestClient;
@@ -214,6 +215,7 @@ export async function sendMessageDiscord(
threadId,
mediaCaption ?? "",
opts.mediaUrl,
opts.filename,
opts.mediaLocalRoots,
mediaMaxBytes,
undefined,
@@ -275,6 +277,7 @@ export async function sendMessageDiscord(
channelId,
textWithMentions,
opts.mediaUrl,
opts.filename,
opts.mediaLocalRoots,
mediaMaxBytes,
opts.replyTo,

View File

@@ -272,6 +272,27 @@ describe("sendMessageDiscord", () => {
);
});
it("prefers the caller-provided filename for media attachments", async () => {
const { rest, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "msg", channel_id: "789" });
await sendMessageDiscord("channel:789", "photo", {
rest,
token: "t",
mediaUrl: "file:///tmp/generated-image",
filename: "renderable.png",
});
expect(postMock).toHaveBeenCalledWith(
Routes.channelMessages("789"),
expect.objectContaining({
body: expect.objectContaining({
files: [expect.objectContaining({ name: "renderable.png" })],
}),
}),
);
});
it("uses configured discord mediaMaxMb for uploads", async () => {
const { rest, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "msg", channel_id: "789" });

View File

@@ -12,6 +12,7 @@ import { Routes, type APIChannel, type APIEmbed } from "discord-api-types/v10";
import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { RetryRunner } from "openclaw/plugin-sdk/infra-runtime";
import { buildOutboundMediaLoadOptions } from "openclaw/plugin-sdk/media-runtime";
import { extensionForMime } from "openclaw/plugin-sdk/media-runtime";
import {
normalizePollDurationHours,
normalizePollInput,
@@ -416,6 +417,7 @@ async function sendDiscordMedia(
channelId: string,
text: string,
mediaUrl: string,
filename: string | undefined,
mediaLocalRoots: readonly string[] | undefined,
maxBytes: number | undefined,
replyTo: string | undefined,
@@ -430,6 +432,12 @@ async function sendDiscordMedia(
mediaUrl,
buildOutboundMediaLoadOptions({ maxBytes, mediaLocalRoots }),
);
const requestedFileName = filename?.trim();
const resolvedFileName =
requestedFileName ||
media.fileName ||
(media.contentType ? `upload${extensionForMime(media.contentType) ?? ""}` : "") ||
"upload";
const chunks = text ? buildDiscordTextChunks(text, { maxLinesPerMessage, chunkMode }) : [];
const caption = chunks[0] ?? "";
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
@@ -449,7 +457,7 @@ async function sendDiscordMedia(
files: [
{
data: fileData,
name: media.fileName ?? "upload",
name: resolvedFileName,
},
],
});

View File

@@ -43,6 +43,7 @@ function stubImageGenerationProviders() {
generate: {
maxCount: 4,
supportsSize: true,
supportsAspectRatio: true,
},
edit: {
enabled: false,
@@ -50,6 +51,7 @@ function stubImageGenerationProviders() {
},
geometry: {
sizes: ["1024x1024", "1024x1536", "1536x1024"],
aspectRatios: ["1:1", "16:9"],
},
},
generateImage: vi.fn(async () => {

View File

@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from "vitest";
import { createBlockReplyDeliveryHandler } from "./reply-delivery.js";
import type { TypingSignaler } from "./typing-mode.js";
describe("createBlockReplyDeliveryHandler", () => {
it("sends media-bearing block replies even when block streaming is disabled", async () => {
const onBlockReply = vi.fn(async () => {});
const normalizeStreamingText = vi.fn((payload: { text?: string }) => ({
text: payload.text,
skip: false,
}));
const typingSignals = {
signalTextDelta: vi.fn(async () => {}),
} as unknown as TypingSignaler;
const handler = createBlockReplyDeliveryHandler({
onBlockReply,
normalizeStreamingText,
applyReplyToMode: (payload) => payload,
typingSignals,
blockStreamingEnabled: false,
blockReplyPipeline: null,
directlySentBlockKeys: new Set(),
});
await handler({
text: "here's the vibe",
mediaUrls: ["/tmp/generated.png"],
replyToCurrent: true,
});
expect(onBlockReply).toHaveBeenCalledWith({
text: undefined,
mediaUrl: "/tmp/generated.png",
mediaUrls: ["/tmp/generated.png"],
replyToCurrent: true,
replyToId: undefined,
replyToTag: undefined,
audioAsVoice: false,
});
expect(typingSignals.signalTextDelta).toHaveBeenCalledWith("here's the vibe");
});
it("keeps text-only block replies buffered when block streaming is disabled", async () => {
const onBlockReply = vi.fn(async () => {});
const handler = createBlockReplyDeliveryHandler({
onBlockReply,
normalizeStreamingText: (payload) => ({ text: payload.text, skip: false }),
applyReplyToMode: (payload) => payload,
typingSignals: {
signalTextDelta: vi.fn(async () => {}),
} as unknown as TypingSignaler,
blockStreamingEnabled: false,
blockReplyPipeline: null,
directlySentBlockKeys: new Set(),
});
await handler({ text: "text only" });
expect(onBlockReply).not.toHaveBeenCalled();
});
});

View File

@@ -128,7 +128,12 @@ export function createBlockReplyDeliveryHandler(params: {
// Track sent key to avoid duplicate in final payloads.
params.directlySentBlockKeys.add(createBlockReplyContentKey(blockPayload));
await params.onBlockReply(blockPayload);
} else if (blockHasMedia) {
// When block streaming is disabled, text-only block replies are accumulated into the
// final response. Media cannot be reconstructed later, so send it immediately and let
// the assistant's final text arrive through the normal final-reply path.
await params.onBlockReply({ ...blockPayload, text: undefined });
}
// When streaming is disabled entirely, blocks are accumulated in final text instead.
// When streaming is disabled entirely, text-only blocks are accumulated in final text.
};
}

View File

@@ -67,6 +67,81 @@ describe("OpenAI image-generation provider", () => {
});
});
it("maps supported aspect ratios onto OpenAI size presets", async () => {
vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "sk-test",
source: "env",
mode: "api-key",
});
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [{ b64_json: Buffer.from("png-data").toString("base64") }],
}),
});
vi.stubGlobal("fetch", fetchMock);
const provider = buildOpenAIImageGenerationProvider();
await provider.generateImage({
provider: "openai",
model: "gpt-image-1.5",
prompt: "draw a portrait",
aspectRatio: "9:16",
cfg: {},
authStore: { version: 1, profiles: {} },
});
expect(fetchMock).toHaveBeenCalledWith(
"https://api.openai.com/v1/images/generations",
expect.objectContaining({
body: JSON.stringify({
model: "gpt-image-1.5",
prompt: "draw a portrait",
n: 1,
size: "1024x1536",
}),
}),
);
});
it("prefers an explicit size over aspect ratio mapping", async () => {
vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "sk-test",
source: "env",
mode: "api-key",
});
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [{ b64_json: Buffer.from("png-data").toString("base64") }],
}),
});
vi.stubGlobal("fetch", fetchMock);
const provider = buildOpenAIImageGenerationProvider();
await provider.generateImage({
provider: "openai",
model: "gpt-image-1.5",
prompt: "draw a landscape",
size: "1024x1024",
aspectRatio: "16:9",
cfg: {},
authStore: { version: 1, profiles: {} },
});
expect(fetchMock).toHaveBeenCalledWith(
"https://api.openai.com/v1/images/generations",
expect.objectContaining({
body: JSON.stringify({
model: "gpt-image-1.5",
prompt: "draw a landscape",
n: 1,
size: "1024x1024",
}),
}),
);
});
it("rejects reference-image edits for now", async () => {
const provider = buildOpenAIImageGenerationProvider();

View File

@@ -6,6 +6,18 @@ const DEFAULT_OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1";
const DEFAULT_OUTPUT_MIME = "image/png";
const DEFAULT_SIZE = "1024x1024";
const OPENAI_SUPPORTED_SIZES = ["1024x1024", "1024x1536", "1536x1024"] as const;
const OPENAI_SUPPORTED_ASPECT_RATIOS = [
"1:1",
"2:3",
"3:2",
"3:4",
"4:3",
"4:5",
"5:4",
"9:16",
"16:9",
"21:9",
] as const;
type OpenAIImageApiResponse = {
data?: Array<{
@@ -19,6 +31,31 @@ function resolveOpenAIBaseUrl(cfg: Parameters<typeof resolveApiKeyForProvider>[0
return direct || DEFAULT_OPENAI_IMAGE_BASE_URL;
}
function resolveOpenAISize(params: { size?: string; aspectRatio?: string }): string {
const explicitSize = params.size?.trim();
if (explicitSize) {
return explicitSize;
}
switch (params.aspectRatio?.trim()) {
case "1:1":
return "1024x1024";
case "2:3":
case "3:4":
case "4:5":
case "9:16":
return "1024x1536";
case "3:2":
case "4:3":
case "5:4":
case "16:9":
case "21:9":
return "1536x1024";
default:
return DEFAULT_SIZE;
}
}
export function buildOpenAIImageGenerationProvider(): ImageGenerationProviderPlugin {
return {
id: "openai",
@@ -29,7 +66,7 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProviderPlu
generate: {
maxCount: 4,
supportsSize: true,
supportsAspectRatio: false,
supportsAspectRatio: true,
supportsResolution: false,
},
edit: {
@@ -42,6 +79,7 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProviderPlu
},
geometry: {
sizes: [...OPENAI_SUPPORTED_SIZES],
aspectRatios: [...OPENAI_SUPPORTED_ASPECT_RATIOS],
},
},
async generateImage(req) {
@@ -75,7 +113,7 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProviderPlu
model: req.model || DEFAULT_OPENAI_IMAGE_MODEL,
prompt: req.prompt,
n: req.count ?? 1,
size: req.size ?? DEFAULT_SIZE,
size: resolveOpenAISize({ size: req.size, aspectRatio: req.aspectRatio }),
}),
signal: controller.signal,
}).finally(() => {