fix: remove WhatsApp batching delay
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user