fix: remove WhatsApp batching delay

This commit is contained in:
Peter Steinberger
2025-12-20 01:36:37 +01:00
parent 46b9006de2
commit afa4a234f9
3 changed files with 54 additions and 89 deletions

View File

@@ -51,7 +51,7 @@ Notes:
- Manual smoke: - Manual smoke:
- Send an `@clawd` ping in the group and confirm a reply that references the sender name. - Send an `@clawd` ping in the group and confirm a reply that references the sender name.
- Send a second ping and verify the history block is included then cleared on the next turn. - Send a second ping and verify the history block is included then cleared on the next turn.
- Check gateway logs (run with `--verbose`) to see `inbound web message (batched)` entries showing `from: <groupJid>` and the `[from: …]` suffix. - Check gateway logs (run with `--verbose`) to see `inbound web message` entries showing `from: <groupJid>` and the `[from: …]` suffix.
## Known considerations ## Known considerations
- Heartbeats are intentionally skipped for groups to avoid noisy broadcasts. - Heartbeats are intentionally skipped for groups to avoid noisy broadcasts.

View File

@@ -79,7 +79,7 @@ const makeSessionStore = async (
await fs.writeFile(storePath, JSON.stringify(entries)); await fs.writeFile(storePath, JSON.stringify(entries));
const cleanup = async () => { const cleanup = async () => {
// Session store writes can be in-flight when the test finishes (e.g. updateLastRoute // Session store writes can be in-flight when the test finishes (e.g. updateLastRoute
// after a batched message flush). `fs.rm({ recursive })` can race and throw ENOTEMPTY. // after a message flush). `fs.rm({ recursive })` can race and throw ENOTEMPTY.
for (let attempt = 0; attempt < 10; attempt += 1) { for (let attempt = 0; attempt < 10; attempt += 1) {
try { try {
await fs.rm(dir, { recursive: true, force: true }); await fs.rm(dir, { recursive: true, force: true });
@@ -866,8 +866,7 @@ describe("web auto-reply", () => {
} }
}); });
it("batches inbound messages while queue is busy and preserves timestamps", async () => { it("processes inbound messages without batching and preserves timestamps", async () => {
vi.useFakeTimers();
const originalMax = process.getMaxListeners(); const originalMax = process.getMaxListeners();
process.setMaxListeners?.(1); // force low to confirm bump process.setMaxListeners?.(1); // force low to confirm bump
@@ -878,7 +877,7 @@ describe("web auto-reply", () => {
const sendMedia = vi.fn(); const sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(undefined); const reply = vi.fn().mockResolvedValue(undefined);
const sendComposing = vi.fn(); const sendComposing = vi.fn();
const resolver = vi.fn().mockResolvedValue({ text: "batched" }); const resolver = vi.fn().mockResolvedValue({ text: "ok" });
let capturedOnMessage: let capturedOnMessage:
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>) | ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
@@ -892,12 +891,6 @@ describe("web auto-reply", () => {
return { close: vi.fn() }; return { close: vi.fn() };
}; };
// Queue starts busy, then frees after one polling tick.
let queueBusy = true;
const queueSpy = vi
.spyOn(commandQueue, "getQueueSize")
.mockImplementation(() => (queueBusy ? 1 : 0));
setLoadConfigMock(() => ({ setLoadConfigMock(() => ({
inbound: { inbound: {
timestampPrefix: "UTC", timestampPrefix: "UTC",
@@ -930,25 +923,22 @@ describe("web auto-reply", () => {
sendMedia, sendMedia,
}); });
// Let the queued batch flush once the queue is free expect(resolver).toHaveBeenCalledTimes(2);
queueBusy = false; const firstArgs = resolver.mock.calls[0][0];
await vi.advanceTimersByTimeAsync(200); const secondArgs = resolver.mock.calls[1][0];
expect(firstArgs.Body).toContain(
expect(resolver).toHaveBeenCalledTimes(1);
const args = resolver.mock.calls[0][0];
expect(args.Body).toContain(
"[WhatsApp +1 2025-01-01 00:00] [clawdis] first", "[WhatsApp +1 2025-01-01 00:00] [clawdis] first",
); );
expect(args.Body).toContain( expect(firstArgs.Body).not.toContain("second");
expect(secondArgs.Body).toContain(
"[WhatsApp +1 2025-01-01 01:00] [clawdis] second", "[WhatsApp +1 2025-01-01 01:00] [clawdis] second",
); );
expect(secondArgs.Body).not.toContain("first");
// Max listeners bumped to avoid warnings in multi-instance test runs // Max listeners bumped to avoid warnings in multi-instance test runs
expect(process.getMaxListeners?.()).toBeGreaterThanOrEqual(50); expect(process.getMaxListeners?.()).toBeGreaterThanOrEqual(50);
queueSpy.mockRestore();
process.setMaxListeners?.(originalMax); process.setMaxListeners?.(originalMax);
vi.useRealTimers();
await store.cleanup(); await store.cleanup();
}); });

View File

@@ -768,9 +768,6 @@ export async function monitorWebProvider(
const MESSAGE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes without any messages const MESSAGE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes without any messages
const WATCHDOG_CHECK_MS = 60 * 1000; // Check every minute const WATCHDOG_CHECK_MS = 60 * 1000; // Check every minute
// Batch inbound messages per conversation while command queue is busy.
type PendingBatch = { messages: WebInboundMsg[]; timer?: NodeJS.Timeout };
const pendingBatches = new Map<string, PendingBatch>();
const backgroundTasks = new Set<Promise<unknown>>(); const backgroundTasks = new Set<Promise<unknown>>();
const buildLine = (msg: WebInboundMsg) => { const buildLine = (msg: WebInboundMsg) => {
@@ -799,20 +796,11 @@ export async function monitorWebProvider(
}); });
}; };
const processBatch = async (conversationId: string) => { const processMessage = async (msg: WebInboundMsg) => {
const batch = pendingBatches.get(conversationId); const conversationId = msg.conversationId ?? msg.from;
if (!batch || batch.messages.length === 0) return; let combinedBody = buildLine(msg);
if (getQueueSize() > 0) {
batch.timer = setTimeout(() => processBatch(conversationId), 150);
return;
}
pendingBatches.delete(conversationId);
const messages = batch.messages; if (msg.chatType === "group") {
const latest = messages[messages.length - 1];
let combinedBody = messages.map(buildLine).join("\n");
if (latest.chatType === "group") {
const history = groupHistories.get(conversationId) ?? []; const history = groupHistories.get(conversationId) ?? [];
const historyWithoutCurrent = const historyWithoutCurrent =
history.length > 0 ? history.slice(0, -1) : []; history.length > 0 ? history.slice(0, -1) : [];
@@ -827,13 +815,13 @@ export async function monitorWebProvider(
}), }),
) )
.join("\\n"); .join("\\n");
combinedBody = `[Chat messages since your last reply - for context]\\n${historyText}\\n\\n[Current message - respond to this]\\n${buildLine(latest)}`; combinedBody = `[Chat messages since your last reply - for context]\\n${historyText}\\n\\n[Current message - respond to this]\\n${buildLine(msg)}`;
} }
// Always surface who sent the triggering message so the agent can address them. // Always surface who sent the triggering message so the agent can address them.
const senderLabel = const senderLabel =
latest.senderName && latest.senderE164 msg.senderName && msg.senderE164
? `${latest.senderName} (${latest.senderE164})` ? `${msg.senderName} (${msg.senderE164})`
: (latest.senderName ?? latest.senderE164 ?? "Unknown"); : (msg.senderName ?? msg.senderE164 ?? "Unknown");
combinedBody = `${combinedBody}\\n[from: ${senderLabel}]`; combinedBody = `${combinedBody}\\n[from: ${senderLabel}]`;
// Clear stored history after using it // Clear stored history after using it
groupHistories.set(conversationId, []); groupHistories.set(conversationId, []);
@@ -841,46 +829,45 @@ export async function monitorWebProvider(
// Echo detection uses combined body so we don't respond twice. // Echo detection uses combined body so we don't respond twice.
if (recentlySent.has(combinedBody)) { if (recentlySent.has(combinedBody)) {
logVerbose(`Skipping auto-reply: detected echo for combined batch`); logVerbose(`Skipping auto-reply: detected echo for combined message`);
recentlySent.delete(combinedBody); recentlySent.delete(combinedBody);
return; return;
} }
const correlationId = latest.id ?? newConnectionId(); const correlationId = msg.id ?? newConnectionId();
replyLogger.info( replyLogger.info(
{ {
connectionId, connectionId,
correlationId, correlationId,
from: latest.chatType === "group" ? conversationId : latest.from, from: msg.chatType === "group" ? conversationId : msg.from,
to: latest.to, to: msg.to,
body: elide(combinedBody, 240), body: elide(combinedBody, 240),
mediaType: latest.mediaType ?? null, mediaType: msg.mediaType ?? null,
mediaPath: latest.mediaPath ?? null, mediaPath: msg.mediaPath ?? null,
batchSize: messages.length,
}, },
"inbound web message (batched)", "inbound web message",
); );
const tsDisplay = latest.timestamp const tsDisplay = msg.timestamp
? new Date(latest.timestamp).toISOString() ? new Date(msg.timestamp).toISOString()
: new Date().toISOString(); : new Date().toISOString();
const fromDisplay = const fromDisplay =
latest.chatType === "group" ? conversationId : latest.from; msg.chatType === "group" ? conversationId : msg.from;
console.log( console.log(
`\n[${tsDisplay}] ${fromDisplay} -> ${latest.to}: ${combinedBody}`, `\n[${tsDisplay}] ${fromDisplay} -> ${msg.to}: ${combinedBody}`,
); );
if (latest.chatType !== "group") { if (msg.chatType !== "group") {
const sessionCfg = cfg.inbound?.session; const sessionCfg = cfg.inbound?.session;
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
const storePath = resolveStorePath(sessionCfg?.store); const storePath = resolveStorePath(sessionCfg?.store);
const to = (() => { const to = (() => {
if (latest.senderE164) return normalizeE164(latest.senderE164); if (msg.senderE164) return normalizeE164(msg.senderE164);
// In direct chats, `latest.from` is already the canonical conversation id, // In direct chats, `msg.from` is already the canonical conversation id,
// which is an E.164 string (e.g. "+1555"). Only fall back to JID parsing // which is an E.164 string (e.g. "+1555"). Only fall back to JID parsing
// when we were handed a JID-like string. // when we were handed a JID-like string.
if (latest.from.includes("@")) return jidToE164(latest.from); if (msg.from.includes("@")) return jidToE164(msg.from);
return normalizeE164(latest.from); return normalizeE164(msg.from);
})(); })();
if (to) { if (to) {
const task = updateLastRoute({ const task = updateLastRoute({
@@ -904,21 +891,21 @@ export async function monitorWebProvider(
const replyResult = await (replyResolver ?? getReplyFromConfig)( const replyResult = await (replyResolver ?? getReplyFromConfig)(
{ {
Body: combinedBody, Body: combinedBody,
From: latest.from, From: msg.from,
To: latest.to, To: msg.to,
MessageSid: latest.id, MessageSid: msg.id,
MediaPath: latest.mediaPath, MediaPath: msg.mediaPath,
MediaUrl: latest.mediaUrl, MediaUrl: msg.mediaUrl,
MediaType: latest.mediaType, MediaType: msg.mediaType,
ChatType: latest.chatType, ChatType: msg.chatType,
GroupSubject: latest.groupSubject, GroupSubject: msg.groupSubject,
GroupMembers: latest.groupParticipants?.join(", "), GroupMembers: msg.groupParticipants?.join(", "),
SenderName: latest.senderName, SenderName: msg.senderName,
SenderE164: latest.senderE164, SenderE164: msg.senderE164,
Surface: "whatsapp", Surface: "whatsapp",
}, },
{ {
onReplyStart: latest.sendComposing, onReplyStart: msg.sendComposing,
}, },
); );
@@ -949,7 +936,7 @@ export async function monitorWebProvider(
try { try {
await deliverWebReply({ await deliverWebReply({
replyResult: replyPayload, replyResult: replyPayload,
msg: latest, msg,
maxMediaBytes, maxMediaBytes,
replyLogger, replyLogger,
runtime, runtime,
@@ -958,7 +945,7 @@ export async function monitorWebProvider(
if (replyPayload.text) { if (replyPayload.text) {
recentlySent.add(replyPayload.text); recentlySent.add(replyPayload.text);
recentlySent.add(combinedBody); // Prevent echo on the batch text itself recentlySent.add(combinedBody); // Prevent echo on the combined text itself
logVerbose( logVerbose(
`Added to echo detection set (size now: ${recentlySent.size}): ${replyPayload.text.substring(0, 50)}...`, `Added to echo detection set (size now: ${recentlySent.size}): ${replyPayload.text.substring(0, 50)}...`,
); );
@@ -969,13 +956,13 @@ export async function monitorWebProvider(
} }
const fromDisplay = const fromDisplay =
latest.chatType === "group" msg.chatType === "group"
? conversationId ? conversationId
: (latest.from ?? "unknown"); : (msg.from ?? "unknown");
if (isVerbose()) { if (isVerbose()) {
console.log( console.log(
success( success(
`↩️ Auto-replied to ${fromDisplay} (web${replyPayload.mediaUrl || replyPayload.mediaUrls?.length ? ", media" : ""}; batched ${messages.length})`, `↩️ Auto-replied to ${fromDisplay} (web${replyPayload.mediaUrl || replyPayload.mediaUrls?.length ? ", media" : ""})`,
), ),
); );
} else { } else {
@@ -988,25 +975,13 @@ export async function monitorWebProvider(
} catch (err) { } catch (err) {
console.error( console.error(
danger( danger(
`Failed sending web auto-reply to ${latest.from ?? conversationId}: ${String(err)}`, `Failed sending web auto-reply to ${msg.from ?? conversationId}: ${String(err)}`,
), ),
); );
} }
} }
}; };
const enqueueBatch = async (msg: WebInboundMsg) => {
const key = msg.conversationId ?? msg.from;
const bucket = pendingBatches.get(key) ?? { messages: [] };
bucket.messages.push(msg);
pendingBatches.set(key, bucket);
if (getQueueSize() === 0) {
await processBatch(key);
} else {
bucket.timer = bucket.timer ?? setTimeout(() => processBatch(key), 150);
}
};
const listener = await (listenerFactory ?? monitorWebInbox)({ const listener = await (listenerFactory ?? monitorWebInbox)({
verbose, verbose,
onMessage: async (msg) => { onMessage: async (msg) => {
@@ -1060,7 +1035,7 @@ export async function monitorWebProvider(
} }
} }
return enqueueBatch(msg); return processMessage(msg);
}, },
}); });