moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,351 @@
/**
* WhatsAppAdapter — QR surfacing, message normalization (DM + group,
* fromMe/status filtering), logged-out auth wipe vs transient reconnect,
* chunked send (CHANNELS-ARC P3). Fake Baileys socket; no network, no
* real Baileys import.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { BufferJSON, initAuthCreds } from '@whiskeysockets/baileys';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
WhatsAppAdapter,
WHATSAPP_AUTH_VAULT_KEY,
WHATSAPP_MAX_TEXT,
disconnectStatusCode,
useVaultWhatsAppAuthState,
} from '../src/local/channels/whatsapp-adapter.js';
import type { WaSocketLike } from '../src/local/channels/whatsapp-adapter.js';
import type { ChannelMessage } from '../src/local/channels/types.js';
const noopLog = { info: () => undefined, warn: () => undefined };
class FakeAuthVault {
readonly entries = new Map<string, string>();
get(key: string) {
const value = this.entries.get(key);
return value === undefined ? null : { value };
}
has(key: string): boolean { return this.entries.has(key); }
set(key: string, value: string): void { this.entries.set(key, value); }
delete(key: string): boolean { return this.entries.delete(key); }
}
type Handler = (arg: unknown) => void;
class FakeWaSocket implements WaSocketLike {
sends: Array<{ jid: string; text: string }> = [];
ended = false;
private handlers = new Map<string, Handler[]>();
ev = {
on: (event: string, cb: Handler): void => {
this.handlers.set(event, [...(this.handlers.get(event) ?? []), cb]);
},
} as WaSocketLike['ev'];
async sendMessage(jid: string, content: { text: string }): Promise<unknown> {
this.sends.push({ jid, text: content.text });
return {};
}
end(): void {
this.ended = true;
}
emit(event: string, arg: unknown): void {
for (const cb of this.handlers.get(event) ?? []) cb(arg);
}
}
function loggedOutError(code: number): { error: unknown } {
return { error: { output: { statusCode: code } } };
}
let dir: string;
let adapter: WhatsAppAdapter | null = null;
function makeAdapter() {
const sockets: FakeWaSocket[] = [];
const received: ChannelMessage[] = [];
const vault = new FakeAuthVault();
const a = new WhatsAppAdapter({
dataDir: dir,
vault,
onMessage: async m => { received.push(m); },
log: noopLog,
socketFactory: async () => {
const s = new FakeWaSocket();
sockets.push(s);
return s;
},
backoffCapMs: 10,
});
return { a, sockets, received, vault };
}
async function tick(ms = 30): Promise<void> {
await new Promise(r => setTimeout(r, ms));
}
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-wa-'));
});
afterEach(async () => {
await adapter?.stop();
adapter = null;
fs.rmSync(dir, { recursive: true, force: true });
});
describe('WhatsAppAdapter pairing + status', () => {
it('surfaces the QR through status and clears it once connected', async () => {
const ctx = makeAdapter();
adapter = ctx.a;
await adapter.start();
ctx.sockets[0].emit('connection.update', { qr: 'QR-DATA-1' });
expect(adapter.getStatus().qr).toBe('QR-DATA-1');
expect(adapter.getStatus().connected).toBe(false);
ctx.sockets[0].emit('connection.update', { connection: 'open' });
expect(adapter.getStatus().connected).toBe(true);
expect(adapter.getStatus().qr).toBeUndefined();
});
it('reports paired=true when creds.json exists on disk', async () => {
const ctx = makeAdapter();
adapter = ctx.a;
await adapter.start();
expect(adapter.getStatus().paired).toBe(false);
fs.mkdirSync(path.join(dir, 'channels', 'whatsapp-auth'), { recursive: true });
fs.writeFileSync(path.join(dir, 'channels', 'whatsapp-auth', 'creds.json'), '{}', 'utf8');
expect(adapter.getStatus().paired).toBe(true);
});
it('reports paired=true from registered encrypted Vault credentials', async () => {
const ctx = makeAdapter();
ctx.vault.set(WHATSAPP_AUTH_VAULT_KEY, JSON.stringify({
version: 1,
creds: { registered: true },
keys: [],
}));
adapter = ctx.a;
await adapter.start();
expect(adapter.getStatus().paired).toBe(true);
});
});
describe('WhatsApp encrypted auth state', () => {
it('persists and reloads credentials and signal keys without plaintext files', async () => {
const vault = new FakeAuthVault();
const legacyDir = path.join(dir, 'channels', 'whatsapp-auth');
const first = await useVaultWhatsAppAuthState(vault, legacyDir);
first.state.creds.registered = true;
await first.state.keys.set({
'pre-key': {
'7': { private: Buffer.from([1, 2]), public: Buffer.from([3, 4]) },
},
});
first.saveCreds();
expect(vault.entries.has(WHATSAPP_AUTH_VAULT_KEY)).toBe(true);
expect(fs.existsSync(legacyDir)).toBe(false);
const reloaded = await useVaultWhatsAppAuthState(vault, legacyDir);
const keys = await reloaded.state.keys.get('pre-key', ['7']);
expect(reloaded.state.creds.registered).toBe(true);
expect(Buffer.from(keys['7'].private)).toEqual(Buffer.from([1, 2]));
expect(Buffer.from(keys['7'].public)).toEqual(Buffer.from([3, 4]));
});
it('migrates a legacy multi-file pairing before deleting plaintext', async () => {
const vault = new FakeAuthVault();
const legacyDir = path.join(dir, 'channels', 'whatsapp-auth');
fs.mkdirSync(legacyDir, { recursive: true });
const creds = initAuthCreds();
creds.registered = true;
fs.writeFileSync(
path.join(legacyDir, 'creds.json'),
JSON.stringify(creds, BufferJSON.replacer),
'utf8',
);
fs.writeFileSync(
path.join(legacyDir, 'pre-key-9.json'),
JSON.stringify({ private: Buffer.from([5]), public: Buffer.from([6]) }, BufferJSON.replacer),
'utf8',
);
const migrated = await useVaultWhatsAppAuthState(vault, legacyDir);
const keys = await migrated.state.keys.get('pre-key', ['9']);
expect(migrated.state.creds.registered).toBe(true);
expect(Buffer.from(keys['9'].private)).toEqual(Buffer.from([5]));
expect(vault.entries.has(WHATSAPP_AUTH_VAULT_KEY)).toBe(true);
expect(fs.existsSync(legacyDir)).toBe(false);
});
it('fails closed and preserves unreadable encrypted state', async () => {
const vault = new FakeAuthVault();
const legacyDir = path.join(dir, 'channels', 'whatsapp-auth');
vault.set(WHATSAPP_AUTH_VAULT_KEY, '{broken');
await expect(useVaultWhatsAppAuthState(vault, legacyDir)).rejects.toThrow(/unreadable/i);
expect(vault.entries.get(WHATSAPP_AUTH_VAULT_KEY)).toBe('{broken');
});
it('fails closed and preserves legacy files when migration cannot parse them', async () => {
const vault = new FakeAuthVault();
const legacyDir = path.join(dir, 'channels', 'whatsapp-auth');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'creds.json'), '{broken', 'utf8');
await expect(useVaultWhatsAppAuthState(vault, legacyDir)).rejects.toThrow(/could not be migrated/i);
expect(fs.existsSync(path.join(legacyDir, 'creds.json'))).toBe(true);
expect(vault.entries.has(WHATSAPP_AUTH_VAULT_KEY)).toBe(false);
});
});
describe('WhatsAppAdapter inbound', () => {
it('normalizes a DM: sender = chat jid', async () => {
const ctx = makeAdapter();
adapter = ctx.a;
await adapter.start();
ctx.sockets[0].emit('messages.upsert', {
type: 'notify',
messages: [{
key: { remoteJid: '3816x@s.whatsapp.net', fromMe: false, id: 'm1' },
pushName: 'Marko',
message: { conversation: 'zdravo' },
}],
});
await tick(5);
expect(ctx.received[0]).toEqual({
platform: 'whatsapp',
chatId: '3816x@s.whatsapp.net',
senderId: '3816x@s.whatsapp.net',
senderName: 'Marko',
text: 'zdravo',
messageId: 'm1',
});
});
it('normalizes a group message: sender = participant', async () => {
const ctx = makeAdapter();
adapter = ctx.a;
await adapter.start();
ctx.sockets[0].emit('messages.upsert', {
type: 'notify',
messages: [{
key: { remoteJid: 'grp@g.us', fromMe: false, id: 'm2', participant: 'u9@s.whatsapp.net' },
message: { extendedTextMessage: { text: 'group hi' } },
}],
});
await tick(5);
expect(ctx.received[0]?.senderId).toBe('u9@s.whatsapp.net');
expect(ctx.received[0]?.chatId).toBe('grp@g.us');
expect(ctx.received[0]?.text).toBe('group hi');
});
it('filters own messages, status broadcasts, non-notify batches, and non-text', async () => {
const ctx = makeAdapter();
adapter = ctx.a;
await adapter.start();
const s = ctx.sockets[0];
s.emit('messages.upsert', {
type: 'notify',
messages: [
{ key: { remoteJid: 'a@s.whatsapp.net', fromMe: true, id: '1' }, message: { conversation: 'me' } },
{ key: { remoteJid: 'status@broadcast', fromMe: false, id: '2' }, message: { conversation: 's' } },
{ key: { remoteJid: 'b@s.whatsapp.net', fromMe: false, id: '3' }, message: {} },
],
});
s.emit('messages.upsert', {
type: 'append',
messages: [{ key: { remoteJid: 'c@s.whatsapp.net', fromMe: false, id: '4' }, message: { conversation: 'history' } }],
});
await tick(5);
expect(ctx.received).toEqual([]);
});
});
describe('WhatsAppAdapter disconnects', () => {
it('reconnects on transient close codes', async () => {
const ctx = makeAdapter();
adapter = ctx.a;
await adapter.start();
ctx.sockets[0].emit('connection.update', { connection: 'open' });
ctx.sockets[0].emit('connection.update', {
connection: 'close',
lastDisconnect: loggedOutError(408), // timeout — transient
});
await tick(50);
expect(ctx.sockets.length).toBeGreaterThanOrEqual(2);
});
it('wipes auth state and does NOT reconnect on loggedOut (401)', async () => {
const ctx = makeAdapter();
adapter = ctx.a;
await adapter.start();
const authDir = path.join(dir, 'channels', 'whatsapp-auth');
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(path.join(authDir, 'creds.json'), '{}', 'utf8');
ctx.vault.set(WHATSAPP_AUTH_VAULT_KEY, JSON.stringify({
version: 1,
creds: { registered: true },
keys: [],
}));
ctx.sockets[0].emit('connection.update', { connection: 'open' });
ctx.sockets[0].emit('connection.update', {
connection: 'close',
lastDisconnect: loggedOutError(401),
});
await tick(50);
expect(ctx.sockets.length).toBe(1); // no reconnect
expect(fs.existsSync(path.join(authDir, 'creds.json'))).toBe(false);
expect(ctx.vault.entries.has(WHATSAPP_AUTH_VAULT_KEY)).toBe(false);
expect(adapter.getStatus().lastError).toMatch(/re-pair/i);
});
it('stop() ends the socket and halts reconnection', async () => {
const ctx = makeAdapter();
adapter = ctx.a;
await adapter.start();
ctx.sockets[0].emit('connection.update', { connection: 'open' });
await adapter.stop();
expect(ctx.sockets[0].ended).toBe(true);
ctx.sockets[0].emit('connection.update', { connection: 'close', lastDisconnect: loggedOutError(408) });
await tick(50);
expect(ctx.sockets.length).toBe(1);
adapter = null;
});
});
describe('WhatsAppAdapter send', () => {
it('chunks long text and targets the jid', async () => {
const ctx = makeAdapter();
adapter = ctx.a;
await adapter.start();
await adapter.send('x@s.whatsapp.net', 'w'.repeat(WHATSAPP_MAX_TEXT + 50));
expect(ctx.sockets[0].sends.length).toBe(2);
expect(ctx.sockets[0].sends[0].jid).toBe('x@s.whatsapp.net');
});
it('throws when not connected', async () => {
const ctx = makeAdapter();
adapter = ctx.a;
await expect(adapter.send('x@s.whatsapp.net', 'hi')).rejects.toThrow(/not connected/);
});
});
describe('disconnectStatusCode', () => {
it('extracts boom-style status codes and tolerates garbage', () => {
expect(disconnectStatusCode(loggedOutError(401))).toBe(401);
expect(disconnectStatusCode({ error: new Error('plain') })).toBeUndefined();
expect(disconnectStatusCode(undefined)).toBeUndefined();
});
});