feat(v2): add native WhatsApp adapter using Baileys v6

Direct ChannelAdapter implementation — no Chat SDK bridge.
Ports v1 infrastructure: getMessage fallback, outgoing queue,
group metadata cache, LID-to-phone mapping, auto-reconnect.
Auth via pairing code (WHATSAPP_PHONE_NUMBER) or QR code.

Text messaging only (MVP). Not yet implemented:
- File/image attachments (send and receive)
- Edit message, delete message
- Reactions
- Bot echo filtering (own messages loop back as inbound)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gabi Simons
2026-04-13 14:53:59 +00:00
parent d16755eabc
commit c303b6eb14
4 changed files with 1436 additions and 8 deletions

905
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -34,10 +34,14 @@
"@chat-adapter/whatsapp": "^4.24.0",
"@onecli-sh/sdk": "^0.3.1",
"@resend/chat-sdk-adapter": "^0.1.1",
"@types/qrcode": "^1.5.6",
"@whiskeysockets/baileys": "^6.7.21",
"better-sqlite3": "11.10.0",
"chat": "^4.24.0",
"chat-adapter-imessage": "^0.1.1",
"cron-parser": "5.5.0"
"cron-parser": "5.5.0",
"pino": "^9.6.0",
"qrcode": "^1.5.4"
},
"devDependencies": {
"@eslint/js": "^9.35.0",

View File

@@ -39,4 +39,5 @@ import './telegram.js';
// gmail (native, no Chat SDK)
// whatsapp baileys (native, no Chat SDK)
// whatsapp (native, no Chat SDK)
import './whatsapp.js';

530
src/channels/whatsapp.ts Normal file
View File

@@ -0,0 +1,530 @@
/**
* WhatsApp channel adapter (v2) — native Baileys v6 implementation.
*
* Implements ChannelAdapter directly (no Chat SDK bridge) using
* @whiskeysockets/baileys v6 (stable). Ports proven v1 infrastructure:
* getMessage fallback, outgoing queue, group metadata cache, LID mapping,
* reconnection with backoff.
*
* Auth credentials persist in data/whatsapp-auth/. On first run:
* - If WHATSAPP_PHONE_NUMBER is set → pairing code (printed to log)
* - Otherwise → QR code (printed to log)
* Subsequent restarts reuse the saved session automatically.
*/
import fs from 'fs';
import path from 'path';
import pino from 'pino';
import {
makeWASocket,
Browsers,
DisconnectReason,
fetchLatestWaWebVersion,
makeCacheableSignalKeyStore,
normalizeMessageContent,
useMultiFileAuthState,
proto,
} from '@whiskeysockets/baileys';
import type { GroupMetadata, WAMessageKey, WASocket } from '@whiskeysockets/baileys';
import { ASSISTANT_HAS_OWN_NUMBER, ASSISTANT_NAME, DATA_DIR } from '../config.js';
import { readEnvFile } from '../env.js';
import { log } from '../log.js';
import { registerChannelAdapter } from './channel-registry.js';
import type {
ChannelAdapter,
ChannelSetup,
ConversationConfig,
ConversationInfo,
InboundMessage,
OutboundMessage,
} from './adapter.js';
// Baileys v6 bug: getPlatformId sends charCode (49) instead of enum value (1).
// Fixed in Baileys 7.x but not backported. Without this, pairing codes fail with
// "couldn't link device" because WhatsApp receives an invalid platform ID.
// Must use createRequire — ESM `import *` creates a read-only namespace.
import { createRequire } from 'module';
const _require = createRequire(import.meta.url);
try {
const _generics = _require(
'@whiskeysockets/baileys/lib/Utils/generics',
) as Record<string, unknown>;
_generics.getPlatformId = (browser: string): string => {
const platformType =
proto.DeviceProps.PlatformType[
browser.toUpperCase() as keyof typeof proto.DeviceProps.PlatformType
];
return platformType ? platformType.toString() : '1';
};
} catch {
// If CJS require fails (Node version mismatch), pairing codes may not work
// but QR auth will still function fine.
log.warn('Could not patch getPlatformId — pairing code auth may fail');
}
const baileysLogger = pino({ level: 'silent' });
const AUTH_DIR_NAME = 'whatsapp-auth';
const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
const GROUP_METADATA_CACHE_TTL_MS = 60_000; // 1 min for outbound sends
const SENT_MESSAGE_CACHE_MAX = 256;
const RECONNECT_DELAY_MS = 5000;
registerChannelAdapter('whatsapp', {
factory: () => {
const env = readEnvFile(['WHATSAPP_PHONE_NUMBER']);
const phoneNumber = env.WHATSAPP_PHONE_NUMBER;
const authDir = path.join(DATA_DIR, AUTH_DIR_NAME);
// Skip if no existing auth and no phone number for pairing
const hasAuth = fs.existsSync(path.join(authDir, 'creds.json'));
if (!hasAuth && !phoneNumber) return null;
fs.mkdirSync(authDir, { recursive: true });
// State
let sock: WASocket;
let connected = false;
let setupConfig: ChannelSetup;
let conversations: Map<string, ConversationConfig>;
// LID → phone JID mapping (WhatsApp's new ID system)
const lidToPhoneMap: Record<string, string> = {};
let botLidUser: string | undefined;
// Outgoing queue for messages sent while disconnected
const outgoingQueue: Array<{ jid: string; text: string }> = [];
let flushing = false;
// Sent message cache for retry/re-encrypt requests
const sentMessageCache = new Map<string, proto.IMessage>();
// Group metadata cache with TTL
const groupMetadataCache = new Map<string, { metadata: GroupMetadata; expiresAt: number }>();
// Group sync tracking
let lastGroupSync = 0;
let groupSyncTimerStarted = false;
// First-connect promise
let resolveFirstOpen: (() => void) | undefined;
let rejectFirstOpen: ((err: Error) => void) | undefined;
// Pairing code file for the setup skill to poll
const pairingCodeFile = path.join(DATA_DIR, 'whatsapp-pairing-code.txt');
// --- Helpers ---
function buildConversationMap(configs: ConversationConfig[]): Map<string, ConversationConfig> {
const map = new Map<string, ConversationConfig>();
for (const conv of configs) map.set(conv.platformId, conv);
return map;
}
function setLidPhoneMapping(lidUser: string, phoneJid: string): void {
if (lidToPhoneMap[lidUser] === phoneJid) return;
lidToPhoneMap[lidUser] = phoneJid;
// Cached group metadata depends on participant IDs — invalidate
groupMetadataCache.clear();
}
async function translateJid(jid: string): Promise<string> {
if (!jid.endsWith('@lid')) return jid;
const lidUser = jid.split('@')[0].split(':')[0];
const cached = lidToPhoneMap[lidUser];
if (cached) return cached;
// Query Baileys' signal repository
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pn = await (sock.signalRepository as any)?.lidMapping?.getPNForLID(jid);
if (pn) {
const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`;
setLidPhoneMapping(lidUser, phoneJid);
log.info('Translated LID to phone JID', { lidJid: jid, phoneJid });
return phoneJid;
}
} catch (err) {
log.debug('Failed to resolve LID via signalRepository', { jid, err });
}
return jid;
}
async function getNormalizedGroupMetadata(jid: string): Promise<GroupMetadata | undefined> {
if (!jid.endsWith('@g.us')) return undefined;
const cached = groupMetadataCache.get(jid);
if (cached && cached.expiresAt > Date.now()) return cached.metadata;
const metadata = await sock.groupMetadata(jid);
const participants = await Promise.all(
metadata.participants.map(async (p) => ({
...p,
id: await translateJid(p.id),
})),
);
const normalized = { ...metadata, participants };
groupMetadataCache.set(jid, {
metadata: normalized,
expiresAt: Date.now() + GROUP_METADATA_CACHE_TTL_MS,
});
return normalized;
}
async function syncGroupMetadata(force = false): Promise<void> {
if (!force && lastGroupSync && Date.now() - lastGroupSync < GROUP_SYNC_INTERVAL_MS) {
return;
}
try {
log.info('Syncing group metadata from WhatsApp...');
const groups = await sock.groupFetchAllParticipating();
let count = 0;
for (const [jid, metadata] of Object.entries(groups)) {
if (metadata.subject) {
setupConfig.onMetadata(jid, metadata.subject, true);
count++;
}
}
lastGroupSync = Date.now();
log.info('Group metadata synced', { count });
} catch (err) {
log.error('Failed to sync group metadata', { err });
}
}
async function flushOutgoingQueue(): Promise<void> {
if (flushing || outgoingQueue.length === 0) return;
flushing = true;
try {
log.info('Flushing outgoing message queue', { count: outgoingQueue.length });
while (outgoingQueue.length > 0) {
const item = outgoingQueue.shift()!;
const sent = await sock.sendMessage(item.jid, { text: item.text });
if (sent?.key?.id && sent.message) {
sentMessageCache.set(sent.key.id, sent.message);
}
}
} finally {
flushing = false;
}
}
async function sendRawMessage(jid: string, text: string): Promise<string | undefined> {
if (!connected) {
outgoingQueue.push({ jid, text });
log.info('WA disconnected, message queued', { jid, queueSize: outgoingQueue.length });
return;
}
try {
const sent = await sock.sendMessage(jid, { text });
if (sent?.key?.id && sent.message) {
sentMessageCache.set(sent.key.id, sent.message);
if (sentMessageCache.size > SENT_MESSAGE_CACHE_MAX) {
const oldest = sentMessageCache.keys().next().value!;
sentMessageCache.delete(oldest);
}
}
return sent?.key?.id ?? undefined;
} catch (err) {
outgoingQueue.push({ jid, text });
log.warn('Failed to send, message queued', { jid, err, queueSize: outgoingQueue.length });
return undefined;
}
}
// --- Socket creation ---
async function connectSocket(): Promise<void> {
const { state, saveCreds } = await useMultiFileAuthState(authDir);
const { version } = await fetchLatestWaWebVersion({}).catch((err) => {
log.warn('Failed to fetch latest WA Web version, using default', { err });
return { version: undefined };
});
sock = makeWASocket({
version,
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, baileysLogger),
},
printQRInTerminal: false,
logger: baileysLogger,
browser: Browsers.macOS('Chrome'),
cachedGroupMetadata: async (jid: string) => getNormalizedGroupMetadata(jid),
getMessage: async (key: WAMessageKey) => {
// Check in-memory cache first (recently sent messages)
const cached = sentMessageCache.get(key.id || '');
if (cached) return cached;
// Return empty message to prevent indefinite "waiting for this message"
return proto.Message.fromObject({});
},
});
// Request pairing code if phone number is set and not yet registered
if (phoneNumber && !state.creds.registered) {
setTimeout(async () => {
try {
const code = await sock.requestPairingCode(phoneNumber);
log.info(`WhatsApp pairing code: ${code}`);
log.info('Enter in WhatsApp > Linked Devices > Link with phone number');
fs.writeFileSync(pairingCodeFile, code, 'utf-8');
} catch (err) {
log.error('Failed to request pairing code', { err });
}
}, 3000);
}
sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr && !phoneNumber) {
// QR code auth — print to terminal
(async () => {
try {
const QRCode = await import('qrcode');
const qrText = await QRCode.toString(qr, { type: 'terminal' });
log.info('WhatsApp QR code — scan with WhatsApp > Linked Devices:\n' + qrText);
} catch {
log.info('WhatsApp QR code (raw)', { qr });
}
})();
}
if (connection === 'close') {
connected = false;
const reason = (lastDisconnect?.error as { output?: { statusCode?: number } })?.output
?.statusCode;
const shouldReconnect = reason !== DisconnectReason.loggedOut;
log.info('WhatsApp connection closed', { reason, shouldReconnect });
if (shouldReconnect) {
log.info('Reconnecting...');
connectSocket().catch((err) => {
log.error('Failed to reconnect, retrying in 5s', { err });
setTimeout(() => {
connectSocket().catch((err2) => {
log.error('Reconnection retry failed', { err: err2 });
});
}, RECONNECT_DELAY_MS);
});
} else {
log.info('WhatsApp logged out');
if (rejectFirstOpen) {
rejectFirstOpen(new Error('WhatsApp logged out'));
rejectFirstOpen = undefined;
resolveFirstOpen = undefined;
}
}
} else if (connection === 'open') {
connected = true;
log.info('Connected to WhatsApp');
// Clean up pairing code file after successful connection
try {
if (fs.existsSync(pairingCodeFile)) fs.unlinkSync(pairingCodeFile);
} catch { /* ignore */ }
// Announce availability for presence updates
sock.sendPresenceUpdate('available').catch((err) => {
log.warn('Failed to send presence update', { err });
});
// Build LID → phone mapping from auth state
if (sock.user) {
const phoneUser = sock.user.id.split(':')[0];
const lidUser = sock.user.lid?.split(':')[0];
if (lidUser && phoneUser) {
setLidPhoneMapping(lidUser, `${phoneUser}@s.whatsapp.net`);
botLidUser = lidUser;
}
}
// Flush queued messages
flushOutgoingQueue().catch((err) => log.error('Failed to flush outgoing queue', { err }));
// Group sync
syncGroupMetadata().catch((err) => log.error('Initial group sync failed', { err }));
if (!groupSyncTimerStarted) {
groupSyncTimerStarted = true;
setInterval(() => {
syncGroupMetadata().catch((err) => log.error('Periodic group sync failed', { err }));
}, GROUP_SYNC_INTERVAL_MS);
}
// Signal first open
if (resolveFirstOpen) {
resolveFirstOpen();
resolveFirstOpen = undefined;
rejectFirstOpen = undefined;
}
}
});
sock.ev.on('creds.update', saveCreds);
// Phone number sharing events — update LID mapping
sock.ev.on('chats.phoneNumberShare', ({ lid, jid }) => {
const lidUser = lid?.split('@')[0].split(':')[0];
if (lidUser && jid) setLidPhoneMapping(lidUser, jid);
});
// Inbound messages
sock.ev.on('messages.upsert', async ({ messages }) => {
for (const msg of messages) {
try {
if (!msg.message) continue;
const normalized = normalizeMessageContent(msg.message);
if (!normalized) continue;
const rawJid = msg.key.remoteJid;
if (!rawJid || rawJid === 'status@broadcast') continue;
// Translate LID → phone JID
let chatJid = await translateJid(rawJid);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (chatJid.endsWith('@lid') && (msg.key as any).senderPn) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pn = (msg.key as any).senderPn as string;
const phoneJid = pn.includes('@') ? pn : `${pn}@s.whatsapp.net`;
setLidPhoneMapping(rawJid.split('@')[0].split(':')[0], phoneJid);
chatJid = phoneJid;
}
const timestamp = new Date(Number(msg.messageTimestamp) * 1000).toISOString();
const isGroup = chatJid.endsWith('@g.us');
// Notify metadata for group discovery
setupConfig.onMetadata(chatJid, undefined, isGroup);
// Only forward messages for registered conversations
if (!conversations.has(chatJid)) continue;
let content =
normalized.conversation ||
normalized.extendedTextMessage?.text ||
normalized.imageMessage?.caption ||
normalized.videoMessage?.caption ||
'';
// Normalize bot LID mention → assistant name for trigger matching
if (botLidUser && content.includes(`@${botLidUser}`)) {
content = content.replace(`@${botLidUser}`, `@${ASSISTANT_NAME}`);
}
// Skip empty protocol messages
if (!content) continue;
const sender = msg.key.participant || msg.key.remoteJid || '';
const senderName = msg.pushName || sender.split('@')[0];
const fromMe = msg.key.fromMe || false;
const isBotMessage = ASSISTANT_HAS_OWN_NUMBER
? fromMe
: content.startsWith(`${ASSISTANT_NAME}:`);
const inbound: InboundMessage = {
id: msg.key.id || `wa-${Date.now()}`,
kind: 'chat',
content: {
text: content,
sender,
senderName,
fromMe,
isBotMessage,
isGroup,
chatJid,
},
timestamp,
};
// WhatsApp doesn't use threads — threadId is null
setupConfig.onInbound(chatJid, null, inbound);
} catch (err) {
log.error('Error processing incoming WhatsApp message', {
err,
remoteJid: msg.key?.remoteJid,
});
}
}
});
}
// --- ChannelAdapter implementation ---
const adapter: ChannelAdapter = {
name: 'whatsapp',
channelType: 'whatsapp',
supportsThreads: false,
async setup(hostConfig: ChannelSetup) {
setupConfig = hostConfig;
conversations = buildConversationMap(hostConfig.conversations);
// Connect and wait for first open
await new Promise<void>((resolve, reject) => {
resolveFirstOpen = resolve;
rejectFirstOpen = reject;
connectSocket().catch(reject);
});
log.info('WhatsApp adapter initialized');
},
async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise<string | undefined> {
const content = message.content as Record<string, unknown>;
// Typing indicator (composing → paused is handled by the host)
const text = (content.markdown as string) || (content.text as string);
if (!text) return;
// Prefix bot messages on shared number
const prefixed = ASSISTANT_HAS_OWN_NUMBER ? text : `${ASSISTANT_NAME}: ${text}`;
return sendRawMessage(platformId, prefixed);
},
async setTyping(platformId: string) {
try {
await sock.sendPresenceUpdate('composing', platformId);
} catch (err) {
log.debug('Failed to update typing status', { jid: platformId, err });
}
},
async teardown() {
connected = false;
sock?.end(undefined);
log.info('WhatsApp adapter shut down');
},
isConnected() {
return connected;
},
async syncConversations(): Promise<ConversationInfo[]> {
try {
const groups = await sock.groupFetchAllParticipating();
return Object.entries(groups)
.filter(([, m]) => m.subject)
.map(([jid, m]) => ({
platformId: jid,
name: m.subject,
isGroup: true,
}));
} catch (err) {
log.error('Failed to sync WhatsApp conversations', { err });
return [];
}
},
updateConversations(configs: ConversationConfig[]) {
conversations = buildConversationMap(configs);
},
};
return adapter;
},
});