Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ plugins instead of in core (#265).

### Changed

- ⚠️ **Node.js ≥ 20.19 now required.** The Baileys engine dependency (`@whiskeysockets/baileys`) is ESM-only and is loaded at startup, so OpenWA now requires Node ≥ 20.19 (for `require()` of ESM). Operators on older Node must upgrade. (#299)
- **Baileys engine loads lazily** `@whiskeysockets/baileys` is imported via a dynamic `import()` only when the Baileys engine is actually used (`ENGINE_TYPE=baileys`). Operators using the default whatsapp-web.js engine are unaffected and there is no global Node version floor. (#299)
- Engine config is now **opaque per-engine**: `EngineFactory` passes only engine-neutral fields
(`sessionId`/`proxyUrl`/`proxyType`) to an engine plugin and supplies engine-specific config (Puppeteer
for whatsapp-web.js) as a blob via the plugin context, so a non-browser engine can be added without the
Expand Down
5 changes: 1 addition & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
{
"name": "openwa",
"version": "0.2.10",
"engines": {
"node": ">=20.19.0"
},
"description": "Open Source WhatsApp API Gateway - Free, Self-Hosted HTTP API for WhatsApp",
"author": "Yudhi Armyndharis & OpenWA Contributors",
"private": true,
Expand Down Expand Up @@ -112,7 +109,7 @@
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
"^.+\\.(t|j)s$": ["ts-jest", { "tsconfig": { "module": "CommonJS", "moduleResolution": "node", "resolvePackageJsonExports": false } }]
},
"moduleNameMapper": {
"^@whiskeysockets/baileys$": "<rootDir>/../test/__mocks__/@whiskeysockets/baileys.ts",
Expand Down
18 changes: 18 additions & 0 deletions src/engine/adapters/baileys-message-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ export interface BaileysIncomingFields {
pushName?: string;
/** The account's own normalized JID, for from/to on outgoing messages. */
selfJid?: string;
/** Pre-extracted media: mimetype + base64 data (+ optional filename). Populated by the adapter. */
media?: IncomingMessage['media'];
/** Pre-extracted location. Populated by the adapter for `locationMessage`. */
location?: IncomingMessage['location'];
/** Pre-extracted quoted message context. Populated by the adapter when `contextInfo` is present. */
quotedMessage?: IncomingMessage['quotedMessage'];
}

/**
Expand Down Expand Up @@ -117,5 +123,17 @@ export function buildIncomingMessageFromBaileys(fields: BaileysIncomingFields):
incoming.contact = { pushName: fields.pushName };
}

if (fields.media) {
incoming.media = fields.media;
}

if (fields.location) {
incoming.location = fields.location;
}

if (fields.quotedMessage) {
incoming.quotedMessage = fields.quotedMessage;
}

return incoming;
}
73 changes: 71 additions & 2 deletions src/engine/adapters/baileys-message-store.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,40 @@
import { DataSource, Repository } from 'typeorm';
import { BaileysStoredMessage } from './baileys-stored-message.entity';
import { BaileysMessageStoreService } from './baileys-message-store.service';
import { Session, SessionStatus } from '../../modules/session/entities/session.entity';

describe('BaileysMessageStoreService', () => {
let ds: DataSource;
let repo: Repository<BaileysStoredMessage>;
let service: BaileysMessageStoreService;

// Seed a sessions row so FK constraints (if SQLite enables them) resolve correctly.
const seedSession = async (id: string): Promise<void> => {
await ds.getRepository(Session).save(
ds.getRepository(Session).create({
id,
name: `session-${id}`,
status: SessionStatus.READY,
phone: null,
pushName: null,
config: {},
proxyUrl: null,
proxyType: null,
connectedAt: null,
lastActiveAt: null,
}),
);
};

beforeEach(async () => {
ds = new DataSource({ type: 'sqlite', database: ':memory:', entities: [BaileysStoredMessage], synchronize: true });
ds = new DataSource({
type: 'sqlite',
database: ':memory:',
// Session must be present so the @ManyToOne relation metadata resolves and synchronize
// can emit the CASCADE FK on the baileys_stored_messages table (I6).
entities: [BaileysStoredMessage, Session],
synchronize: true,
});
await ds.initialize();
repo = ds.getRepository(BaileysStoredMessage);
service = new BaileysMessageStoreService(repo);
Expand All @@ -27,26 +53,66 @@ describe('BaileysMessageStoreService', () => {
}) as unknown as Parameters<BaileysMessageStoreService['put']>[1];

it('round-trips a WAMessage through BufferJSON', async () => {
await seedSession('s1');
await service.put('s1', msg('M1'));
const got = await service.getMessage('s1', 'M1');
expect(got?.key?.id).toBe('M1');
expect(got?.message?.conversation).toBe('M1');
});

it('returns null for an unknown id and is session-scoped', async () => {
await seedSession('s1');
await service.put('s1', msg('M1'));
expect(await service.getMessage('s1', 'NOPE')).toBeNull();
expect(await service.getMessage('s2', 'M1')).toBeNull();
});

it('is idempotent on (sessionId, waMessageId)', async () => {
await seedSession('s1');
await service.put('s1', msg('M1'));
await service.put('s1', msg('M1'));
expect(await repo.count({ where: { sessionId: 's1' } })).toBe(1);
});

it('evicts oldest beyond the per-session cap', async () => {
/**
* C1 regression test — drives eviction through the REAL put() path so the stored
* createdAt value comes from the upsert payload (millisecond precision), not from
* SQLite's datetime('now') (second precision). Without the `createdAt: new Date()`
* fix in put(), the string comparison '…:XX' < '…:XX.000' evaluates TRUE for every
* same-second row, and enforceLimit() deletes ALL rows instead of keeping the cap.
*
* This test MUST FAIL against the old code (no explicit createdAt in upsert) and
* PASS with the fix.
*/
it('eviction via put() keeps exactly the cap — never wipes the store (C1)', async () => {
process.env.BAILEYS_MESSAGE_STORE_LIMIT = '3';
await seedSession('s_c1');
const s = new BaileysMessageStoreService(repo);

// Insert 6 messages via put() — each call sets createdAt: new Date(), so even within the
// same wall-clock second the stored values carry millisecond precision.
for (let i = 1; i <= 6; i++) {
await s.put('s_c1', msg(`C${i}`));
}

const count = await repo.count({ where: { sessionId: 's_c1' } });
// With the bug: count is 0 (all evicted). With the fix: count is exactly 3.
expect(count).toBe(3);

// The newest messages (C4, C5, C6) must survive — they have the latest createdAt.
expect(await s.getMessage('s_c1', 'C4')).not.toBeNull();
expect(await s.getMessage('s_c1', 'C5')).not.toBeNull();
expect(await s.getMessage('s_c1', 'C6')).not.toBeNull();

// The oldest messages must be evicted.
expect(await s.getMessage('s_c1', 'C1')).toBeNull();
expect(await s.getMessage('s_c1', 'C2')).toBeNull();
expect(await s.getMessage('s_c1', 'C3')).toBeNull();
});

it('evicts oldest beyond the per-session cap (pre-seeded rows, distinct timestamps)', async () => {
process.env.BAILEYS_MESSAGE_STORE_LIMIT = '2';
await seedSession('s1');
const s = new BaileysMessageStoreService(repo);
// Use distinct createdAt values so ordering is deterministic regardless of UUID tiebreaker.
const t0 = new Date('2024-01-01T00:00:00.000Z');
Expand All @@ -69,6 +135,7 @@ describe('BaileysMessageStoreService', () => {

it('keeps exactly limit rows when multiple share the same createdAt (tiebreaker via id)', async () => {
process.env.BAILEYS_MESSAGE_STORE_LIMIT = '2';
await seedSession('s2');
const s = new BaileysMessageStoreService(repo);
// Insert 3 rows with identical createdAt to stress the (createdAt, id) tiebreaker.
// With UUID primary keys, id ordering is lexicographic — we can only assert count, not which survive.
Expand All @@ -85,6 +152,8 @@ describe('BaileysMessageStoreService', () => {
});

it('clearSession removes only that session', async () => {
await seedSession('s1');
await seedSession('s2');
await service.put('s1', msg('M1'));
await service.put('s2', msg('M2'));
await service.clearSession('s1');
Expand Down
21 changes: 19 additions & 2 deletions src/engine/adapters/baileys-message-store.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BufferJSON } from '@whiskeysockets/baileys';
import type * as BaileysLib from '@whiskeysockets/baileys';
import type { WAMessage } from '@whiskeysockets/baileys';
import { BaileysStoredMessage } from './baileys-stored-message.entity';
import { BaileysMessageStore } from '../types/baileys.types';
Expand All @@ -13,6 +13,13 @@ function positiveIntFromEnv(name: string, fallback: number): number {

@Injectable()
export class BaileysMessageStoreService implements BaileysMessageStore {
/** Lazily loaded @whiskeysockets/baileys module (ESM-only; loaded on first use, not at boot). */
private baileysLib?: typeof BaileysLib;

private async loadLib(): Promise<typeof BaileysLib> {
return (this.baileysLib ??= await import('@whiskeysockets/baileys'));
}

constructor(
@InjectRepository(BaileysStoredMessage, 'data')
private readonly repo: Repository<BaileysStoredMessage>,
Expand All @@ -23,9 +30,18 @@ export class BaileysMessageStoreService implements BaileysMessageStore {
if (!waMessageId) {
return;
}
const { BufferJSON } = await this.loadLib();
const serializedMessage = JSON.stringify(msg, BufferJSON.replacer);
// Idempotent: the same message arrives from the send return AND the messages.upsert echo.
await this.repo.upsert({ sessionId, waMessageId, serializedMessage }, ['sessionId', 'waMessageId']);
// createdAt is set explicitly so the stored value carries millisecond precision — matching the
// :createdAt bound param used in enforceLimit(). Without this, SQLite's datetime('now') stores
// second-precision (e.g. '…:11') while the JS Date bound serializes as '…:11.000', and SQLite
// string-compares '…:11' < '…:11.000' = TRUE, causing every same-second row to be over-evicted
// and the store to be wiped to ~0 (C1).
await this.repo.upsert({ sessionId, waMessageId, serializedMessage, createdAt: new Date() }, [
'sessionId',
'waMessageId',
]);
await this.enforceLimit(sessionId);
}

Expand All @@ -34,6 +50,7 @@ export class BaileysMessageStoreService implements BaileysMessageStore {
if (!row) {
return null;
}
const { BufferJSON } = await this.loadLib();
return JSON.parse(row.serializedMessage, BufferJSON.reviver) as WAMessage;
}

Expand Down
10 changes: 9 additions & 1 deletion src/engine/adapters/baileys-stored-message.entity.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { Column, CreateDateColumn, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { Session } from '../../modules/session/entities/session.entity';

/**
* Persisted Baileys message store (the lib ships none). Holds the serialized WAMessage proto
* (via BufferJSON) so reply/forward/react/delete can resolve the original message/key by id across
* restarts. Engine-specific — lives in the engine layer, not the neutral `messages` table.
*
* The `session` relation declares the CASCADE FK so both the `synchronize:true` SQLite path and
* the migration path clean up stored messages when the parent session row is deleted (I6).
*/
@Entity('baileys_stored_messages')
@Index(['sessionId', 'waMessageId'], { unique: true }) // lookup + dedup (send-return vs upsert echo)
Expand All @@ -15,6 +19,10 @@ export class BaileysStoredMessage {
@Column()
sessionId: string;

@ManyToOne(() => Session, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'sessionId' })
session?: Session;

@Column()
waMessageId: string;

Expand Down
Loading