mirror of https://github.com/openclaw/openclaw.git
Reply: fix generated image delivery to Discord (#52489)
This commit is contained in:
parent
6d34d62795
commit
24032dcc0e
|
|
@ -386,6 +386,7 @@ export async function handleDiscordMessagingAction(
|
|||
...cfgOptions,
|
||||
...(accountId ? { accountId } : {}),
|
||||
mediaUrl,
|
||||
filename: filename ?? undefined,
|
||||
mediaLocalRoots: options?.mediaLocalRoots,
|
||||
replyTo,
|
||||
components,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue