diff --git a/docs/claude-config.md b/docs/claude-config.md index 076e53ad5..cc2346cd7 100644 --- a/docs/claude-config.md +++ b/docs/claude-config.md @@ -58,7 +58,7 @@ Notes on this configuration: - To send an image from Claude, include a line like `MEDIA:https://example.com/pic.jpg` in the output. warelay will: - Host local paths for Twilio using the media server/Tailscale Funnel. - Send buffers directly for the Web provider. -- Inbound media is downloaded (≤5 MB) and exposed to your templates as `{{MediaPath}}`, `{{MediaUrl}}`, and `{{MediaType}}`. You can mention this in your prompt if you want Claude to reason about the attachment. +- Inbound media is downloaded (≤5 MB) and exposed to your templates as `{{MediaPath}}`, `{{MediaUrl}}`, and `{{MediaType}}`. You can mention this in your prompt if you want Claude to reason about the attachment. Outbound media from Claude (via `MEDIA:`) is resized/recompressed on the Web provider path; control the cap with `inbound.reply.mediaMaxMb` (default 5). ## Testing the setup 1. Start a relay (auto-selects Web when logged in, otherwise Twilio polling): diff --git a/docs/images.md b/docs/images.md index cbc8f1ac7..5c1ecf398 100644 --- a/docs/images.md +++ b/docs/images.md @@ -21,7 +21,8 @@ This document defines how `warelay` should handle sending and replying with imag ## Provider Behavior ### Web (Baileys) - Input: local file path **or** HTTP(S) URL. -- Flow: load into Buffer (max 5 MB), send via `sock.sendMessage(jid, { image: buffer, caption })`. +- Flow: load into Buffer, **resize + recompress to JPEG** (max side 2048px, quality step-down) to fit under a configurable cap, then send via `sock.sendMessage(jid, { image: buffer, caption })`. +- Size cap: default 5 MB; override with `inbound.reply.mediaMaxMb` in `~/.warelay/warelay.json`. - Caption uses `--message` or `reply.text`; if caption is empty, send media-only. - Logging: non-verbose shows `↩️`/`✅` with caption; verbose includes `(media, B, ms fetch)`. diff --git a/src/provider-web.test.ts b/src/provider-web.test.ts index 022ae0a9a..626312642 100644 --- a/src/provider-web.test.ts +++ b/src/provider-web.test.ts @@ -501,6 +501,109 @@ describe("provider-web", () => { fetchMock.mockRestore(); }); + it( + "compresses common formats to jpeg under the cap", + { timeout: 15_000 }, + async () => { + const formats = [ + { + name: "png", + mime: "image/png", + make: (buf: Buffer, opts: { width: number; height: number }) => + sharp(buf, { + raw: { width: opts.width, height: opts.height, channels: 3 }, + }) + .png({ compressionLevel: 0 }) + .toBuffer(), + }, + { + name: "jpeg", + mime: "image/jpeg", + make: (buf: Buffer, opts: { width: number; height: number }) => + sharp(buf, { + raw: { width: opts.width, height: opts.height, channels: 3 }, + }) + .jpeg({ quality: 100, chromaSubsampling: "4:4:4" }) + .toBuffer(), + }, + { + name: "webp", + mime: "image/webp", + make: (buf: Buffer, opts: { width: number; height: number }) => + sharp(buf, { + raw: { width: opts.width, height: opts.height, channels: 3 }, + }) + .webp({ quality: 100 }) + .toBuffer(), + }, + ] as const; + + for (const fmt of formats) { + // Force a small cap to ensure compression is exercised for every format. + loadConfigMock = () => ({ inbound: { reply: { mediaMaxMb: 1 } } }); + const sendMedia = vi.fn(); + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ + text: "hi", + mediaUrl: `https://example.com/big.${fmt.name}`, + }); + + let capturedOnMessage: + | (( + msg: import("./provider-web.js").WebInboundMessage, + ) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./provider-web.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + const width = 2000; + const height = 2000; + const raw = crypto.randomBytes(width * height * 3); + const big = await fmt.make(raw, { width, height }); + expect(big.length).toBeGreaterThan(1 * 1024 * 1024); + + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + body: true, + arrayBuffer: async () => + big.buffer.slice(big.byteOffset, big.byteOffset + big.byteLength), + headers: { get: () => fmt.mime }, + status: 200, + } as Response); + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "hello", + from: "+1", + to: "+2", + id: `msg-${fmt.name}`, + sendComposing, + reply, + sendMedia, + }); + + expect(sendMedia).toHaveBeenCalledTimes(1); + const payload = sendMedia.mock.calls[0][0] as { + image: Buffer; + mimetype?: string; + }; + expect(payload.image.length).toBeLessThanOrEqual(1 * 1024 * 1024); + expect(payload.mimetype).toBe("image/jpeg"); + expect(reply).not.toHaveBeenCalled(); + + fetchMock.mockRestore(); + } + }); + it("honors mediaMaxMb from config", async () => { loadConfigMock = () => ({ inbound: { reply: { mediaMaxMb: 1 } } }); const sendMedia = vi.fn(); @@ -573,6 +676,54 @@ describe("provider-web", () => { fetchMock.mockRestore(); }); + it("falls back to text when media is unsupported", async () => { + const sendMedia = vi.fn(); + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ + text: "hi", + mediaUrl: "https://example.com/file.pdf", + }); + + let capturedOnMessage: + | ((msg: import("./provider-web.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./provider-web.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + body: true, + arrayBuffer: async () => Buffer.from("%PDF-1.4").buffer, + headers: { get: () => "application/pdf" }, + status: 200, + } as Response); + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "hello", + from: "+1", + to: "+2", + id: "msg-pdf", + sendComposing, + reply, + sendMedia, + }); + + expect(sendMedia).not.toHaveBeenCalled(); + expect(reply).toHaveBeenCalledWith("hi"); + + fetchMock.mockRestore(); + }); + it("logs outbound replies to file", async () => { const logPath = path.join( os.tmpdir(),