diff --git a/bridge/src/server.ts b/bridge/src/server.ts index 7d48f5e..ec5573a 100644 --- a/bridge/src/server.ts +++ b/bridge/src/server.ts @@ -12,6 +12,13 @@ interface SendCommand { text: string; } +interface SendImageCommand { + type: 'send_image'; + to: string; + imagePath: string; + caption?: string; +} + interface BridgeMessage { type: 'message' | 'status' | 'qr' | 'error'; [key: string]: unknown; @@ -72,7 +79,7 @@ export class BridgeServer { ws.on('message', async (data) => { try { - const cmd = JSON.parse(data.toString()) as SendCommand; + const cmd = JSON.parse(data.toString()) as SendCommand | SendImageCommand; await this.handleCommand(cmd); ws.send(JSON.stringify({ type: 'sent', to: cmd.to })); } catch (error) { @@ -92,9 +99,11 @@ export class BridgeServer { }); } - private async handleCommand(cmd: SendCommand): Promise { + private async handleCommand(cmd: SendCommand | SendImageCommand): Promise { if (cmd.type === 'send' && this.wa) { await this.wa.sendMessage(cmd.to, cmd.text); + } else if (cmd.type === 'send_image' && this.wa) { + await this.wa.sendImage(cmd.to, cmd.imagePath, cmd.caption); } } diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts index 069d72b..d34100f 100644 --- a/bridge/src/whatsapp.ts +++ b/bridge/src/whatsapp.ts @@ -9,11 +9,17 @@ import makeWASocket, { useMultiFileAuthState, fetchLatestBaileysVersion, makeCacheableSignalKeyStore, + downloadMediaMessage, + extractMessageContent as baileysExtractMessageContent, } from '@whiskeysockets/baileys'; import { Boom } from '@hapi/boom'; import qrcode from 'qrcode-terminal'; import pino from 'pino'; +import { writeFile, mkdir, readFile } from 'fs/promises'; +import { join } from 'path'; +import { homedir } from 'os'; +import { randomBytes } from 'crypto'; const VERSION = '0.1.0'; @@ -24,6 +30,7 @@ export interface InboundMessage { content: string; timestamp: number; isGroup: boolean; + media?: string[]; } export interface WhatsAppClientOptions { @@ -110,14 +117,21 @@ export class WhatsAppClient { if (type !== 'notify') return; for (const msg of messages) { - // Skip own messages if (msg.key.fromMe) continue; - - // Skip status updates if (msg.key.remoteJid === 'status@broadcast') continue; - const content = this.extractMessageContent(msg); - if (!content) continue; + const unwrapped = baileysExtractMessageContent(msg.message); + if (!unwrapped) continue; + + const content = this.getTextContent(unwrapped); + const mediaPaths: string[] = []; + + if (unwrapped.imageMessage) { + const path = await this.downloadImage(msg, unwrapped.imageMessage.mimetype ?? undefined); + if (path) mediaPaths.push(path); + } + + if (!content && mediaPaths.length === 0) continue; const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false; @@ -125,18 +139,43 @@ export class WhatsAppClient { id: msg.key.id || '', sender: msg.key.remoteJid || '', pn: msg.key.remoteJidAlt || '', - content, + content: content || '', timestamp: msg.messageTimestamp as number, isGroup, + ...(mediaPaths.length > 0 ? { media: mediaPaths } : {}), }); } }); } - private extractMessageContent(msg: any): string | null { - const message = msg.message; - if (!message) return null; + private async downloadImage(msg: any, mimetype?: string): Promise { + try { + const mediaDir = join(homedir(), '.nanobot', 'media'); + await mkdir(mediaDir, { recursive: true }); + const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer; + + const mime = mimetype || 'image/jpeg'; + const extMap: Record = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/gif': '.gif', + 'image/webp': '.webp', + }; + const ext = extMap[mime] || '.jpg'; + + const filename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`; + const filepath = join(mediaDir, filename); + await writeFile(filepath, buffer); + + return filepath; + } catch (err) { + console.error('Failed to download image:', err); + return null; + } + } + + private getTextContent(message: any): string | null { // Text message if (message.conversation) { return message.conversation; @@ -147,9 +186,9 @@ export class WhatsAppClient { return message.extendedTextMessage.text; } - // Image with caption - if (message.imageMessage?.caption) { - return `[Image] ${message.imageMessage.caption}`; + // Image with optional caption + if (message.imageMessage) { + return message.imageMessage.caption || ''; } // Video with caption @@ -178,6 +217,18 @@ export class WhatsAppClient { await this.sock.sendMessage(to, { text }); } + async sendImage(to: string, imagePath: string, caption?: string): Promise { + if (!this.sock) { + throw new Error('Not connected'); + } + + const buffer = await readFile(imagePath); + await this.sock.sendMessage(to, { + image: buffer, + caption: caption || undefined, + }); + } + async disconnect(): Promise { if (this.sock) { this.sock.end(undefined); diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 0d1ec7e..1a96753 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -83,12 +83,26 @@ class WhatsAppChannel(BaseChannel): return try: - payload = { - "type": "send", - "to": msg.chat_id, - "text": msg.content - } - await self._ws.send(json.dumps(payload, ensure_ascii=False)) + # Send media files first + for media_path in (msg.media or []): + try: + payload = { + "type": "send_image", + "to": msg.chat_id, + "imagePath": media_path, + } + await self._ws.send(json.dumps(payload, ensure_ascii=False)) + except Exception as e: + logger.error("Error sending WhatsApp media {}: {}", media_path, e) + + # Send text message if there's content + if msg.content: + payload = { + "type": "send", + "to": msg.chat_id, + "text": msg.content + } + await self._ws.send(json.dumps(payload, ensure_ascii=False)) except Exception as e: logger.error("Error sending WhatsApp message: {}", e) @@ -128,10 +142,18 @@ class WhatsAppChannel(BaseChannel): logger.info("Voice message received from {}, but direct download from bridge is not yet supported.", sender_id) content = "[Voice Message: Transcription not available for WhatsApp yet]" + # Extract media paths (images downloaded by the bridge) + media_paths = data.get("media") or [] + + # For image messages without caption, provide descriptive content + if not content and media_paths: + content = "[image]" + await self._handle_message( sender_id=sender_id, chat_id=sender, # Use full LID for replies content=content, + media=media_paths, metadata={ "message_id": message_id, "timestamp": data.get("timestamp"),