diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index fc48794bb..0a503cb99 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -202,6 +202,58 @@ describe("partial reply gating", () => { expect(reply).toHaveBeenCalledWith("final reply"); }); + it("updates last-route for direct chats without senderE164", async () => { + const now = Date.now(); + const store = await makeSessionStore({ + main: { sessionId: "sid", updatedAt: now - 1 }, + }); + + const replyResolver = vi.fn().mockResolvedValue(undefined); + + const mockConfig: ClawdisConfig = { + inbound: { + allowFrom: ["*"], + reply: { + mode: "command", + session: { store: store.storePath, mainKey: "main" }, + }, + }, + }; + + setLoadConfigMock(mockConfig); + + await monitorWebProvider( + false, + async ({ onMessage }) => { + await onMessage({ + id: "m1", + from: "+1000", + conversationId: "+1000", + to: "+2000", + body: "hello", + timestamp: now, + chatType: "direct", + chatId: "direct:+1000", + sendComposing: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue(undefined), + sendMedia: vi.fn().mockResolvedValue(undefined), + }); + return { close: vi.fn().mockResolvedValue(undefined) }; + }, + false, + replyResolver, + ); + + const stored = JSON.parse(await fs.readFile(store.storePath, "utf8")) as { + main?: { lastChannel?: string; lastTo?: string }; + }; + expect(stored.main?.lastChannel).toBe("whatsapp"); + expect(stored.main?.lastTo).toBe("+1000"); + + resetLoadConfigMock(); + await store.cleanup(); + }); + it("defaults to self-only when no config is present", async () => { const cfg: ClawdisConfig = { inbound: { @@ -661,6 +713,10 @@ describe("web auto-reply", () => { const originalMax = process.getMaxListeners(); process.setMaxListeners?.(1); // force low to confirm bump + const store = await makeSessionStore({ + main: { sessionId: "sid", updatedAt: Date.now() }, + }); + const sendMedia = vi.fn(); const reply = vi.fn().mockResolvedValue(undefined); const sendComposing = vi.fn(); @@ -684,7 +740,12 @@ describe("web auto-reply", () => { .spyOn(commandQueue, "getQueueSize") .mockImplementation(() => (queueBusy ? 1 : 0)); - setLoadConfigMock(() => ({ inbound: { timestampPrefix: "UTC" } })); + setLoadConfigMock(() => ({ + inbound: { + timestampPrefix: "UTC", + reply: { mode: "command", session: { store: store.storePath } }, + }, + })); await monitorWebProvider(false, listenerFactory, false, resolver); expect(capturedOnMessage).toBeDefined(); @@ -713,7 +774,7 @@ describe("web auto-reply", () => { // Let the queued batch flush once the queue is free queueBusy = false; - vi.advanceTimersByTime(200); + await vi.advanceTimersByTimeAsync(200); expect(resolver).toHaveBeenCalledTimes(1); const args = resolver.mock.calls[0][0]; @@ -730,6 +791,7 @@ describe("web auto-reply", () => { queueSpy.mockRestore(); process.setMaxListeners?.(originalMax); vi.useRealTimers(); + await store.cleanup(); }); it("falls back to text when media send fails", async () => { diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index aa84f1071..e762f4b52 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -783,7 +783,7 @@ export async function monitorWebProvider( const batch = pendingBatches.get(conversationId); if (!batch || batch.messages.length === 0) return; if (getQueueSize() > 0) { - batch.timer = setTimeout(() => void processBatch(conversationId), 150); + batch.timer = setTimeout(() => processBatch(conversationId), 150); return; } pendingBatches.delete(conversationId); @@ -854,15 +854,25 @@ export async function monitorWebProvider( const sessionCfg = cfg.inbound?.reply?.session; const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; const storePath = resolveStorePath(sessionCfg?.store); - const to = latest.senderE164 - ? normalizeE164(latest.senderE164) - : jidToE164(latest.from); + const to = (() => { + if (latest.senderE164) return normalizeE164(latest.senderE164); + // In direct chats, `latest.from` is already the canonical conversation id, + // which is an E.164 string (e.g. "+1555"). Only fall back to JID parsing + // when we were handed a JID-like string. + if (latest.from.includes("@")) return jidToE164(latest.from); + return normalizeE164(latest.from); + })(); if (to) { - await updateLastRoute({ + void updateLastRoute({ storePath, sessionKey: mainKey, channel: "whatsapp", to, + }).catch((err) => { + replyLogger.warn( + { error: String(err), storePath, sessionKey: mainKey, to }, + "failed updating last route", + ); }); } } @@ -969,8 +979,7 @@ export async function monitorWebProvider( if (getQueueSize() === 0) { await processBatch(key); } else { - bucket.timer = - bucket.timer ?? setTimeout(() => void processBatch(key), 150); + bucket.timer = bucket.timer ?? setTimeout(() => processBatch(key), 150); } }; @@ -1401,6 +1410,12 @@ export async function monitorWebProvider( }, "web reconnect: max attempts reached; continuing in degraded mode", ); + runtime.error( + danger( + `WhatsApp Web reconnect: max attempts reached (${reconnectAttempts}/${reconnectPolicy.maxAttempts}). Stopping web monitoring.`, + ), + ); + await closeListener(); break; }