Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ LOG_LEVEL=info # error | warn | info | debug
# (docker-compose.dev.yml: `${AUTO_START_SESSIONS:-true}`), which is its intended convenience —
# uncommenting the line here turns that off again.
# 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
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions docs/12-troubleshooting-faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
10 changes: 9 additions & 1 deletion src/config/env.validation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
1 change: 1 addition & 0 deletions src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
16 changes: 15 additions & 1 deletion src/modules/session/session-engine-lifecycle.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ import { SessionEngineLeafEvents } from './session-engine-leaf-events';
import { SessionEngineEventWiring, SessionEngineWiringHost } from './session-engine-event-wiring';
import { SessionEngineControls } from './session-engine-controls';

/**
* 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 => 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
// by-type stats filter skips the row. Sources that lack the payload (wwjs own-send echo, media-free
Expand Down Expand Up @@ -597,7 +604,14 @@ export class SessionEngineLifecycle {
// 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',
});
}
}

/**
Expand Down
16 changes: 16 additions & 0 deletions src/modules/session/session.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ describe('SessionService', () => {
let mockEngine: Record<string, jest.Mock>;

beforeEach(async () => {
delete process.env.STATUS_SEED_ON_READY;
repository = {
count: jest.fn(),
find: jest.fn(),
Expand Down Expand Up @@ -3473,7 +3474,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([
Expand Down Expand Up @@ -3569,6 +3581,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([
Expand Down Expand Up @@ -3596,6 +3609,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([
Expand Down Expand Up @@ -3630,6 +3644,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) =>
Expand Down Expand Up @@ -3663,6 +3678,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'));

Expand Down