diff --git a/bridge/src/server.ts b/bridge/src/server.ts index 7d48f5e..4e50f4a 100644 --- a/bridge/src/server.ts +++ b/bridge/src/server.ts @@ -12,6 +12,17 @@ interface SendCommand { text: string; } +interface SendMediaCommand { + type: 'send_media'; + to: string; + filePath: string; + mimetype: string; + caption?: string; + fileName?: string; +} + +type BridgeCommand = SendCommand | SendMediaCommand; + interface BridgeMessage { type: 'message' | 'status' | 'qr' | 'error'; [key: string]: unknown; @@ -72,7 +83,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 BridgeCommand; await this.handleCommand(cmd); ws.send(JSON.stringify({ type: 'sent', to: cmd.to })); } catch (error) { @@ -92,9 +103,13 @@ export class BridgeServer { }); } - private async handleCommand(cmd: SendCommand): Promise { - if (cmd.type === 'send' && this.wa) { + private async handleCommand(cmd: BridgeCommand): Promise { + if (!this.wa) return; + + if (cmd.type === 'send') { await this.wa.sendMessage(cmd.to, cmd.text); + } else if (cmd.type === 'send_media') { + await this.wa.sendMedia(cmd.to, cmd.filePath, cmd.mimetype, cmd.caption, cmd.fileName); } } diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts index f0485bd..04eba0f 100644 --- a/bridge/src/whatsapp.ts +++ b/bridge/src/whatsapp.ts @@ -16,8 +16,8 @@ import makeWASocket, { import { Boom } from '@hapi/boom'; import qrcode from 'qrcode-terminal'; import pino from 'pino'; -import { writeFile, mkdir } from 'fs/promises'; -import { join } from 'path'; +import { readFile, writeFile, mkdir } from 'fs/promises'; +import { join, basename } from 'path'; import { randomBytes } from 'crypto'; const VERSION = '0.1.0'; @@ -230,6 +230,32 @@ export class WhatsAppClient { await this.sock.sendMessage(to, { text }); } + async sendMedia( + to: string, + filePath: string, + mimetype: string, + caption?: string, + fileName?: string, + ): Promise { + if (!this.sock) { + throw new Error('Not connected'); + } + + const buffer = await readFile(filePath); + const category = mimetype.split('/')[0]; + + if (category === 'image') { + await this.sock.sendMessage(to, { image: buffer, caption: caption || undefined, mimetype }); + } else if (category === 'video') { + await this.sock.sendMessage(to, { video: buffer, caption: caption || undefined, mimetype }); + } else if (category === 'audio') { + await this.sock.sendMessage(to, { audio: buffer, mimetype }); + } else { + const name = fileName || basename(filePath); + await this.sock.sendMessage(to, { document: buffer, mimetype, fileName: name }); + } + } + async disconnect(): Promise { if (this.sock) { this.sock.end(undefined); diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index f1a1fca..7239888 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -137,11 +137,28 @@ class WhatsAppChannel(BaseChannel): logger.warning("WhatsApp bridge not connected") return - try: - 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) + chat_id = msg.chat_id + + if msg.content: + try: + payload = {"type": "send", "to": 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) + + for media_path in msg.media or []: + try: + mime, _ = mimetypes.guess_type(media_path) + payload = { + "type": "send_media", + "to": chat_id, + "filePath": media_path, + "mimetype": mime or "application/octet-stream", + "fileName": media_path.rsplit("/", 1)[-1], + } + await self._ws.send(json.dumps(payload, ensure_ascii=False)) + except Exception as e: + logger.error("Error sending WhatsApp media {}: {}", media_path, e) async def _handle_bridge_message(self, raw: str) -> None: """Handle a message from the bridge.""" diff --git a/tests/test_whatsapp_channel.py b/tests/test_whatsapp_channel.py new file mode 100644 index 0000000..1413429 --- /dev/null +++ b/tests/test_whatsapp_channel.py @@ -0,0 +1,108 @@ +"""Tests for WhatsApp channel outbound media support.""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from nanobot.bus.events import OutboundMessage +from nanobot.channels.whatsapp import WhatsAppChannel + + +def _make_channel() -> WhatsAppChannel: + bus = MagicMock() + ch = WhatsAppChannel({"enabled": True}, bus) + ch._ws = AsyncMock() + ch._connected = True + return ch + + +@pytest.mark.asyncio +async def test_send_text_only(): + ch = _make_channel() + msg = OutboundMessage(channel="whatsapp", chat_id="123@s.whatsapp.net", content="hello") + + await ch.send(msg) + + ch._ws.send.assert_called_once() + payload = json.loads(ch._ws.send.call_args[0][0]) + assert payload["type"] == "send" + assert payload["text"] == "hello" + + +@pytest.mark.asyncio +async def test_send_media_dispatches_send_media_command(): + ch = _make_channel() + msg = OutboundMessage( + channel="whatsapp", + chat_id="123@s.whatsapp.net", + content="check this out", + media=["/tmp/photo.jpg"], + ) + + await ch.send(msg) + + assert ch._ws.send.call_count == 2 + text_payload = json.loads(ch._ws.send.call_args_list[0][0][0]) + media_payload = json.loads(ch._ws.send.call_args_list[1][0][0]) + + assert text_payload["type"] == "send" + assert text_payload["text"] == "check this out" + + assert media_payload["type"] == "send_media" + assert media_payload["filePath"] == "/tmp/photo.jpg" + assert media_payload["mimetype"] == "image/jpeg" + assert media_payload["fileName"] == "photo.jpg" + + +@pytest.mark.asyncio +async def test_send_media_only_no_text(): + ch = _make_channel() + msg = OutboundMessage( + channel="whatsapp", + chat_id="123@s.whatsapp.net", + content="", + media=["/tmp/doc.pdf"], + ) + + await ch.send(msg) + + ch._ws.send.assert_called_once() + payload = json.loads(ch._ws.send.call_args[0][0]) + assert payload["type"] == "send_media" + assert payload["mimetype"] == "application/pdf" + + +@pytest.mark.asyncio +async def test_send_multiple_media(): + ch = _make_channel() + msg = OutboundMessage( + channel="whatsapp", + chat_id="123@s.whatsapp.net", + content="", + media=["/tmp/a.png", "/tmp/b.mp4"], + ) + + await ch.send(msg) + + assert ch._ws.send.call_count == 2 + p1 = json.loads(ch._ws.send.call_args_list[0][0][0]) + p2 = json.loads(ch._ws.send.call_args_list[1][0][0]) + assert p1["mimetype"] == "image/png" + assert p2["mimetype"] == "video/mp4" + + +@pytest.mark.asyncio +async def test_send_when_disconnected_is_noop(): + ch = _make_channel() + ch._connected = False + + msg = OutboundMessage( + channel="whatsapp", + chat_id="123@s.whatsapp.net", + content="hello", + media=["/tmp/x.jpg"], + ) + await ch.send(msg) + + ch._ws.send.assert_not_called()