diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 2e294de81..336bab6f9 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -171,8 +171,15 @@ const mediaNote = ? `[media attached: ${ctx.MediaPath}${ctx.MediaType ? ` (${ctx.MediaType})` : ""}${ctx.MediaUrl ? ` | ${ctx.MediaUrl}` : ""}]` : undefined; // For command prompts we prepend the media note so Claude et al. see it; text replies stay clean. + const mediaReplyHint = + mediaNote && reply?.mode === "command" + ? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body." + : undefined; const commandBody = mediaNote - ? `${mediaNote}\n${prefixedBody ?? ""}`.trim() + ? [mediaNote, mediaReplyHint, prefixedBody ?? ""] + .filter(Boolean) + .join("\n") + .trim() : prefixedBody; const templatingCtx: TemplateContext = { ...sessionCtx, @@ -282,10 +289,36 @@ const mediaNote = const rawStdout = stdout.trim(); let trimmed = rawStdout; let mediaFromCommand: string | undefined; - const mediaMatch = /MEDIA:\s*(.+)$/im.exec(rawStdout); - if (mediaMatch?.[1]) { - mediaFromCommand = mediaMatch[1].trim(); - trimmed = rawStdout.replace(mediaMatch[0], "").trim(); + const mediaLine = rawStdout + .split("\n") + .find((line) => /^MEDIA:/i.test(line)); + if (mediaLine) { + const after = mediaLine.replace(/^MEDIA:\s*/i, ""); + const parts = after.trim().split(/\s+/); + if (parts.length === 1 && parts[0]) { + mediaFromCommand = parts[0]; + } + trimmed = rawStdout + .split("\n") + .filter((line) => !/^MEDIA:/i.test(line)) + .join("\n") + .trim(); + // Basic sanity: accept only URLs or existing file paths without whitespace. + const hasWhitespace = mediaFromCommand + ? /\s/.test(mediaFromCommand) + : false; + const looksLikeUrl = mediaFromCommand + ? /^https?:\/\//i.test(mediaFromCommand) + : false; + if ( + !mediaFromCommand || + hasWhitespace || + (!looksLikeUrl && mediaFromCommand.length > 1024) + ) { + mediaFromCommand = undefined; + } + } else { + trimmed = rawStdout; } if (stderr?.trim()) { logVerbose(`Command auto-reply stderr: ${stderr.trim()}`); diff --git a/src/index.core.test.ts b/src/index.core.test.ts index a3507aa8a..f772790ee 100644 --- a/src/index.core.test.ts +++ b/src/index.core.test.ts @@ -146,6 +146,32 @@ describe("config and templating", () => { expect(result?.mediaUrl).toBe("https://example.com/img.jpg"); }); + it("ignores invalid MEDIA lines with whitespace", async () => { + const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ + stdout: "hello\nMEDIA: not a url with spaces\nrest\n", + stderr: "", + code: 0, + signal: null, + killed: false, + }); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["echo", "{{Body}}"], + }, + }, + }; + const result = await index.getReplyFromConfig( + { Body: "hi", From: "+1", To: "+2" }, + undefined, + cfg, + runSpy, + ); + expect(result?.text).toBe("hello\nrest"); + expect(result?.mediaUrl).toBeUndefined(); + }); + it("getReplyFromConfig runs command and manages session store", async () => { const tmpStore = path.join(os.tmpdir(), `warelay-store-${Date.now()}.json`); vi.spyOn(crypto, "randomUUID").mockReturnValue("session-123");