limits: chunk replies for twilio/web
This commit is contained in:
@@ -55,6 +55,7 @@
|
|||||||
### Fixed
|
### 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.
|
- 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.
|
- 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
|
### 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.
|
- **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";
|
export type { GetReplyOptions, ReplyPayload } from "./types.js";
|
||||||
|
|
||||||
|
const TWILIO_TEXT_LIMIT = 1600;
|
||||||
|
|
||||||
const ABORT_TRIGGERS = new Set(["stop", "esc", "abort", "wait", "exit"]);
|
const ABORT_TRIGGERS = new Set(["stop", "esc", "abort", "wait", "exit"]);
|
||||||
const ABORT_MEMORY = new Map<string, boolean>();
|
const ABORT_MEMORY = new Map<string, boolean>();
|
||||||
|
|
||||||
@@ -450,37 +452,47 @@ export async function autoReplyIfConfigured(
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const replyPayload of replies) {
|
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
|
const mediaList = replyPayload.mediaUrls?.length
|
||||||
? replyPayload.mediaUrls
|
? replyPayload.mediaUrls
|
||||||
: replyPayload.mediaUrl
|
: replyPayload.mediaUrl
|
||||||
? [replyPayload.mediaUrl]
|
? [replyPayload.mediaUrl]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (mediaList.length === 0) {
|
const text = replyPayload.text ?? "";
|
||||||
await sendTwilio(replyPayload.text ?? "");
|
const chunks =
|
||||||
} else {
|
text.length > 0
|
||||||
await sendTwilio(replyPayload.text ?? "", mediaList[0]);
|
? (text.match(new RegExp(`.{1,${TWILIO_TEXT_LIMIT}}`, "g")) ?? [])
|
||||||
for (const extra of mediaList.slice(1)) {
|
: [""];
|
||||||
await sendTwilio("", extra);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isVerbose()) {
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
console.log(
|
const body = chunks[i];
|
||||||
info(
|
const attachMedia = i === 0 ? mediaList[0] : undefined;
|
||||||
`↩️ Auto-replied to ${replyTo} (sid ${message.sid ?? "no-sid"}${replyPayload.mediaUrl ? ", media" : ""})`,
|
|
||||||
),
|
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) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import {
|
|||||||
} from "./reconnect.js";
|
} from "./reconnect.js";
|
||||||
import { getWebAuthAgeMs } from "./session.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.
|
* Send a message via IPC if relay is running, otherwise fall back to direct.
|
||||||
* This avoids Signal session corruption from multiple Baileys connections.
|
* This avoids Signal session corruption from multiple Baileys connections.
|
||||||
@@ -371,14 +373,23 @@ async function deliverWebReply(params: {
|
|||||||
skipLog,
|
skipLog,
|
||||||
} = params;
|
} = params;
|
||||||
const replyStarted = Date.now();
|
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
|
const mediaList = replyResult.mediaUrls?.length
|
||||||
? replyResult.mediaUrls
|
? replyResult.mediaUrls
|
||||||
: replyResult.mediaUrl
|
: replyResult.mediaUrl
|
||||||
? [replyResult.mediaUrl]
|
? [replyResult.mediaUrl]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (mediaList.length === 0 && replyResult.text) {
|
// Text-only replies
|
||||||
await msg.reply(replyResult.text || "");
|
if (mediaList.length === 0 && textChunks.length) {
|
||||||
|
for (const chunk of textChunks) {
|
||||||
|
await msg.reply(chunk);
|
||||||
|
}
|
||||||
if (!skipLog) {
|
if (!skipLog) {
|
||||||
logInfo(
|
logInfo(
|
||||||
`✅ Sent web reply to ${msg.from} (${(Date.now() - replyStarted).toFixed(0)}ms)`,
|
`✅ Sent web reply to ${msg.from} (${(Date.now() - replyStarted).toFixed(0)}ms)`,
|
||||||
@@ -402,7 +413,9 @@ async function deliverWebReply(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanText = replyResult.text ?? undefined;
|
const remainingText = [...textChunks];
|
||||||
|
|
||||||
|
// Media (with optional caption on first item)
|
||||||
for (const [index, mediaUrl] of mediaList.entries()) {
|
for (const [index, mediaUrl] of mediaList.entries()) {
|
||||||
try {
|
try {
|
||||||
const media = await loadWebMedia(mediaUrl, maxMediaBytes);
|
const media = await loadWebMedia(mediaUrl, maxMediaBytes);
|
||||||
@@ -414,7 +427,8 @@ async function deliverWebReply(params: {
|
|||||||
`Web auto-reply media source: ${mediaUrl} (kind ${media.kind})`,
|
`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") {
|
if (media.kind === "image") {
|
||||||
await msg.sendMedia({
|
await msg.sendMedia({
|
||||||
image: media.buffer,
|
image: media.buffer,
|
||||||
@@ -454,7 +468,7 @@ async function deliverWebReply(params: {
|
|||||||
connectionId: connectionId ?? null,
|
connectionId: connectionId ?? null,
|
||||||
to: msg.from,
|
to: msg.from,
|
||||||
from: msg.to,
|
from: msg.to,
|
||||||
text: index === 0 ? (cleanText ?? null) : null,
|
text: caption ?? null,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
mediaSizeBytes: media.buffer.length,
|
mediaSizeBytes: media.buffer.length,
|
||||||
mediaKind: media.kind,
|
mediaKind: media.kind,
|
||||||
@@ -466,12 +480,18 @@ async function deliverWebReply(params: {
|
|||||||
console.error(
|
console.error(
|
||||||
danger(`Failed sending web media to ${msg.from}: ${String(err)}`),
|
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}`);
|
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(
|
export async function monitorWebProvider(
|
||||||
|
|||||||
Reference in New Issue
Block a user