limits: chunk replies for twilio/web
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<string, boolean>();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user