diff --git a/CHANGELOG.md b/CHANGELOG.md index dce8595e0..3c1c5fab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ ### Fixed - Support multiple assistant text replies when using Tau RPC: agents now emit `texts` arrays and command auto-replies deliver each message separately without leaking raw JSON. - Normalized agent parsers (pi/claude/opencode/codex/gemini) to the new plural output shape. +- Enforce outbound text size caps: WhatsApp/Twilio messages chunked at 1600 chars; web replies chunked at 4000 chars. ### Changes - **Heartbeat backpressure:** Web reply heartbeats now check the shared command queue and skip while any command/Claude runs are in flight, preventing concurrent prompts during long-running requests. diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 06dfa2728..4889887ec 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -28,6 +28,8 @@ import type { GetReplyOptions, ReplyPayload } from "./types.js"; export type { GetReplyOptions, ReplyPayload } from "./types.js"; +const TWILIO_TEXT_LIMIT = 1600; + const ABORT_TRIGGERS = new Set(["stop", "esc", "abort", "wait", "exit"]); const ABORT_MEMORY = new Map(); @@ -450,37 +452,47 @@ export async function autoReplyIfConfigured( }; for (const replyPayload of replies) { - if (replyPayload.text) { - logVerbose( - `Auto-replying via Twilio: from ${replyFrom} to ${replyTo}, body length ${replyPayload.text.length}`, - ); - } else { - logVerbose( - `Auto-replying via Twilio: from ${replyFrom} to ${replyTo} (media)`, - ); - } - const mediaList = replyPayload.mediaUrls?.length ? replyPayload.mediaUrls : replyPayload.mediaUrl ? [replyPayload.mediaUrl] : []; - if (mediaList.length === 0) { - await sendTwilio(replyPayload.text ?? ""); - } else { - await sendTwilio(replyPayload.text ?? "", mediaList[0]); - for (const extra of mediaList.slice(1)) { - await sendTwilio("", extra); - } - } + const text = replyPayload.text ?? ""; + const chunks = + text.length > 0 + ? (text.match(new RegExp(`.{1,${TWILIO_TEXT_LIMIT}}`, "g")) ?? []) + : [""]; - if (isVerbose()) { - console.log( - info( - `↩️ Auto-replied to ${replyTo} (sid ${message.sid ?? "no-sid"}${replyPayload.mediaUrl ? ", media" : ""})`, - ), - ); + for (let i = 0; i < chunks.length; i++) { + const body = chunks[i]; + const attachMedia = i === 0 ? mediaList[0] : undefined; + + if (body) { + logVerbose( + `Auto-replying via Twilio: from ${replyFrom} to ${replyTo}, body length ${body.length}`, + ); + } else if (attachMedia) { + logVerbose( + `Auto-replying via Twilio: from ${replyFrom} to ${replyTo} (media only)`, + ); + } + + await sendTwilio(body, attachMedia); + + if (i === 0 && mediaList.length > 1) { + for (const extra of mediaList.slice(1)) { + await sendTwilio("", extra); + } + } + + if (isVerbose()) { + console.log( + info( + `↩️ Auto-replied to ${replyTo} (sid ${message.sid ?? "no-sid"}${attachMedia ? ", media" : ""})`, + ), + ); + } } } } catch (err) { diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 2cf9722c8..3677f9843 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -29,6 +29,8 @@ import { } from "./reconnect.js"; import { getWebAuthAgeMs } from "./session.js"; +const WEB_TEXT_LIMIT = 4000; + /** * Send a message via IPC if relay is running, otherwise fall back to direct. * This avoids Signal session corruption from multiple Baileys connections. @@ -371,14 +373,23 @@ async function deliverWebReply(params: { skipLog, } = params; const replyStarted = Date.now(); + const textChunks = + (replyResult.text || "").length > 0 + ? ((replyResult.text || "").match( + new RegExp(`.{1,${WEB_TEXT_LIMIT}}`, "g"), + ) ?? []) + : []; const mediaList = replyResult.mediaUrls?.length ? replyResult.mediaUrls : replyResult.mediaUrl ? [replyResult.mediaUrl] : []; - if (mediaList.length === 0 && replyResult.text) { - await msg.reply(replyResult.text || ""); + // Text-only replies + if (mediaList.length === 0 && textChunks.length) { + for (const chunk of textChunks) { + await msg.reply(chunk); + } if (!skipLog) { logInfo( `✅ Sent web reply to ${msg.from} (${(Date.now() - replyStarted).toFixed(0)}ms)`, @@ -402,7 +413,9 @@ async function deliverWebReply(params: { return; } - const cleanText = replyResult.text ?? undefined; + const remainingText = [...textChunks]; + + // Media (with optional caption on first item) for (const [index, mediaUrl] of mediaList.entries()) { try { const media = await loadWebMedia(mediaUrl, maxMediaBytes); @@ -414,7 +427,8 @@ async function deliverWebReply(params: { `Web auto-reply media source: ${mediaUrl} (kind ${media.kind})`, ); } - const caption = index === 0 ? cleanText || undefined : undefined; + const caption = + index === 0 ? remainingText.shift() || undefined : undefined; if (media.kind === "image") { await msg.sendMedia({ image: media.buffer, @@ -454,7 +468,7 @@ async function deliverWebReply(params: { connectionId: connectionId ?? null, to: msg.from, from: msg.to, - text: index === 0 ? (cleanText ?? null) : null, + text: caption ?? null, mediaUrl, mediaSizeBytes: media.buffer.length, mediaKind: media.kind, @@ -466,12 +480,18 @@ async function deliverWebReply(params: { console.error( danger(`Failed sending web media to ${msg.from}: ${String(err)}`), ); - if (index === 0 && cleanText) { + replyLogger.warn({ err, mediaUrl }, "failed to send web media reply"); + if (index === 0 && remainingText.length) { console.log(`⚠️ Media skipped; sent text-only to ${msg.from}`); - await msg.reply(cleanText || ""); + await msg.reply(remainingText.shift() || ""); } } } + + // Remaining text chunks after media + for (const chunk of remainingText) { + await msg.reply(chunk); + } } export async function monitorWebProvider(