From dff9d88abb79ab25c098e404af9ba4291a0f878c Mon Sep 17 00:00:00 2001 From: duckvhuynh Date: Sat, 1 Aug 2026 18:47:52 +0700 Subject: [PATCH 1/2] fix(session): make eager status backfill opt-in --- .env.example | 4 ++++ docker-compose.yml | 4 ++++ docs/12-troubleshooting-faq.md | 20 ++++++++++++++++++++ src/modules/session/session.service.spec.ts | 16 ++++++++++++++++ src/modules/session/session.service.ts | 17 ++++++++++++++++- 5 files changed, 60 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 62ba89468..0bc020de9 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,10 @@ LOG_LEVEL=info # error | warn | info | debug # docker-compose.yml did not forward it before then (#985), so setting it on an earlier version had # no effect and sessions stayed DISCONNECTED on boot regardless. AUTO_START_SESSIONS=false +# Fetch active statuses that predate a connection as soon as a session reaches READY. Disabled by +# default because the eager status@broadcast read can make WhatsApp revoke some freshly paired +# companions at their first scheduled Web reload. Live status events are unaffected. +STATUS_SEED_ON_READY=false # 0 = unlimited. Set a positive integer to cap concurrently running/initializing sessions. # A FAILED session is evicted by design (no live engine), so it does not hold a concurrency slot. MAX_CONCURRENT_SESSIONS=0 diff --git a/docker-compose.yml b/docker-compose.yml index 2e544bb0d..df80c7ca9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -155,6 +155,10 @@ services: # "true", so a blank forward (nothing set) keeps the default off. Without this line the value in # .env never reaches the container and the flag silently does nothing. - AUTO_START_SESSIONS=${AUTO_START_SESSIONS:-} + # Fetching status@broadcast immediately after a fresh pairing can make affected accounts lose + # the companion at WhatsApp Web's first scheduled reload. Disabled by default; live statuses + # still arrive normally. Opt in only after validating the account. + - STATUS_SEED_ON_READY=${STATUS_SEED_ON_READY:-false} # Storage. Blank-forwarded (see Database note) so a dashboard local↔S3 switch applies; a real # host value pins. Credentials use the canonical S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY names the # app and dashboard write; the legacy S3_ACCESS_KEY / S3_SECRET_KEY are ALSO forwarded (and read diff --git a/docs/12-troubleshooting-faq.md b/docs/12-troubleshooting-faq.md index 5647575a1..8afe9bbb2 100644 --- a/docs/12-troubleshooting-faq.md +++ b/docs/12-troubleshooting-faq.md @@ -461,6 +461,26 @@ is nothing to SIGKILL). A browser left wedged by an earlier teardown is reaped a next `start()` (an orphan sweep keyed on the session's browser marker runs at every engine launch), so the stop → start sequence alone is sufficient once the engine is gone. +### Issue: Freshly paired session logs out at the first five-minute reload + +> **Engine:** This issue applies to the `whatsapp-web.js` engine only. + +**Symptoms:** The session reaches `ready` normally, then at almost exactly five minutes WhatsApp Web +reloads, briefly returns to `CONNECTED`/`hasSynced`, and navigates to +`?post_logout=1&logout_reason=0`. The phone silently removes the companion from Linked devices and +OpenWA returns to a fresh QR because `whatsapp-web.js` deletes LocalAuth credentials on `LOGOUT`. + +**Cause:** OpenWA used to backfill active WhatsApp Status posts immediately in its `ready` callback by +fetching `status@broadcast`. Some accounts tolerate that request, but affected accounts have the new +companion revoked at WhatsApp Web's first scheduled reload. A minimal `whatsapp-web.js` client using +the same container, browser, account and Web build remains linked when it does not perform this eager +fetch. + +**Fix:** Status backfill-on-ready is disabled by default. Keep `STATUS_SEED_ON_READY=false` for +affected accounts. Live status events still work; only the one-time history backfill of statuses that +predate the connection is skipped. Operators who have tested their accounts and need the backfill can +opt in with `STATUS_SEED_ON_READY=true`. + ### Issue: Session stuck at `action_required` ("What's new" onboarding modal) > **Engine:** This issue applies to the `whatsapp-web.js` engine only (Chromium/Puppeteer-based). It does not affect `ENGINE_TYPE=baileys`. diff --git a/src/modules/session/session.service.spec.ts b/src/modules/session/session.service.spec.ts index 950d52b02..fe42d1dca 100644 --- a/src/modules/session/session.service.spec.ts +++ b/src/modules/session/session.service.spec.ts @@ -81,6 +81,7 @@ describe('SessionService', () => { let mockEngine: Record; beforeEach(async () => { + delete process.env.STATUS_SEED_ON_READY; repository = { count: jest.fn(), find: jest.fn(), @@ -3467,7 +3468,18 @@ describe('SessionService', () => { expect(auth[0][2]).toMatchObject({ sessionId: 'sess-uuid-1', phone: '628123', pushName: 'Alice' }); }); + it('does not fetch status history on ready by default', async () => { + const callbacks = await startAndCaptureCallbacks(); + + callbacks.onReady!('628123', 'Alice'); + await flush(); + + expect(mockEngine.getChatHistory).not.toHaveBeenCalled(); + expect(statusStore.ingest).not.toHaveBeenCalled(); + }); + it('seeds the status store from the status-broadcast chat history when the engine reports ready', async () => { + process.env.STATUS_SEED_ON_READY = 'true'; const callbacks = await startAndCaptureCallbacks(); const nowSec = Math.floor(Date.now() / 1000); mockEngine.getChatHistory.mockResolvedValue([ @@ -3563,6 +3575,7 @@ describe('SessionService', () => { }); it('seeds status media downloaded with the history so it renders like a live post', async () => { + process.env.STATUS_SEED_ON_READY = 'true'; const callbacks = await startAndCaptureCallbacks(); const media = { mimetype: 'image/png', data: 'QUJD' }; mockEngine.getChatHistory.mockResolvedValue([ @@ -3590,6 +3603,7 @@ describe('SessionService', () => { }); it('skips the account’s own (fromMe) statuses and statuses older than 24h when seeding', async () => { + process.env.STATUS_SEED_ON_READY = 'true'; const callbacks = await startAndCaptureCallbacks(); const nowSec = Math.floor(Date.now() / 1000); mockEngine.getChatHistory.mockResolvedValue([ @@ -3624,6 +3638,7 @@ describe('SessionService', () => { }); it('keeps seeding the remaining statuses when one item’s ingest fails', async () => { + process.env.STATUS_SEED_ON_READY = 'true'; const callbacks = await startAndCaptureCallbacks(); const nowSec = Math.floor(Date.now() / 1000); const seedItem = (id: string, author: string) => @@ -3657,6 +3672,7 @@ describe('SessionService', () => { }); it('swallows a status-history failure on ready (e.g. Baileys has no status chat) without throwing', async () => { + process.env.STATUS_SEED_ON_READY = 'true'; const callbacks = await startAndCaptureCallbacks(); mockEngine.getChatHistory.mockRejectedValue(new Error('not supported')); diff --git a/src/modules/session/session.service.ts b/src/modules/session/session.service.ts index 20a5db989..87c580b39 100644 --- a/src/modules/session/session.service.ts +++ b/src/modules/session/session.service.ts @@ -63,6 +63,14 @@ import { HookManager } from '../../core/hooks'; // proves 50 too few. const STATUS_SEED_LIMIT = 50; +/** + * Eager status-history reads are opt-in. On affected freshly paired whatsapp-web.js accounts, + * fetching status@broadcast before WhatsApp Web's first scheduled reload makes WhatsApp revoke the + * companion at that reload. Live status events remain unaffected when this backfill is disabled. + */ +const isStatusSeedOnReadyEnabled = (): boolean => + ['true', '1', 'yes', 'on'].includes((process.env.STATUS_SEED_ON_READY ?? 'false').trim().toLowerCase()); + interface ReconnectState extends ReconnectAttemptState { /** The pending attempt's timer. Lives here, not in the policy, which stays free of side effects. */ timer: NodeJS.Timeout | null; @@ -1284,7 +1292,14 @@ export class SessionService implements OnModuleDestroy, OnModuleInit, OnApplicat // Best-effort snapshot of the account's own contacts' currently-active statuses. Live status // posts arrive through onMessage below; this just backfills what was already up before we // connected. Not awaited — onReady must not block on it. - void this.seedStatuses(id, engine); + if (isStatusSeedOnReadyEnabled()) { + void this.seedStatuses(id, engine); + } else { + this.logger.debug('Status backfill on session ready is disabled', { + sessionId: id, + action: 'status_seed_on_ready_disabled', + }); + } } /** From 2bdc7d7c425176bc5ee842908cfe428de534f45f Mon Sep 17 00:00:00 2001 From: duckvhuynh Date: Mon, 3 Aug 2026 16:11:51 +0700 Subject: [PATCH 2/2] fix(config): validate status seed flag --- src/config/env.validation.spec.ts | 10 +++++++++- src/config/env.validation.ts | 1 + .../session/session-engine-lifecycle.service.ts | 3 +-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/config/env.validation.spec.ts b/src/config/env.validation.spec.ts index 44853c338..550de358f 100644 --- a/src/config/env.validation.spec.ts +++ b/src/config/env.validation.spec.ts @@ -133,13 +133,21 @@ describe('validateEnv', () => { expect(() => validateEnv({ QUEUE_ENABLED: '1' })).toThrow(/QUEUE_ENABLED/); expect(() => validateEnv({ MCP_ENABLED: 'yes' })).toThrow(/MCP_ENABLED/); expect(() => validateEnv({ SERVE_DASHBOARD: 'no' })).toThrow(/SERVE_DASHBOARD/); + expect(() => validateEnv({ STATUS_SEED_ON_READY: 'yes' })).toThrow(/STATUS_SEED_ON_READY/); // The raw value is checked, NOT a trimmed one: a trailing space / CR (Windows-edited env file // forwarded verbatim by `docker run --env-file`) must still be rejected — otherwise the flag reads // false at every `=== 'true'` site while validation passes, giving false assurance. expect(() => validateEnv({ QUEUE_ENABLED: 'true ' })).toThrow(/QUEUE_ENABLED/); expect(() => validateEnv({ MCP_ENABLED: 'true\r' })).toThrow(/MCP_ENABLED/); // Canonical values, unset, and blank (a compose `${KEY:-}` forward renders '') all pass. - expect(() => validateEnv({ QUEUE_ENABLED: 'true', MCP_ENABLED: 'false', SERVE_DASHBOARD: 'true' })).not.toThrow(); + expect(() => + validateEnv({ + QUEUE_ENABLED: 'true', + MCP_ENABLED: 'false', + SERVE_DASHBOARD: 'true', + STATUS_SEED_ON_READY: 'false', + }), + ).not.toThrow(); expect(() => validateEnv({ QUEUE_ENABLED: '', SERVE_DASHBOARD: '' })).not.toThrow(); expect(() => validateEnv({})).not.toThrow(); }); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index a3ba82765..e834c3a6c 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -233,6 +233,7 @@ export function validateEnv(config: EnvConfig): EnvConfig { 'MCP_ENABLED', 'SERVE_DASHBOARD', 'AUTO_START_SESSIONS', + 'STATUS_SEED_ON_READY', 'STORE_EPHEMERAL_MESSAGES', 'RESOLVE_LID_TO_PHONE', 'SIMULATE_TYPING', diff --git a/src/modules/session/session-engine-lifecycle.service.ts b/src/modules/session/session-engine-lifecycle.service.ts index 253f5e24a..6389f22c9 100644 --- a/src/modules/session/session-engine-lifecycle.service.ts +++ b/src/modules/session/session-engine-lifecycle.service.ts @@ -32,8 +32,7 @@ import { SessionEngineControls } from './session-engine-controls'; * fetching status@broadcast before WhatsApp Web's first scheduled reload makes WhatsApp revoke the * companion at that reload. Live status events remain unaffected when this backfill is disabled. */ -const isStatusSeedOnReadyEnabled = (): boolean => - ['true', '1', 'yes', 'on'].includes((process.env.STATUS_SEED_ON_READY ?? 'false').trim().toLowerCase()); +const isStatusSeedOnReadyEnabled = (): boolean => process.env.STATUS_SEED_ON_READY === 'true'; // Message types that carry downloadable media. Any persisted row of these types must have a media // marker in metadata — never NULL — or the dashboard renders an empty bubble (no placeholder) and the