Skip to content

Commit 35ffe62

Browse files
Jasmine ZhangJasmine Zhang
authored andcommitted
fix(message): stage buffer-only send attachments
1 parent 3a2f54e commit 35ffe62

2 files changed

Lines changed: 90 additions & 0 deletions

File tree

src/infra/outbound/message-action-runner.media.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,40 @@ describe("runMessageAction media behavior", () => {
335335
});
336336
});
337337

338+
it("hydrates buffer-only send attachments into a temporary media file", async () => {
339+
setActivePluginRegistry(
340+
createTestRegistry([
341+
{
342+
pluginId: "workspace",
343+
source: "test",
344+
plugin: workspacePlugin,
345+
},
346+
]),
347+
);
348+
349+
const result = await runMessageAction({
350+
cfg: workspaceConfig,
351+
action: "send",
352+
params: {
353+
channel: "workspace",
354+
target: "12345678",
355+
buffer: onePixelPng.toString("base64"),
356+
filename: "pixel.png",
357+
contentType: "image/png",
358+
},
359+
});
360+
361+
expect(result.kind).toBe("send");
362+
const sendArgs = firstMockArg(channelResolutionMocks.executeSendAction, "executeSendAction");
363+
expect(typeof sendArgs.mediaUrl).toBe("string");
364+
expect(String(sendArgs.mediaUrl)).toMatch(/outbound-buffer-media/);
365+
await expect(fs.readFile(String(sendArgs.mediaUrl))).resolves.toEqual(onePixelPng);
366+
expect(sendArgs.payload).toMatchObject({
367+
text: "",
368+
mediaUrl: String(sendArgs.mediaUrl),
369+
});
370+
});
371+
338372
it("sends structured mediaUrls arrays", async () => {
339373
setActivePluginRegistry(
340374
createTestRegistry([

src/infra/outbound/message-action-runner.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { randomUUID } from "node:crypto";
2+
import fs from "node:fs/promises";
3+
import path from "node:path";
14
// Message-action runner normalizes tool params, resolves channel/target/media,
25
// applies policies, and dispatches send/poll/plugin actions.
36
import {
@@ -33,8 +36,11 @@ import {
3336
normalizeMessagePresentation,
3437
type ReplyPayloadDelivery,
3538
} from "../../interactive/payload.js";
39+
import { canonicalizeBase64 } from "../../media/base64.js";
40+
import { basenameFromAnyPath } from "../../media/file-name.js";
3641
import type { OutboundMediaAccess } from "../../media/load-options.js";
3742
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
43+
import { extensionForMime } from "../../media/mime.js";
3844
import { resolveAgentScopedOutboundMediaAccess } from "../../media/read-capability.js";
3945
import { hasPollCreationParams } from "../../poll-params.js";
4046
import { resolvePollMaxSelections } from "../../polls.js";
@@ -49,6 +55,7 @@ import {
4955
type GatewayClientName,
5056
} from "../../utils/message-channel.js";
5157
import { formatErrorMessage } from "../errors.js";
58+
import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js";
5259
import { throwIfAborted } from "./abort.js";
5360
import { resolveOutboundChannelPlugin } from "./channel-resolution.js";
5461
import {
@@ -467,6 +474,51 @@ type SendPayloadParts = {
467474
silent?: boolean;
468475
};
469476

477+
async function materializeBufferBackedSendMedia(params: {
478+
actionParams: Record<string, unknown>;
479+
sandboxRoot?: string;
480+
}): Promise<void> {
481+
const existingMedia =
482+
readStringParam(params.actionParams, "media", { trim: false }) ??
483+
readStringParam(params.actionParams, "mediaUrl", { trim: false }) ??
484+
readStringParam(params.actionParams, "path", { trim: false }) ??
485+
readStringParam(params.actionParams, "filePath", { trim: false }) ??
486+
readStringParam(params.actionParams, "fileUrl", { trim: false });
487+
if (existingMedia) {
488+
return;
489+
}
490+
491+
const rawBuffer = readStringParam(params.actionParams, "buffer", { trim: false });
492+
if (!rawBuffer) {
493+
return;
494+
}
495+
const canonicalBase64 = canonicalizeBase64(rawBuffer);
496+
if (!canonicalBase64) {
497+
throw new Error("send buffer must be valid base64");
498+
}
499+
500+
const filenameHint =
501+
readStringParam(params.actionParams, "filename", { trim: false }) ?? "attachment";
502+
const contentType =
503+
readStringParam(params.actionParams, "contentType") ??
504+
readStringParam(params.actionParams, "mimeType") ??
505+
undefined;
506+
const baseName = basenameFromAnyPath(filenameHint)?.trim() || "attachment";
507+
const normalizedName = path.extname(baseName)
508+
? baseName
509+
: `${baseName}.${extensionForMime(contentType) ?? "bin"}`;
510+
const rootDir = params.sandboxRoot?.trim() || resolvePreferredOpenClawTmpDir();
511+
const outputDir = path.join(rootDir, "outbound-buffer-media");
512+
await fs.mkdir(outputDir, { recursive: true });
513+
const outputPath = path.join(outputDir, `${randomUUID()}-${normalizedName}`);
514+
await fs.writeFile(outputPath, Buffer.from(canonicalBase64, "base64"));
515+
params.actionParams.buffer = canonicalBase64;
516+
params.actionParams.media = outputPath;
517+
if (!readStringParam(params.actionParams, "filename", { trim: false })) {
518+
params.actionParams.filename = normalizedName;
519+
}
520+
}
521+
470522
function updateSendPayloadPartsFromReplyPayload(
471523
parts: SendPayloadParts,
472524
payload: ReplyPayload,
@@ -934,6 +986,10 @@ async function buildSendPayloadParts(params: {
934986
}
935987
}
936988
}
989+
await materializeBufferBackedSendMedia({
990+
actionParams,
991+
sandboxRoot: input.sandboxRoot,
992+
});
937993
const mediaHint =
938994
readStringParam(actionParams, "media", { trim: false }) ??
939995
readStringParam(actionParams, "mediaUrl", { trim: false }) ??

0 commit comments

Comments
 (0)