Fix WhatsApp overlap by serial queue, typing, and outbound delivery.

Serialize per-session inbound turns and merge follow-ups after the active run; show composing while busy and enqueue final replies so long agent runs still reach the group.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-22 10:04:25 +08:00
parent f1ed86537c
commit 2446993b11
6 changed files with 801 additions and 79 deletions

View file

@ -35,6 +35,14 @@ const PROXY_URL = (
).trim();
const MEDIA_MAX_BYTES = Number(process.env.OCLAW_WHATSAPP_MEDIA_MAX_BYTES || String(20 * 1024 * 1024)) || 20 * 1024 * 1024;
const MEDIA_DOWNLOAD_TIMEOUT_MS = Number(process.env.OCLAW_WHATSAPP_MEDIA_TIMEOUT_MS || "90000") || 90_000;
const TYPING_ENABLED =
String(process.env.OCLAW_WHATSAPP_TYPING || "1").trim() !== "0" &&
String(process.env.OCLAW_WHATSAPP_TYPING || "1").trim().toLowerCase() !== "false" &&
String(process.env.OCLAW_WHATSAPP_TYPING || "1").trim().toLowerCase() !== "off";
const TYPING_HEARTBEAT_MS = Math.max(
3000,
Number(process.env.OCLAW_WHATSAPP_TYPING_HEARTBEAT_MS || "8000") || 8000,
);
function log(msg: string): void {
process.stdout.write(`${new Date().toISOString()} [baileys-whatsapp] ${msg}\n`);
@ -916,11 +924,22 @@ async function pollOutboundQueue(sock: ReturnType<typeof makeWASocket>): Promise
`outbound sent id=${id} chat=${chatId} attachments=${attachments.length} mediaPath=${mediaPath ? "yes" : "no"} text=${text.slice(0, 80)}`,
);
} else {
const sendContent = buildOutboundSendContent(text, source, sock, chatId);
const sent = await sock.sendMessage(chatId, sendContent);
const meta = decodeOutboundSource(source);
const content = buildMentionedOutboundText(text, meta, sock, chatId);
const quoted = buildQuotedMessage({
chatId: String((meta as any).quote_remote_jid || (meta as any).quoteRemoteJid || chatId).trim(),
stanzaId: String((meta as any).quote_stanza_id || (meta as any).quoteStanzaId || "").trim(),
participant: String((meta as any).quote_participant || (meta as any).quoteParticipant || "").trim(),
quoteText: String((meta as any).quote_text || (meta as any).quoteText || "").trim(),
});
const sent = await sock.sendMessage(
chatId,
content as any,
quoted ? { quoted } : undefined,
);
stanzaId = String((sent as any)?.key?.id || "").trim();
log(
`outbound sent id=${id} chat=${chatId} stanza=${stanzaId || "?"} mentions=${(sendContent.mentions || []).length} mention0=${(sendContent.mentions || [])[0] || ""} text=${sendContent.text.slice(0, 80)}`,
`outbound sent id=${id} chat=${chatId} stanza=${stanzaId || "?"} mentions=${(content.mentions || []).length} mention0=${(content.mentions || [])[0] || ""} text=${content.text.slice(0, 80)}`,
);
}
try {
@ -953,7 +972,7 @@ async function pollOutboundQueue(sock: ReturnType<typeof makeWASocket>): Promise
}
function startOutboundPoller(getSock: () => ReturnType<typeof makeWASocket> | null): void {
const intervalMs = Number(process.env.OCLAW_WHATSAPP_OUTBOUND_POLL_MS || "2500") || 2500;
const intervalMs = Number(process.env.OCLAW_WHATSAPP_OUTBOUND_POLL_MS || "1000") || 1000;
setInterval(() => {
const s = getSock();
if (!s) return;
@ -965,6 +984,56 @@ function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
type TypingEntry = { refs: number; timer: ReturnType<typeof setInterval> | null };
const typingByChat = new Map<string, TypingEntry>();
async function sendTypingPresence(
sock: ReturnType<typeof makeWASocket> | null,
chatId: string,
state: "composing" | "paused",
): Promise<void> {
const s = sock;
const jid = String(chatId || "").trim();
if (!s || !jid || !TYPING_ENABLED) return;
try {
await s.sendPresenceUpdate(state, jid);
} catch (err) {
if (VERBOSE) log(`presence ${state} failed chat=${jid}: ${String(err).slice(0, 120)}`);
}
}
function startTypingHeartbeat(sock: ReturnType<typeof makeWASocket> | null, chatId: string): void {
const jid = String(chatId || "").trim();
if (!sock || !jid || !TYPING_ENABLED) return;
let entry = typingByChat.get(jid);
if (!entry) {
entry = { refs: 0, timer: null };
typingByChat.set(jid, entry);
}
entry.refs += 1;
if (entry.refs === 1) {
void sendTypingPresence(sock, jid, "composing");
entry.timer = setInterval(() => {
void sendTypingPresence(sock, jid, "composing");
}, TYPING_HEARTBEAT_MS);
}
}
function stopTypingHeartbeat(sock: ReturnType<typeof makeWASocket> | null, chatId: string): void {
const jid = String(chatId || "").trim();
if (!jid || !TYPING_ENABLED) return;
const entry = typingByChat.get(jid);
if (!entry) return;
entry.refs = Math.max(0, entry.refs - 1);
if (entry.refs > 0) return;
if (entry.timer) {
clearInterval(entry.timer);
entry.timer = null;
}
typingByChat.delete(jid);
void sendTypingPresence(sock, jid, "paused");
}
function _ipLooksHijackedOrUnroutable(ip: string): boolean {
const s = String(ip || "").trim();
if (!s) return false;
@ -1199,9 +1268,20 @@ async function main(): Promise<void> {
} else if (VERBOSE || attachments.length) {
log(`inbound posting chat=${chatId} user=${userId} textLen=${text.length} attachments=${attachments.length}`);
}
const out = await postInbound(inbound);
startTypingHeartbeat(s, chatId);
let out: Json = {};
try {
out = await postInbound(inbound);
} finally {
stopTypingHeartbeat(s, chatId);
}
const replies = Array.isArray(out.replies) ? (out.replies as Json[]) : [];
if (isGroup || VERBOSE) log(`inbound ok chat=${chatId} replies=${replies.length}`);
const delivery = String((out as any).delivery || "").trim().toLowerCase();
if (isGroup || VERBOSE || delivery === "queued" || delivery === "accepted_queued") {
log(
`inbound ok chat=${chatId} replies=${replies.length} delivery=${delivery || "sync"} outboundId=${String((out as any).outbound_message_id || "")}`,
);
}
for (const r of replies) {
const outText = String((r as any).text || "").trim();
if (!shouldSendOutboundText(outText) && !(Array.isArray((r as any).attachments) && (r as any).attachments.length)) {