diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 5929d27a7..b2f5b6f42 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -3,6 +3,9 @@ export type MsgContext = { From?: string; To?: string; MessageSid?: string; + MediaPath?: string; + MediaUrl?: string; + MediaType?: string; }; export type TemplateContext = MsgContext & { diff --git a/src/index.core.test.ts b/src/index.core.test.ts index 05a55f22b..96e1bd0c5 100644 --- a/src/index.core.test.ts +++ b/src/index.core.test.ts @@ -86,9 +86,33 @@ describe("config and templating", () => { { onReplyStart }, cfg, ); - expect(result?.text).toBe("Hello whatsapp:+1555 [pfx] hi"); - expect(onReplyStart).toHaveBeenCalled(); - }); + expect(result?.text).toBe("Hello whatsapp:+1555 [pfx] hi"); + expect(onReplyStart).toHaveBeenCalled(); + }); + + it("getReplyFromConfig templating includes media fields", async () => { + const cfg = { + inbound: { + reply: { + mode: "text" as const, + text: "{{MediaPath}} {{MediaType}} {{MediaUrl}}", + }, + }, + }; + const result = await index.getReplyFromConfig( + { + Body: "", + From: "+1", + To: "+2", + MediaPath: "/tmp/a.jpg", + MediaType: "image/jpeg", + MediaUrl: "http://example.com/a.jpg", + }, + undefined, + cfg, + ); + expect(result?.text).toBe("/tmp/a.jpg image/jpeg http://example.com/a.jpg"); + }); it("getReplyFromConfig runs command and manages session store", async () => { const tmpStore = path.join(os.tmpdir(), `warelay-store-${Date.now()}.json`); diff --git a/src/media/store.ts b/src/media/store.ts index 71a7ed50f..601ec7dff 100644 --- a/src/media/store.ts +++ b/src/media/store.ts @@ -39,9 +39,13 @@ function looksLikeUrl(src: string) { return /^https?:\/\//i.test(src); } -async function downloadToFile(url: string, dest: string) { +async function downloadToFile( + url: string, + dest: string, + headers?: Record, +) { await new Promise((resolve, reject) => { - const req = request(url, (res) => { + const req = request(url, { headers }, (res) => { if (!res.statusCode || res.statusCode >= 400) { reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`)); return; @@ -70,13 +74,16 @@ export type SavedMedia = { export async function saveMediaSource( source: string, + headers?: Record, + subdir = "", ): Promise { - await ensureMediaDir(); + const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR; + await fs.mkdir(dir, { recursive: true }); await cleanOldMedia(); const id = crypto.randomUUID(); - const dest = path.join(MEDIA_DIR, id); + const dest = path.join(dir, id); if (looksLikeUrl(source)) { - await downloadToFile(source, dest); + await downloadToFile(source, dest, headers); const stat = await fs.stat(dest); return { id, path: dest, size: stat.size }; } @@ -91,3 +98,19 @@ export async function saveMediaSource( await fs.copyFile(source, dest); return { id, path: dest, size: stat.size }; } + +export async function saveMediaBuffer( + buffer: Buffer, + contentType?: string, + subdir = "inbound", +): Promise { + if (buffer.byteLength > MAX_BYTES) { + throw new Error("Media exceeds 5MB limit"); + } + const dir = path.join(MEDIA_DIR, subdir); + await fs.mkdir(dir, { recursive: true }); + const id = crypto.randomUUID(); + const dest = path.join(dir, id); + await fs.writeFile(dest, buffer); + return { id, path: dest, size: buffer.byteLength, contentType }; +} diff --git a/src/provider-web.test.ts b/src/provider-web.test.ts index 56f105641..ea023ad63 100644 --- a/src/provider-web.test.ts +++ b/src/provider-web.test.ts @@ -12,6 +12,17 @@ vi.mock("@whiskeysockets/baileys", () => { return created.mod; }); +vi.mock("./media/store.js", () => ({ + saveMediaBuffer: vi + .fn() + .mockImplementation(async (_buf: Buffer, contentType?: string) => ({ + id: "mid", + path: "/tmp/mid", + size: _buf.length, + contentType, + })), +})); + function getLastSocket(): MockBaileysSocket { const getter = (globalThis as Record)[ Symbol.for("warelay:lastSocket") @@ -170,6 +181,34 @@ describe("provider-web", () => { await listener.close(); }); + it("monitorWebInbox captures media path for image messages", async () => { + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = getLastSocket(); + const upsert = { + type: "notify", + messages: [ + { + key: { id: "med1", fromMe: false, remoteJid: "888@s.whatsapp.net" }, + message: { imageMessage: { mimetype: "image/jpeg" } }, + messageTimestamp: 1_700_000_100, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + expect(onMessage).toHaveBeenCalledWith( + expect.objectContaining({ + body: "", + mediaPath: "/tmp/mid", + mediaType: "image/jpeg", + }), + ); + await listener.close(); + }); + it("logWebSelfId prints cached E.164 when creds exist", () => { const existsSpy = vi .spyOn(fsSync, "existsSync") diff --git a/src/provider-web.ts b/src/provider-web.ts index 17fdcb22c..78ff9144a 100644 --- a/src/provider-web.ts +++ b/src/provider-web.ts @@ -9,6 +9,7 @@ import { makeCacheableSignalKeyStore, makeWASocket, useMultiFileAuthState, + downloadMediaMessage, type AnyMessageContent, } from "@whiskeysockets/baileys"; import pino from "pino"; @@ -20,6 +21,7 @@ import { waitForever } from "./cli/wait.js"; import { getReplyFromConfig } from "./auto-reply/reply.js"; import { defaultRuntime, type RuntimeEnv } from "./runtime.js"; import { logInfo, logWarn } from "./logger.js"; +import { saveMediaBuffer } from "./media/store.js"; const WA_WEB_AUTH_DIR = path.join(os.homedir(), ".warelay", "credentials"); @@ -226,6 +228,9 @@ export type WebInboundMessage = { sendComposing: () => Promise; reply: (text: string) => Promise; sendMedia: (payload: { image: Buffer; caption?: string; mimetype?: string }) => Promise; + mediaPath?: string; + mediaType?: string; + mediaUrl?: string; }; export async function monitorWebInbox(options: { @@ -253,8 +258,26 @@ export async function monitorWebInbox(options: { continue; const from = jidToE164(remoteJid); if (!from) continue; - const body = extractText(msg.message ?? undefined); - if (!body) continue; + let body = extractText(msg.message ?? undefined); + if (!body) { + body = extractMediaPlaceholder(msg.message ?? undefined); + if (!body) continue; + } + let mediaPath: string | undefined; + let mediaType: string | undefined; + try { + const inboundMedia = await downloadInboundMedia(msg, sock); + if (inboundMedia) { + const saved = await saveMediaBuffer( + inboundMedia.buffer, + inboundMedia.mimetype, + ); + mediaPath = saved.path; + mediaType = inboundMedia.mimetype; + } + } catch (err) { + logVerbose(`Inbound media download failed: ${String(err)}`); + } const chatJid = remoteJid; const sendComposing = async () => { try { @@ -287,6 +310,8 @@ export async function monitorWebInbox(options: { sendComposing, reply, sendMedia, + mediaPath, + mediaType, }); } catch (err) { console.error( @@ -330,6 +355,9 @@ export async function monitorWebProvider( From: msg.from, To: msg.to, MessageSid: msg.id, + MediaPath: msg.mediaPath, + MediaUrl: msg.mediaUrl, + MediaType: msg.mediaType, }, { onReplyStart: msg.sendComposing, @@ -441,6 +469,48 @@ function extractText(message: proto.IMessage | undefined): string | undefined { return undefined; } +function extractMediaPlaceholder(message: proto.IMessage | undefined): string | undefined { + if (!message) return undefined; + if (message.imageMessage) return ""; + if (message.videoMessage) return ""; + if (message.audioMessage) return ""; + if (message.documentMessage) return ""; + if (message.stickerMessage) return ""; + return undefined; +} + +async function downloadInboundMedia( + msg: proto.IWebMessageInfo, + sock: ReturnType, +): Promise<{ buffer: Buffer; mimetype?: string } | undefined> { + const message = msg.message; + if (!message) return undefined; + const mimetype = + message.imageMessage?.mimetype ?? + message.videoMessage?.mimetype ?? + message.documentMessage?.mimetype ?? + message.audioMessage?.mimetype ?? + message.stickerMessage?.mimetype; + if ( + !message.imageMessage && + !message.videoMessage && + !message.documentMessage && + !message.audioMessage && + !message.stickerMessage + ) { + return undefined; + } + try { + const buffer = (await downloadMediaMessage(msg as any, "buffer", {}, { + reuploadRequest: sock.updateMediaMessage, + })) as Buffer; + return { buffer, mimetype }; + } catch (err) { + logVerbose(`downloadMediaMessage failed: ${String(err)}`); + return undefined; + } +} + async function loadWebMedia( mediaUrl: string, ): Promise<{ buffer: Buffer; contentType?: string }> { diff --git a/src/twilio/webhook.ts b/src/twilio/webhook.ts index 9230e0f0b..0aa49bc75 100644 --- a/src/twilio/webhook.ts +++ b/src/twilio/webhook.ts @@ -4,7 +4,7 @@ import chalk from "chalk"; import type { Server } from "http"; import { success, logVerbose, danger } from "../globals.js"; -import { readEnv } from "../env.js"; +import { readEnv, type EnvConfig } from "../env.js"; import { createClient } from "./client.js"; import { normalizePath } from "../utils.js"; import { getReplyFromConfig, type ReplyPayload } from "../auto-reply/reply.js"; @@ -12,6 +12,7 @@ import { sendTypingIndicator } from "./typing.js"; import { logTwilioSendError } from "./utils.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { attachMediaRoutes } from "../media/server.js"; +import { saveMediaSource } from "../media/store.js"; /** Start the inbound webhook HTTP server and wire optional auto-replies. */ export async function startWebhook( @@ -39,12 +40,33 @@ export async function startWebhook( [INBOUND] ${From ?? "unknown"} -> ${To ?? "unknown"} (${MessageSid ?? "no-sid"})`); if (verbose) runtime.log(chalk.gray(`Body: ${Body ?? ""}`)); + const numMedia = Number.parseInt((req.body?.NumMedia ?? "0") as string, 10); + let mediaPath: string | undefined; + let mediaUrlInbound: string | undefined; + let mediaType: string | undefined; + if (numMedia > 0 && typeof req.body?.MediaUrl0 === "string") { + mediaUrlInbound = req.body.MediaUrl0 as string; + mediaType = typeof req.body?.MediaContentType0 === "string" + ? (req.body.MediaContentType0 as string) + : undefined; + try { + const creds = buildTwilioBasicAuth(env); + const saved = await saveMediaSource(mediaUrlInbound, { + Authorization: `Basic ${creds}`, + }, "inbound"); + mediaPath = saved.path; + if (!mediaType && saved.contentType) mediaType = saved.contentType; + } catch (err) { + runtime.error(danger(`Failed to download inbound media: ${String(err)}`)); + } + } + const client = createClient(env); let replyResult: ReplyPayload | undefined = autoReply !== undefined ? { text: autoReply } : undefined; if (!replyResult) { replyResult = await getReplyFromConfig( - { Body, From, To, MessageSid }, + { Body, From, To, MessageSid, MediaPath: mediaPath, MediaUrl: mediaUrlInbound, MediaType: mediaType }, { onReplyStart: () => sendTypingIndicator(client, runtime, MessageSid), }, @@ -105,3 +127,10 @@ export async function startWebhook( server.once("error", onError); }); } + +function buildTwilioBasicAuth(env: EnvConfig) { + if ("authToken" in env.auth) { + return Buffer.from(`${env.accountSid}:${env.auth.authToken}`).toString("base64"); + } + return Buffer.from(`${env.auth.apiKey}:${env.auth.apiSecret}`).toString("base64"); +} diff --git a/test/mocks/baileys.ts b/test/mocks/baileys.ts index 2588e1cf3..09b77a9a8 100644 --- a/test/mocks/baileys.ts +++ b/test/mocks/baileys.ts @@ -16,6 +16,7 @@ export type MockBaileysModule = { useMultiFileAuthState: ReturnType; jidToE164?: (jid: string) => string | null; proto?: unknown; + downloadMediaMessage?: ReturnType; }; export function createMockBaileys(): { mod: MockBaileysModule; lastSocket: () => MockBaileysSocket } { @@ -44,6 +45,7 @@ export function createMockBaileys(): { mod: MockBaileysModule; lastSocket: () => saveCreds: vi.fn(), })), jidToE164: (jid: string) => jid.replace(/@.*$/, "").replace(/^/, "+"), + downloadMediaMessage: vi.fn().mockResolvedValue(Buffer.from("img")), }; return {