| 1 | /** |
| 2 | * WhatsApp client wrapper using Baileys. |
| 3 | * Based on OpenClaw's working implementation. |
| 4 | */ |
| 5 | |
| 6 | /* eslint-disable @typescript-eslint/no-explicit-any */ |
| 7 | import makeWASocket, { |
| 8 | DisconnectReason, |
| 9 | useMultiFileAuthState, |
| 10 | fetchLatestBaileysVersion, |
| 11 | makeCacheableSignalKeyStore, |
| 12 | downloadMediaMessage, |
| 13 | extractMessageContent as baileysExtractMessageContent, |
| 14 | } from '@whiskeysockets/baileys'; |
| 15 | |
| 16 | import { Boom } from '@hapi/boom'; |
| 17 | import qrcode from 'qrcode-terminal'; |
| 18 | import pino from 'pino'; |
| 19 | import { readFile, writeFile, mkdir } from 'fs/promises'; |
| 20 | import { join, basename } from 'path'; |
| 21 | import { randomBytes } from 'crypto'; |
| 22 | |
| 23 | const VERSION = '0.1.0'; |
| 24 | |
| 25 | export interface InboundMessage { |
| 26 | id: string; |
| 27 | sender: string; |
| 28 | pn: string; |
| 29 | content: string; |
| 30 | timestamp: number; |
| 31 | isGroup: boolean; |
| 32 | wasMentioned?: boolean; |
| 33 | media?: string[]; |
| 34 | } |
| 35 | |
| 36 | export interface WhatsAppClientOptions { |
| 37 | authDir: string; |
| 38 | onMessage: (msg: InboundMessage) => void; |
| 39 | onQR: (qr: string) => void; |
| 40 | onStatus: (status: string) => void; |
| 41 | } |
| 42 | |
| 43 | export class WhatsAppClient { |
| 44 | private sock: any = null; |
| 45 | private options: WhatsAppClientOptions; |
| 46 | private reconnecting = false; |
| 47 | |
| 48 | constructor(options: WhatsAppClientOptions) { |
| 49 | this.options = options; |
| 50 | } |
| 51 | |
| 52 | private normalizeJid(jid: string | undefined | null): string { |
| 53 | return (jid || '').split(':')[0]; |
| 54 | } |
| 55 | |
| 56 | private wasMentioned(msg: any): boolean { |
| 57 | if (!msg?.key?.remoteJid?.endsWith('@g.us')) return false; |
| 58 | |
| 59 | const candidates = [ |
| 60 | msg?.message?.extendedTextMessage?.contextInfo?.mentionedJid, |
| 61 | msg?.message?.imageMessage?.contextInfo?.mentionedJid, |
| 62 | msg?.message?.videoMessage?.contextInfo?.mentionedJid, |
| 63 | msg?.message?.documentMessage?.contextInfo?.mentionedJid, |
| 64 | msg?.message?.audioMessage?.contextInfo?.mentionedJid, |
| 65 | ]; |
| 66 | const mentioned = candidates.flatMap((items) => (Array.isArray(items) ? items : [])); |
| 67 | if (mentioned.length === 0) return false; |
| 68 | |
| 69 | const selfIds = new Set( |
| 70 | [this.sock?.user?.id, this.sock?.user?.lid, this.sock?.user?.jid] |
| 71 | .map((jid) => this.normalizeJid(jid)) |
| 72 | .filter(Boolean), |
| 73 | ); |
| 74 | return mentioned.some((jid: string) => selfIds.has(this.normalizeJid(jid))); |
| 75 | } |
| 76 | |
| 77 | async connect(): Promise<void> { |
| 78 | const logger = pino({ level: 'silent' }); |
| 79 | const { state, saveCreds } = await useMultiFileAuthState(this.options.authDir); |
| 80 | const { version } = await fetchLatestBaileysVersion(); |
| 81 | |
| 82 | console.log(`Using Baileys version: ${version.join('.')}`); |
| 83 | |
| 84 | // Create socket following OpenClaw's pattern |
| 85 | this.sock = makeWASocket({ |
| 86 | auth: { |
| 87 | creds: state.creds, |
| 88 | keys: makeCacheableSignalKeyStore(state.keys, logger), |
| 89 | }, |
| 90 | version, |
| 91 | logger, |
| 92 | printQRInTerminal: false, |
| 93 | browser: ['nanobot', 'cli', VERSION], |
| 94 | syncFullHistory: false, |
| 95 | markOnlineOnConnect: false, |
| 96 | }); |
| 97 | |
| 98 | // Handle WebSocket errors |
| 99 | if (this.sock.ws && typeof this.sock.ws.on === 'function') { |
| 100 | this.sock.ws.on('error', (err: Error) => { |
| 101 | console.error('WebSocket error:', err.message); |
| 102 | }); |
| 103 | } |
| 104 | |
| 105 | // Handle connection updates |
| 106 | this.sock.ev.on('connection.update', async (update: any) => { |
| 107 | const { connection, lastDisconnect, qr } = update; |
| 108 | |
| 109 | if (qr) { |
| 110 | // Display QR code in terminal |
| 111 | console.log('\n📱 Scan this QR code with WhatsApp (Linked Devices):\n'); |
| 112 | qrcode.generate(qr, { small: true }); |
| 113 | this.options.onQR(qr); |
| 114 | } |
| 115 | |
| 116 | if (connection === 'close') { |
| 117 | const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode; |
| 118 | const shouldReconnect = statusCode !== DisconnectReason.loggedOut; |
| 119 | |
| 120 | console.log(`Connection closed. Status: ${statusCode}, Will reconnect: ${shouldReconnect}`); |
| 121 | this.options.onStatus('disconnected'); |
| 122 | |
| 123 | if (shouldReconnect && !this.reconnecting) { |
| 124 | this.reconnecting = true; |
| 125 | console.log('Reconnecting in 5 seconds...'); |
| 126 | setTimeout(() => { |
| 127 | this.reconnecting = false; |
| 128 | this.connect(); |
| 129 | }, 5000); |
| 130 | } |
| 131 | } else if (connection === 'open') { |
| 132 | console.log('✅ Connected to WhatsApp'); |
| 133 | this.options.onStatus('connected'); |
| 134 | } |
| 135 | }); |
| 136 | |
| 137 | // Save credentials on update |
| 138 | this.sock.ev.on('creds.update', saveCreds); |
| 139 | |
| 140 | // Handle incoming messages |
| 141 | this.sock.ev.on('messages.upsert', async ({ messages, type }: { messages: any[]; type: string }) => { |
| 142 | if (type !== 'notify') return; |
| 143 | |
| 144 | for (const msg of messages) { |
| 145 | if (msg.key.fromMe) continue; |
| 146 | if (msg.key.remoteJid === 'status@broadcast') continue; |
| 147 | |
| 148 | const unwrapped = baileysExtractMessageContent(msg.message); |
| 149 | if (!unwrapped) continue; |
| 150 | |
| 151 | const content = this.getTextContent(unwrapped); |
| 152 | let fallbackContent: string | null = null; |
| 153 | const mediaPaths: string[] = []; |
| 154 | |
| 155 | if (unwrapped.imageMessage) { |
| 156 | fallbackContent = '[Image]'; |
| 157 | const path = await this.downloadMedia(msg, unwrapped.imageMessage.mimetype ?? undefined); |
| 158 | if (path) mediaPaths.push(path); |
| 159 | } else if (unwrapped.documentMessage) { |
| 160 | fallbackContent = '[Document]'; |
| 161 | const path = await this.downloadMedia(msg, unwrapped.documentMessage.mimetype ?? undefined, |
| 162 | unwrapped.documentMessage.fileName ?? undefined); |
| 163 | if (path) mediaPaths.push(path); |
| 164 | } else if (unwrapped.videoMessage) { |
| 165 | fallbackContent = '[Video]'; |
| 166 | const path = await this.downloadMedia(msg, unwrapped.videoMessage.mimetype ?? undefined); |
| 167 | if (path) mediaPaths.push(path); |
| 168 | } |
| 169 | |
| 170 | const finalContent = content || (mediaPaths.length === 0 ? fallbackContent : '') || ''; |
| 171 | if (!finalContent && mediaPaths.length === 0) continue; |
| 172 | |
| 173 | const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false; |
| 174 | const wasMentioned = this.wasMentioned(msg); |
| 175 | |
| 176 | this.options.onMessage({ |
| 177 | id: msg.key.id || '', |
| 178 | sender: msg.key.remoteJid || '', |
| 179 | pn: msg.key.remoteJidAlt || '', |
| 180 | content: finalContent, |
| 181 | timestamp: msg.messageTimestamp as number, |
| 182 | isGroup, |
| 183 | ...(isGroup ? { wasMentioned } : {}), |
| 184 | ...(mediaPaths.length > 0 ? { media: mediaPaths } : {}), |
| 185 | }); |
| 186 | } |
| 187 | }); |
| 188 | } |
| 189 | |
| 190 | private async downloadMedia(msg: any, mimetype?: string, fileName?: string): Promise<string | null> { |
| 191 | try { |
| 192 | const mediaDir = join(this.options.authDir, '..', 'media'); |
| 193 | await mkdir(mediaDir, { recursive: true }); |
| 194 | |
| 195 | const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer; |
| 196 | |
| 197 | let outFilename: string; |
| 198 | if (fileName) { |
| 199 | // Documents have a filename — use it with a unique prefix to avoid collisions |
| 200 | const prefix = `wa_${Date.now()}_${randomBytes(4).toString('hex')}_`; |
| 201 | outFilename = prefix + fileName; |
| 202 | } else { |
| 203 | const mime = mimetype || 'application/octet-stream'; |
| 204 | // Derive extension from mimetype subtype (e.g. "image/png" → ".png", "application/pdf" → ".pdf") |
| 205 | const ext = '.' + (mime.split('/').pop()?.split(';')[0] || 'bin'); |
| 206 | outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`; |
| 207 | } |
| 208 | |
| 209 | const filepath = join(mediaDir, outFilename); |
| 210 | await writeFile(filepath, buffer); |
| 211 | |
| 212 | return filepath; |
| 213 | } catch (err) { |
| 214 | console.error('Failed to download media:', err); |
| 215 | return null; |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | private getTextContent(message: any): string | null { |
| 220 | // Text message |
| 221 | if (message.conversation) { |
| 222 | return message.conversation; |
| 223 | } |
| 224 | |
| 225 | // Extended text (reply, link preview) |
| 226 | if (message.extendedTextMessage?.text) { |
| 227 | return message.extendedTextMessage.text; |
| 228 | } |
| 229 | |
| 230 | // Image with optional caption |
| 231 | if (message.imageMessage) { |
| 232 | return message.imageMessage.caption || ''; |
| 233 | } |
| 234 | |
| 235 | // Video with optional caption |
| 236 | if (message.videoMessage) { |
| 237 | return message.videoMessage.caption || ''; |
| 238 | } |
| 239 | |
| 240 | // Document with optional caption |
| 241 | if (message.documentMessage) { |
| 242 | return message.documentMessage.caption || ''; |
| 243 | } |
| 244 | |
| 245 | // Voice/Audio message |
| 246 | if (message.audioMessage) { |
| 247 | return `[Voice Message]`; |
| 248 | } |
| 249 | |
| 250 | return null; |
| 251 | } |
| 252 | |
| 253 | async sendMessage(to: string, text: string): Promise<void> { |
| 254 | if (!this.sock) { |
| 255 | throw new Error('Not connected'); |
| 256 | } |
| 257 | |
| 258 | await this.sock.sendMessage(to, { text }); |
| 259 | } |
| 260 | |
| 261 | async sendMedia( |
| 262 | to: string, |
| 263 | filePath: string, |
| 264 | mimetype: string, |
| 265 | caption?: string, |
| 266 | fileName?: string, |
| 267 | ): Promise<void> { |
| 268 | if (!this.sock) { |
| 269 | throw new Error('Not connected'); |
| 270 | } |
| 271 | |
| 272 | const buffer = await readFile(filePath); |
| 273 | const category = mimetype.split('/')[0]; |
| 274 | |
| 275 | if (category === 'image') { |
| 276 | await this.sock.sendMessage(to, { image: buffer, caption: caption || undefined, mimetype }); |
| 277 | } else if (category === 'video') { |
| 278 | await this.sock.sendMessage(to, { video: buffer, caption: caption || undefined, mimetype }); |
| 279 | } else if (category === 'audio') { |
| 280 | await this.sock.sendMessage(to, { audio: buffer, mimetype }); |
| 281 | } else { |
| 282 | const name = fileName || basename(filePath); |
| 283 | await this.sock.sendMessage(to, { document: buffer, mimetype, fileName: name }); |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | async disconnect(): Promise<void> { |
| 288 | if (this.sock) { |
| 289 | this.sock.end(undefined); |
| 290 | this.sock = null; |
| 291 | } |
| 292 | } |
| 293 | } |
| 294 |