diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 2dee6a7ef..b9c28b58a 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -297,38 +297,38 @@ const mediaNote = let mediaFromCommand: string | undefined; const mediaLine = rawStdout .split("\n") - .find((line) => /^MEDIA:/i.test(line)); + .find((line) => /\bMEDIA:/i.test(line)); if (mediaLine) { - const after = mediaLine.replace(/^MEDIA:\s*/i, ""); - const parts = after.trim().split(/\s+/); - if (parts[0]) { - mediaFromCommand = normalizeMediaSource(parts[0]); + let isValidMedia = false; + const mediaMatch = mediaLine.match(/\bMEDIA:\s*([^\s]+)/i); + if (mediaMatch?.[1]) { + const candidate = normalizeMediaSource(mediaMatch[1]); + const looksLikeUrl = /^https?:\/\//i.test(candidate); + const looksLikePath = + candidate.startsWith("/") || candidate.startsWith("./"); + const hasWhitespace = /\s/.test(candidate); + isValidMedia = + !hasWhitespace && + candidate.length <= 1024 && + (looksLikeUrl || looksLikePath); + if (isValidMedia) mediaFromCommand = candidate; } - 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; - const looksLikePath = mediaFromCommand - ? mediaFromCommand.startsWith("/") || mediaFromCommand.startsWith("./") - : false; - if ( - !mediaFromCommand || - hasWhitespace || - (!looksLikeUrl && !looksLikePath) || - mediaFromCommand.length > 1024 - ) { - mediaFromCommand = undefined; + if (isValidMedia && mediaMatch?.[0]) { + trimmed = rawStdout + .replace(mediaMatch[0], "") + .replace(/\s{2,}/g, " ") + .replace(/\s+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + } else { + trimmed = rawStdout + .split("\n") + .filter((line) => line !== mediaLine) + .join("\n") + .replace(/\n\s+/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); } - } 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 65c45983a..841a1e8f7 100644 --- a/src/index.core.test.ts +++ b/src/index.core.test.ts @@ -171,6 +171,32 @@ describe("config and templating", () => { expect(result?.mediaUrl).toBe("/tmp/pic.png"); }); + it("extracts MEDIA token inline within a sentence", async () => { + const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ + stdout: "caption before MEDIA:/tmp/pic.png caption after", + 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?.mediaUrl).toBe("/tmp/pic.png"); + expect(result?.text).toBe("caption before caption after"); + }); + 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", @@ -483,11 +509,11 @@ describe("twilio interactions", () => { }); describe("webhook and messaging", () => { - it("startWebhook responds and auto-replies", async () => { - const client = twilioFactory._createClient(); - client.messages.create.mockResolvedValue({}); - twilioFactory.mockReturnValue(client); - vi.spyOn(index, "getReplyFromConfig").mockResolvedValue({ text: "Auto" }); + it("startWebhook responds and auto-replies", async () => { + const client = twilioFactory._createClient(); + client.messages.create.mockResolvedValue({}); + twilioFactory.mockReturnValue(client); + vi.spyOn(index, "getReplyFromConfig").mockResolvedValue({ text: "Auto" }); const server = await index.startWebhook(0, "/hook", undefined, false); const address = server.address() as net.AddressInfo; @@ -501,6 +527,39 @@ describe("webhook and messaging", () => { await new Promise((resolve) => server.close(resolve)); }); + it("hosts local media before replying via webhook", async () => { + const client = twilioFactory._createClient(); + client.messages.create.mockResolvedValue({}); + twilioFactory.mockReturnValue(client); + const replies = await import("./auto-reply/reply.js"); + const hostModule = await import("./media/host.js"); + const hostSpy = vi + .spyOn(hostModule, "ensureMediaHosted") + .mockResolvedValue({ url: "https://ts.net/media/abc", id: "abc", size: 123 }); + vi.spyOn(replies, "getReplyFromConfig").mockResolvedValue({ + text: "Auto", + mediaUrl: "/tmp/pic.png", + }); + + const server = await index.startWebhook(0, "/hook", undefined, false); + const address = server.address() as net.AddressInfo; + const url = `http://127.0.0.1:${address.port}/hook`; + await fetch(url, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "From=whatsapp%3A%2B1555&To=whatsapp%3A%2B1666&Body=Hello&MessageSid=SM2", + }); + + expect(hostSpy).toHaveBeenCalledWith("/tmp/pic.png"); + expect(client.messages.create).toHaveBeenCalledWith( + expect.objectContaining({ + mediaUrl: ["https://ts.net/media/abc"], + }), + ); + hostSpy.mockRestore(); + await new Promise((resolve) => server.close(resolve)); + }); + it("listRecentMessages merges and sorts", async () => { const inbound = [ { diff --git a/src/provider-web.ts b/src/provider-web.ts index f5adae8e9..e313f63a2 100644 --- a/src/provider-web.ts +++ b/src/provider-web.ts @@ -23,6 +23,10 @@ import { defaultRuntime, type RuntimeEnv } from "./runtime.js"; import { logInfo, logWarn } from "./logger.js"; import { saveMediaBuffer } from "./media/store.js"; +function formatDuration(ms: number) { + return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`; +} + const WA_WEB_AUTH_DIR = path.join(os.homedir(), ".warelay", "credentials"); export async function createWaSocket(printQr: boolean, verbose: boolean) { @@ -117,15 +121,12 @@ export async function sendMessageWeb( logVerbose(`Presence update skipped: ${String(err)}`); } let payload: AnyMessageContent = { text: body }; - if (options.mediaUrl) { - const normalized = options.mediaUrl.startsWith("file://") - ? options.mediaUrl.replace("file://", "") - : options.mediaUrl; - const media = await loadWebMedia(options.mediaUrl); - payload = { - image: media.buffer, - caption: body || undefined, - mimetype: media.contentType, + if (options.mediaUrl) { + const media = await loadWebMedia(options.mediaUrl); + payload = { + image: media.buffer, + caption: body || undefined, + mimetype: media.contentType, }; } logInfo( @@ -369,6 +370,9 @@ export async function monitorWebProvider( if (!replyResult || (!replyResult.text && !replyResult.mediaUrl)) return; try { if (replyResult.mediaUrl) { + logVerbose( + `Web auto-reply media detected: ${replyResult.mediaUrl}`, + ); const media = await loadWebMedia(replyResult.mediaUrl); await msg.sendMedia({ image: media.buffer, @@ -382,7 +386,7 @@ export async function monitorWebProvider( if (isVerbose()) { console.log( success( - `↩️ Auto-replied to ${msg.from} (web, ${replyResult.text?.length ?? 0} chars${replyResult.mediaUrl ? ", media" : ""}, ${durationMs}ms)`, + `↩️ Auto-replied to ${msg.from} (web, ${replyResult.text?.length ?? 0} chars${replyResult.mediaUrl ? ", media" : ""}, ${formatDuration(durationMs)})`, ), ); } else { @@ -493,7 +497,8 @@ async function downloadInboundMedia( message.videoMessage?.mimetype ?? message.documentMessage?.mimetype ?? message.audioMessage?.mimetype ?? - message.stickerMessage?.mimetype; + message.stickerMessage?.mimetype ?? + undefined; if ( !message.imageMessage && !message.videoMessage && @@ -506,6 +511,7 @@ async function downloadInboundMedia( try { const buffer = (await downloadMediaMessage(msg as any, "buffer", {}, { reuploadRequest: sock.updateMediaMessage, + logger: (sock as { logger?: unknown })?.logger as any, })) as Buffer; return { buffer, mimetype }; } catch (err) { @@ -531,6 +537,7 @@ async function loadWebMedia( } return { buffer: array, contentType: res.headers.get("content-type") ?? undefined }; } + // Local path const data = await fs.readFile(mediaUrl); if (data.length > 5 * 1024 * 1024) { throw new Error("Media exceeds 5MB limit"); diff --git a/src/twilio/webhook.ts b/src/twilio/webhook.ts index 0aa49bc75..f6d4944d4 100644 --- a/src/twilio/webhook.ts +++ b/src/twilio/webhook.ts @@ -13,6 +13,7 @@ import { logTwilioSendError } from "./utils.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { attachMediaRoutes } from "../media/server.js"; import { saveMediaSource } from "../media/store.js"; +import { ensureMediaHosted } from "../media/host.js"; /** Start the inbound webhook HTTP server and wire optional auto-replies. */ export async function startWebhook( @@ -75,16 +76,21 @@ export async function startWebhook( if (replyResult && (replyResult.text || replyResult.mediaUrl)) { try { + let mediaUrl = replyResult.mediaUrl; + if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) { + const hosted = await ensureMediaHosted(mediaUrl); + mediaUrl = hosted.url; + } await client.messages.create({ from: To, to: From, body: replyResult.text ?? "", - ...(replyResult.mediaUrl ? { mediaUrl: [replyResult.mediaUrl] } : {}), + ...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}), }); if (verbose) runtime.log( success( - `↩️ Auto-replied to ${From}${replyResult.mediaUrl ? " (media)" : ""}`, + `↩️ Auto-replied to ${From}${mediaUrl ? " (media)" : ""}`, ), ); } catch (err) {