diff --git a/CHANGELOG.md b/CHANGELOG.md index 90128e027..e730c7957 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ - Doctor: repair gateway service entrypoint when switching between npm and git installs; add Docker e2e coverage. — thanks @steipete - Daemon: align generated systemd unit with docs for network-online + restart delay. (#479) — thanks @azade-c - Daemon: add KillMode=process to systemd units to avoid podman restart hangs. (#541) — thanks @ogulcancelik +- WhatsApp: make inbound media size cap configurable (default 50 MB). (#505) — thanks @koala73 - Doctor: run legacy state migrations in non-interactive mode without prompts. - Cron: parse Telegram topic targets for isolated delivery. (#478) — thanks @nachoiacovino - Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. (follow-up #516) — thanks @YuriNachos diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index f3f5e8ee5..da70fd387 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -265,7 +265,8 @@ For groups, use `whatsapp.groupPolicy` + `whatsapp.groupAllowFrom`. whatsapp: { dmPolicy: "pairing", // pairing | allowlist | open | disabled allowFrom: ["+15555550123", "+447700900123"], - textChunkLimit: 4000 // optional outbound chunk size (chars) + textChunkLimit: 4000, // optional outbound chunk size (chars) + mediaMaxMb: 50 // optional inbound media cap (MB) } } ``` diff --git a/docs/providers/whatsapp.md b/docs/providers/whatsapp.md index faf42418c..9321bdeab 100644 --- a/docs/providers/whatsapp.md +++ b/docs/providers/whatsapp.md @@ -151,7 +151,8 @@ Behavior: ## Limits - Outbound text is chunked to `whatsapp.textChunkLimit` (default 4000). -- Media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB). +- Inbound media saves are capped by `whatsapp.mediaMaxMb` (default 50 MB). +- Outbound media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB). ## Outbound send (text + media) - Uses active web listener; error if gateway not running. @@ -166,7 +167,7 @@ Behavior: - Gateway: `send` params include `gifPlayback: true` ## Media limits + optimization -- Default cap: 5 MB (per media item). +- Default outbound cap: 5 MB (per media item). - Override: `agents.defaults.mediaMaxMb`. - Images are auto-optimized to JPEG under cap (resize + quality sweep). - Oversize media => error; media reply falls back to text warning. @@ -187,7 +188,9 @@ Behavior: - `whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled). - `whatsapp.selfChatMode` (same-phone setup; suppress pairing replies for outbound DMs). - `whatsapp.allowFrom` (DM allowlist). +- `whatsapp.mediaMaxMb` (inbound media save cap). - `whatsapp.accounts..*` (per-account settings + optional `authDir`). +- `whatsapp.accounts..mediaMaxMb` (per-account inbound media cap). - `whatsapp.groupAllowFrom` (group sender allowlist). - `whatsapp.groupPolicy` (group policy). - `whatsapp.groups` (group allowlist + mention gating defaults; use `"*"` to allow all) diff --git a/skills/local-places/src/local_places/__pycache__/__init__.cpython-314.pyc b/skills/local-places/src/local_places/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 0a17848a4..000000000 Binary files a/skills/local-places/src/local_places/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/skills/local-places/src/local_places/__pycache__/google_places.cpython-314.pyc b/skills/local-places/src/local_places/__pycache__/google_places.cpython-314.pyc deleted file mode 100644 index 94944facf..000000000 Binary files a/skills/local-places/src/local_places/__pycache__/google_places.cpython-314.pyc and /dev/null differ diff --git a/skills/local-places/src/local_places/__pycache__/main.cpython-314.pyc b/skills/local-places/src/local_places/__pycache__/main.cpython-314.pyc deleted file mode 100644 index ec25ea963..000000000 Binary files a/skills/local-places/src/local_places/__pycache__/main.cpython-314.pyc and /dev/null differ diff --git a/skills/local-places/src/local_places/__pycache__/schemas.cpython-314.pyc b/skills/local-places/src/local_places/__pycache__/schemas.cpython-314.pyc deleted file mode 100644 index 997365113..000000000 Binary files a/skills/local-places/src/local_places/__pycache__/schemas.cpython-314.pyc and /dev/null differ diff --git a/src/config/types.ts b/src/config/types.ts index 8cfdae2e6..60cf46af1 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -131,6 +131,8 @@ export type WhatsAppConfig = { groupPolicy?: GroupPolicy; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; + /** Maximum media file size in MB. Default: 50. */ + mediaMaxMb?: number; /** Disable block streaming for this account. */ blockStreaming?: boolean; /** Merge streamed block replies before sending. */ @@ -160,6 +162,7 @@ export type WhatsAppAccountConfig = { groupAllowFrom?: string[]; groupPolicy?: GroupPolicy; textChunkLimit?: number; + mediaMaxMb?: number; blockStreaming?: boolean; /** Merge streamed block replies before sending. */ blockStreamingCoalesce?: BlockStreamingCoalesceConfig; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index ae655eb41..7c5d0fd7d 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -1227,6 +1227,7 @@ export const ClawdbotSchema = z.object({ groupAllowFrom: z.array(z.string()).optional(), groupPolicy: GroupPolicySchema.optional().default("open"), textChunkLimit: z.number().int().positive().optional(), + mediaMaxMb: z.number().int().positive().optional(), blockStreaming: z.boolean().optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), groups: z @@ -1262,6 +1263,7 @@ export const ClawdbotSchema = z.object({ groupAllowFrom: z.array(z.string()).optional(), groupPolicy: GroupPolicySchema.optional().default("open"), textChunkLimit: z.number().int().positive().optional(), + mediaMaxMb: z.number().int().positive().optional().default(50), blockStreaming: z.boolean().optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), actions: z diff --git a/src/web/accounts.ts b/src/web/accounts.ts index 5a368a2d1..131e427a8 100644 --- a/src/web/accounts.ts +++ b/src/web/accounts.ts @@ -23,6 +23,7 @@ export type ResolvedWhatsAppAccount = { groupPolicy?: GroupPolicy; dmPolicy?: DmPolicy; textChunkLimit?: number; + mediaMaxMb?: number; blockStreaming?: boolean; groups?: WhatsAppAccountConfig["groups"]; }; @@ -120,6 +121,7 @@ export function resolveWhatsAppAccount(params: { groupPolicy: accountCfg?.groupPolicy ?? params.cfg.whatsapp?.groupPolicy, textChunkLimit: accountCfg?.textChunkLimit ?? params.cfg.whatsapp?.textChunkLimit, + mediaMaxMb: accountCfg?.mediaMaxMb ?? params.cfg.whatsapp?.mediaMaxMb, blockStreaming: accountCfg?.blockStreaming ?? params.cfg.whatsapp?.blockStreaming, groups: accountCfg?.groups ?? params.cfg.whatsapp?.groups, diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 3764148cf..f22148864 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -788,6 +788,7 @@ export async function monitorWebProvider( groupAllowFrom: account.groupAllowFrom, groupPolicy: account.groupPolicy, textChunkLimit: account.textChunkLimit, + mediaMaxMb: account.mediaMaxMb, blockStreaming: account.blockStreaming, groups: account.groups, }, @@ -1305,6 +1306,7 @@ export async function monitorWebProvider( verbose, accountId: account.accountId, authDir: account.authDir, + mediaMaxMb: account.mediaMaxMb, onMessage: async (msg) => { handledMessages += 1; lastMessageAt = Date.now(); diff --git a/src/web/inbound.media.test.ts b/src/web/inbound.media.test.ts index ee4ef47e0..c18cf7f26 100644 --- a/src/web/inbound.media.test.ts +++ b/src/web/inbound.media.test.ts @@ -3,12 +3,21 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; const readAllowFromStoreMock = vi.fn().mockResolvedValue([]); const upsertPairingRequestMock = vi .fn() .mockResolvedValue({ code: "PAIRCODE", created: true }); +const saveMediaBufferSpy = vi.fn(); vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); @@ -33,6 +42,19 @@ vi.mock("../pairing/pairing-store.js", () => ({ upsertPairingRequestMock(...args), })); +vi.mock("../media/store.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + saveMediaBuffer: vi.fn( + async (...args: Parameters) => { + saveMediaBufferSpy(...args); + return actual.saveMediaBuffer(...args); + }, + ), + }; +}); + const HOME = path.join( os.tmpdir(), `clawdbot-inbound-media-${crypto.randomUUID()}`, @@ -87,6 +109,10 @@ vi.mock("./session.js", () => { import { monitorWebInbox } from "./inbound.js"; describe("web inbound media saves with extension", () => { + beforeEach(() => { + saveMediaBufferSpy.mockClear(); + }); + beforeAll(async () => { await fs.rm(HOME, { recursive: true, force: true }); }); @@ -182,4 +208,44 @@ describe("web inbound media saves with extension", () => { await listener.close(); }); + + it("passes mediaMaxMb to saveMediaBuffer", async () => { + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ + verbose: false, + onMessage, + mediaMaxMb: 1, + }); + const { createWaSocket } = await import("./session.js"); + const realSock = await ( + createWaSocket as unknown as () => Promise<{ + ev: import("node:events").EventEmitter; + }> + )(); + + const upsert = { + type: "notify", + messages: [ + { + key: { id: "img3", fromMe: false, remoteJid: "222@s.whatsapp.net" }, + message: { imageMessage: { mimetype: "image/jpeg" } }, + messageTimestamp: 1_700_000_003, + }, + ], + }; + + realSock.ev.emit("messages.upsert", upsert); + + for (let i = 0; i < 10; i++) { + if (onMessage.mock.calls.length > 0) break; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + expect(onMessage).toHaveBeenCalledTimes(1); + expect(saveMediaBufferSpy).toHaveBeenCalled(); + const lastCall = saveMediaBufferSpy.mock.calls.at(-1); + expect(lastCall?.[3]).toBe(1 * 1024 * 1024); + + await listener.close(); + }); }); diff --git a/src/web/inbound.ts b/src/web/inbound.ts index b8f129de1..589e7ddde 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -83,6 +83,7 @@ export async function monitorWebInbox(options: { accountId: string; authDir: string; onMessage: (msg: WebInboundMessage) => Promise; + mediaMaxMb?: number; }) { const inboundLogger = getChildLogger({ module: "web-inbound" }); const inboundConsoleLog = createSubsystemLogger( @@ -375,9 +376,16 @@ export async function monitorWebInbox(options: { try { const inboundMedia = await downloadInboundMedia(msg, sock); if (inboundMedia) { + const maxMb = + typeof options.mediaMaxMb === "number" && options.mediaMaxMb > 0 + ? options.mediaMaxMb + : 50; + const maxBytes = maxMb * 1024 * 1024; const saved = await saveMediaBuffer( inboundMedia.buffer, inboundMedia.mimetype, + "inbound", + maxBytes, ); mediaPath = saved.path; mediaType = inboundMedia.mimetype;