mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix: preserve explicit MIME types on attachment actions (#130714)
Resolve attachment MIME aliases once and carry normalized metadata through send preservation and structured hydration. Remove stale nested-MIME publication while preserving filenames, sniffing and staging. Closes #130711.
This commit is contained in:
committed by
GitHub
parent
8d4ff679a2
commit
1a18981a84
@@ -28,6 +28,15 @@ portable formats, byte limits, and lazy transcoding, see
|
||||
- `--dry-run` — print the resolved payload and skip sending.
|
||||
- `--json` — print the result as JSON: `{ action, channel, dryRun, handledBy, messageId?, payload }` (`payload` carries the channel-specific send result, including any media reference).
|
||||
|
||||
## Message tool attachment metadata
|
||||
|
||||
For `buffer` attachments, `contentType` takes precedence over `mimeType`; a data URL's
|
||||
MIME type is used only when neither is supplied. For `reply`, `sendAttachment`,
|
||||
`upload-file`, and `setGroupIcon`, top-level MIME metadata also takes precedence over
|
||||
the selected `attachments[]` entry. Hydration carries that choice as `contentType`
|
||||
and uses it to infer a missing filename. Explicit filenames are preserved. This
|
||||
metadata precedence does not change MIME detection when media bytes are loaded or staged.
|
||||
|
||||
## WhatsApp Web channel behavior
|
||||
|
||||
- Input: local file path **or** HTTP(S) URL.
|
||||
|
||||
@@ -150,27 +150,36 @@ describe("runMessageAction media behavior", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["send", "sendAttachment", "reply", "upload-file", "setGroupIcon"] as const)(
|
||||
"keeps explicit content type authoritative for %s data URLs",
|
||||
async (action) => {
|
||||
const args: Record<string, unknown> = {
|
||||
buffer: parameterizedPngDataUrl,
|
||||
contentType: "image/jpeg",
|
||||
};
|
||||
it.each(
|
||||
(["send", "sendAttachment", "reply", "upload-file", "setGroupIcon"] as const).flatMap(
|
||||
(action) => [
|
||||
{ action, name: "contentType", metadata: { contentType: "image/jpeg" } },
|
||||
{ action, name: "mimeType", metadata: { mimeType: "image/jpeg" } },
|
||||
{
|
||||
action,
|
||||
name: "both aliases",
|
||||
metadata: { contentType: "image/jpeg", mimeType: "image/webp" },
|
||||
},
|
||||
],
|
||||
),
|
||||
)("keeps $name authoritative for $action data URLs", async ({ action, metadata }) => {
|
||||
const args: Record<string, unknown> = {
|
||||
buffer: parameterizedPngDataUrl,
|
||||
...metadata,
|
||||
};
|
||||
|
||||
await hydrateAttachmentParamsForAction({
|
||||
cfg: {},
|
||||
channel: "imessage",
|
||||
args,
|
||||
action,
|
||||
dryRun: true,
|
||||
mediaPolicy: { mode: "host" },
|
||||
});
|
||||
await hydrateAttachmentParamsForAction({
|
||||
cfg: {},
|
||||
channel: "imessage",
|
||||
args,
|
||||
action,
|
||||
dryRun: true,
|
||||
mediaPolicy: { mode: "host" },
|
||||
});
|
||||
|
||||
expect(args.contentType).toBe("image/jpeg");
|
||||
expect(args.filename).toBe("attachment.jpg");
|
||||
},
|
||||
);
|
||||
expect(args.contentType).toBe("image/jpeg");
|
||||
expect(args.filename).toBe("attachment.jpg");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["duplicate marker", "image/png;base64;base64"],
|
||||
@@ -641,33 +650,56 @@ describe("runMessageAction media behavior", () => {
|
||||
expect(canonicalizeBase64(String(handlerParams.buffer))).toBe(onePixelPngBase64);
|
||||
});
|
||||
|
||||
it("hydrates buffer and metadata from attachments[] before the reply handler runs", async () => {
|
||||
const result = await runMessageAction({
|
||||
cfg,
|
||||
action: "reply",
|
||||
params: {
|
||||
channel: "replychat",
|
||||
target: "+15551234567",
|
||||
messageId: "parent-id",
|
||||
text: "look at this",
|
||||
attachments: [
|
||||
{
|
||||
url: "https://example.com/pic.png",
|
||||
name: "reply.png",
|
||||
mimeType: "image/png",
|
||||
},
|
||||
],
|
||||
it.each([
|
||||
{ name: "nested MIME", metadata: {}, contentType: "image/png" },
|
||||
{
|
||||
name: "explicit MIME alias",
|
||||
metadata: { mimeType: "text/plain" },
|
||||
contentType: "text/plain",
|
||||
},
|
||||
{
|
||||
name: "contentType before mimeType",
|
||||
metadata: {
|
||||
contentType: "text/plain",
|
||||
mimeType: "application/json",
|
||||
filename: "explicit.txt",
|
||||
},
|
||||
});
|
||||
contentType: "text/plain",
|
||||
},
|
||||
])(
|
||||
"passes $name from attachments[] to the reply handler",
|
||||
async ({ metadata, contentType }) => {
|
||||
const result = await runMessageAction({
|
||||
cfg,
|
||||
action: "reply",
|
||||
params: {
|
||||
channel: "replychat",
|
||||
target: "+15551234567",
|
||||
messageId: "parent-id",
|
||||
text: "look at this",
|
||||
...metadata,
|
||||
attachments: [
|
||||
{
|
||||
url: "https://example.com/pic.png",
|
||||
name: "reply.png",
|
||||
mimeType: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("action");
|
||||
expect(loadWebMedia).toHaveBeenCalledWith("https://example.com/pic.png", expect.any(Object));
|
||||
expect(handleActionMock).toHaveBeenCalledTimes(1);
|
||||
const handlerParams = firstMockArg(handleActionMock, "handleAction");
|
||||
expect(handlerParams.buffer).toBe(Buffer.from("hello").toString("base64"));
|
||||
expect(handlerParams.filename).toBe("reply.png");
|
||||
expect(handlerParams.contentType).toBe("image/png");
|
||||
});
|
||||
expect(result.kind).toBe("action");
|
||||
expect(loadWebMedia).toHaveBeenCalledWith(
|
||||
"https://example.com/pic.png",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(handleActionMock).toHaveBeenCalledTimes(1);
|
||||
const handlerParams = firstMockArg(handleActionMock, "handleAction");
|
||||
expect(handlerParams.buffer).toBe(Buffer.from("hello").toString("base64"));
|
||||
expect(handlerParams.filename).toBe(metadata.filename ?? "reply.png");
|
||||
expect(handlerParams.contentType).toBe(contentType);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not copy metadata from attachments[] when top-level media wins", async () => {
|
||||
await runMessageAction({
|
||||
|
||||
@@ -635,13 +635,26 @@ describe("message action media helpers", () => {
|
||||
expect(args.filename).toBe("cute.png");
|
||||
});
|
||||
|
||||
it("hydrates reply attachments from the first structured attachment source", async () => {
|
||||
it.each([
|
||||
{ name: "nested MIME", metadata: {}, contentType: "application/octet-stream" },
|
||||
{
|
||||
name: "explicit MIME alias",
|
||||
metadata: { mimeType: "text/plain" },
|
||||
contentType: "text/plain",
|
||||
},
|
||||
{
|
||||
name: "contentType before mimeType",
|
||||
metadata: { contentType: "text/plain", mimeType: "application/json" },
|
||||
contentType: "text/plain",
|
||||
},
|
||||
])("hydrates structured reply attachments with $name", async ({ metadata, contentType }) => {
|
||||
const args: Record<string, unknown> = {
|
||||
...metadata,
|
||||
attachments: [
|
||||
{
|
||||
url: "https://example.com/cute.png",
|
||||
mimeType: "image/png",
|
||||
name: "cute.png",
|
||||
media: "https://example.invalid/note",
|
||||
mimeType: "application/octet-stream",
|
||||
name: "note.txt",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -655,8 +668,9 @@ describe("message action media helpers", () => {
|
||||
mediaPolicy: { mode: "host" },
|
||||
});
|
||||
|
||||
expect(args.filename).toBe("cute.png");
|
||||
expect(args.contentType).toBe("image/png");
|
||||
expect(args.filename).toBe("note.txt");
|
||||
expect(args.contentType).toBe(contentType);
|
||||
expect(args.buffer).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not hydrate ignored structured attachments when plugin media params win", async () => {
|
||||
@@ -707,12 +721,12 @@ describe("message action media helpers", () => {
|
||||
expect(args.caption).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hydrates buffer-only send params into outbound media paths", async () => {
|
||||
it.each(["contentType", "mimeType"])("stages buffer-only sends with %s metadata", async (key) => {
|
||||
await withTempOpenClawStateDir(async () => {
|
||||
const args: Record<string, unknown> = {
|
||||
buffer: Buffer.from("artifact bytes").toString("base64"),
|
||||
filename: "artifact.txt",
|
||||
contentType: "text/plain",
|
||||
[key]: "text/plain",
|
||||
};
|
||||
|
||||
await hydrateAttachmentParamsForAction({
|
||||
@@ -727,6 +741,8 @@ describe("message action media helpers", () => {
|
||||
expect(args.mediaUrl).toBe(args.media);
|
||||
expect(args.mediaUrls).toEqual([args.media]);
|
||||
expect(args.buffer).toBeUndefined();
|
||||
expect(args.contentType).toBe("text/plain");
|
||||
expect(args.filename).toBe("artifact.txt");
|
||||
await expect(fs.readFile(String(args.media), "utf8")).resolves.toBe("artifact bytes");
|
||||
});
|
||||
});
|
||||
@@ -806,27 +822,35 @@ describe("message action media helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("previews dry-run buffer-only sends without writing outbound media files", async () => {
|
||||
it.each(
|
||||
["dry-run", "preserve-buffer"].flatMap((mode) => [
|
||||
{ mode, buffer: "SGVsbG8=", name: "raw base64" },
|
||||
{ mode, buffer: "data:application/octet-stream;base64,SGVsbG8=", name: "data URL" },
|
||||
]),
|
||||
)("keeps explicit MIME for $mode $name without staging", async ({ mode, buffer }) => {
|
||||
await withTempOpenClawStateDir(async (stateDir) => {
|
||||
const args: Record<string, unknown> = {
|
||||
buffer: Buffer.from("preview").toString("base64"),
|
||||
buffer,
|
||||
filename: "preview.txt",
|
||||
contentType: "text/plain",
|
||||
mimeType: "text/plain",
|
||||
};
|
||||
|
||||
await hydrateAttachmentParamsForAction({
|
||||
cfg,
|
||||
channel: "workspace",
|
||||
channel: "imessage",
|
||||
args,
|
||||
action: "send",
|
||||
dryRun: true,
|
||||
dryRun: mode === "dry-run",
|
||||
preserveSendBuffer: mode === "preserve-buffer",
|
||||
mediaPolicy: { mode: "host" },
|
||||
});
|
||||
|
||||
expect(args.media).toBe("buffer://message-send/attachment");
|
||||
expect(args.mediaUrl).toBe("buffer://message-send/attachment");
|
||||
expect(args.mediaUrls).toEqual(["buffer://message-send/attachment"]);
|
||||
expect(args.buffer).toBeUndefined();
|
||||
expect(args.buffer).toBe(mode === "preserve-buffer" ? buffer : undefined);
|
||||
expect(args.contentType).toBe("text/plain");
|
||||
expect(args.filename).toBe("preview.txt");
|
||||
await expect(fs.readdir(path.join(stateDir, "media", "outbound"))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -335,19 +335,17 @@ async function hydrateSendBufferMediaParams(params: {
|
||||
}
|
||||
const normalized = normalizeBase64Payload({
|
||||
base64: rawBuffer,
|
||||
contentType: readToolStringParam(params.args, "contentType") ?? undefined,
|
||||
contentType:
|
||||
readToolStringParam(params.args, "contentType") ??
|
||||
readToolStringParam(params.args, "mimeType"),
|
||||
});
|
||||
if (!normalized.base64) {
|
||||
return;
|
||||
}
|
||||
const contentType =
|
||||
readToolStringParam(params.args, "contentType") ??
|
||||
readToolStringParam(params.args, "mimeType") ??
|
||||
normalized.contentType;
|
||||
const filename =
|
||||
readToolStringParam(params.args, "filename") ??
|
||||
inferAttachmentFilename({
|
||||
contentType: contentType ?? undefined,
|
||||
contentType: normalized.contentType,
|
||||
});
|
||||
const maxBytes = resolveSendBufferMaxBytes(params);
|
||||
if (params.dryRun || params.preserveBuffer) {
|
||||
@@ -376,7 +374,7 @@ async function hydrateSendBufferMediaParams(params: {
|
||||
}),
|
||||
maxBytes,
|
||||
{
|
||||
contentType: contentType ?? undefined,
|
||||
contentType: normalized.contentType,
|
||||
filename,
|
||||
},
|
||||
);
|
||||
@@ -503,9 +501,9 @@ async function hydrateAttachmentPayload(params: {
|
||||
});
|
||||
if (normalized.base64 !== rawBuffer && normalized.base64) {
|
||||
params.args.buffer = normalized.base64;
|
||||
if (normalized.contentType && !contentTypeParam) {
|
||||
params.args.contentType = normalized.contentType;
|
||||
}
|
||||
}
|
||||
if (normalized.contentType && !readToolStringParam(params.args, "contentType")) {
|
||||
params.args.contentType = normalized.contentType;
|
||||
}
|
||||
|
||||
const filename = readToolStringParam(params.args, "filename");
|
||||
@@ -648,9 +646,6 @@ async function hydrateAttachmentActionPayload(params: {
|
||||
if (attachmentSource?.filename && !readToolStringParam(params.args, "filename")) {
|
||||
params.args.filename = attachmentSource.filename;
|
||||
}
|
||||
if (attachmentSource?.contentType && !readToolStringParam(params.args, "contentType")) {
|
||||
params.args.contentType = attachmentSource.contentType;
|
||||
}
|
||||
|
||||
if (params.allowMessageCaptionFallback) {
|
||||
const caption = readToolStringParam(params.args, "caption", { allowEmpty: true })?.trim();
|
||||
|
||||
@@ -70,7 +70,10 @@ describe("runMessageAction plugin dispatch", () => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
it("preserves buffer-only send bytes for gateway-side materialization", async () => {
|
||||
it.each([
|
||||
{ name: "raw base64", buffer: "SGVsbG8=" },
|
||||
{ name: "data URL", buffer: "data:application/octet-stream;base64,SGVsbG8=" },
|
||||
])("preserves $name bytes and MIME for gateway-side materialization", async ({ buffer }) => {
|
||||
const gatewayPlugin = createGatewayActionPlugin({
|
||||
pluginId: "gatewaychat",
|
||||
label: "Gateway Chat",
|
||||
@@ -101,9 +104,9 @@ describe("runMessageAction plugin dispatch", () => {
|
||||
params: {
|
||||
channel: "gatewaychat",
|
||||
target: "user-123",
|
||||
buffer: Buffer.from("gateway bytes").toString("base64"),
|
||||
buffer,
|
||||
filename: "gateway.txt",
|
||||
contentType: "text/plain",
|
||||
mimeType: "text/plain",
|
||||
},
|
||||
gateway: {
|
||||
clientName: "cli",
|
||||
@@ -123,7 +126,7 @@ describe("runMessageAction plugin dispatch", () => {
|
||||
media: "buffer://message-send/attachment",
|
||||
mediaUrl: "buffer://message-send/attachment",
|
||||
mediaUrls: ["buffer://message-send/attachment"],
|
||||
buffer: Buffer.from("gateway bytes").toString("base64"),
|
||||
buffer,
|
||||
filename: "gateway.txt",
|
||||
contentType: "text/plain",
|
||||
},
|
||||
@@ -132,7 +135,10 @@ describe("runMessageAction plugin dispatch", () => {
|
||||
expect(mocks.executeSendAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves buffer-only send bytes for gateway delivery-mode channels", async () => {
|
||||
it.each([
|
||||
{ name: "raw base64", buffer: "SGVsbG8=" },
|
||||
{ name: "data URL", buffer: "data:application/octet-stream;base64,SGVsbG8=" },
|
||||
])("preserves $name bytes and MIME for gateway delivery-mode channels", async ({ buffer }) => {
|
||||
const gatewayDeliveryPlugin: ChannelPlugin = {
|
||||
id: "gatewaydeliver",
|
||||
meta: {
|
||||
@@ -175,9 +181,9 @@ describe("runMessageAction plugin dispatch", () => {
|
||||
params: {
|
||||
channel: "gatewaydeliver",
|
||||
target: "user-123",
|
||||
buffer: Buffer.from("gateway delivery bytes").toString("base64"),
|
||||
buffer,
|
||||
filename: "delivery.txt",
|
||||
contentType: "text/plain",
|
||||
mimeType: "text/plain",
|
||||
},
|
||||
gateway: {
|
||||
clientName: "cli",
|
||||
@@ -191,7 +197,7 @@ describe("runMessageAction plugin dispatch", () => {
|
||||
{
|
||||
mediaUrl: "buffer://message-send/attachment",
|
||||
mediaUrls: ["buffer://message-send/attachment"],
|
||||
buffer: Buffer.from("gateway delivery bytes").toString("base64"),
|
||||
buffer,
|
||||
filename: "delivery.txt",
|
||||
contentType: "text/plain",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user