diff --git a/HANDOFF.md b/HANDOFF.md index 6644ec9..46d04cb 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -22,7 +22,7 @@ - **Stage 10.5** (85c72e4): Rename secrets.py, enhanced /status, usage tracking - **Stage 11** (d1bb67a): Security Hardening — prompt injection, invocation/security logging, extended doctor - **Stage 12** (2d8e56d): Telegram Bot — python-telegram-bot, commands, inline keyboards, concurrent with Discord -- **Stage 13**: WhatsApp Bridge — Baileys Node.js bridge + Python adapter, polling, CLI commands +- **Stage 13** (80502b7 + fix): WhatsApp Bridge — Baileys Node.js bridge + Python adapter, polling, group chat, CLI commands ### Total teste: 442 PASS (zero failures) @@ -45,7 +45,7 @@ - Routes through existing router.py (same as Discord/Telegram) - Separate auth: whatsapp.owner + whatsapp.admins (phone numbers) - Private chat: admin-only (unauthorized logged to security.log) - - Group chat: registered chats only (skipped for now) + - Group chat: registered chats processed, uses group JID as channel_id - Commands: /clear, /status handled inline - Other commands and messages routed to Claude via route_message - Message splitting at 4096 chars diff --git a/bridge/whatsapp/index.js b/bridge/whatsapp/index.js index 22721d6..15e146a 100644 --- a/bridge/whatsapp/index.js +++ b/bridge/whatsapp/index.js @@ -7,7 +7,7 @@ const QRCode = require('qrcode'); const path = require('path'); const PORT = 8098; -const HOST = '127.0.0.1'; +const HOST = '0.0.0.0'; const AUTH_DIR = path.join(__dirname, 'auth'); const MAX_RECONNECT_ATTEMPTS = 5; @@ -84,26 +84,27 @@ async function startConnection() { if (type !== 'notify') return; for (const msg of messages) { - // Skip own messages - if (msg.key.fromMe) continue; // Skip status broadcasts if (msg.key.remoteJid === 'status@broadcast') continue; + // Skip own messages in private chats (allow in groups for self-chat) + const isGroup = msg.key.remoteJid.endsWith('@g.us'); + if (msg.key.fromMe && !isGroup) continue; // Only text messages const text = msg.message?.conversation || msg.message?.extendedTextMessage?.text; if (!text) continue; - const isGroup = msg.key.remoteJid.endsWith('@g.us'); - messageQueue.push({ from: msg.key.remoteJid, + participant: msg.key.participant || null, pushName: msg.pushName || null, text, timestamp: msg.messageTimestamp, id: msg.key.id, isGroup, + fromMe: msg.key.fromMe || false, }); - console.log(`[whatsapp] Message from ${msg.pushName || msg.key.remoteJid}: ${text.substring(0, 80)}`); + console.log(`[whatsapp] Message from ${msg.pushName || 'unknown'} in ${msg.key.remoteJid}: ${text.substring(0, 80)}`); } }); } @@ -128,7 +129,14 @@ app.get('/qr', (_req, res) => { if (!currentQR) { return res.json({ error: 'no QR code available yet' }); } - res.json({ qr: currentQR }); + // Return as HTML page with QR image for easy scanning + const html = ` +
Scan with WhatsApp → Linked Devices
`; + res.type('html').send(html); }); app.post('/send', async (req, res) => { diff --git a/config.json b/config.json index 0214fbe..c1c1199 100644 --- a/config.json +++ b/config.json @@ -13,12 +13,17 @@ }, "telegram_channels": {}, "whatsapp": { - "enabled": false, + "enabled": true, "bridge_url": "http://127.0.0.1:8098", - "owner": null, + "owner": "40723197939", "admins": [] }, - "whatsapp_channels": {}, + "whatsapp_channels": { + "echo-test": { + "id": "120363424350922235@g.us", + "default_model": "opus" + } + }, "heartbeat": { "enabled": true, "interval_minutes": 30 diff --git a/src/adapters/whatsapp.py b/src/adapters/whatsapp.py index e0057b2..66f5632 100644 --- a/src/adapters/whatsapp.py +++ b/src/adapters/whatsapp.py @@ -130,23 +130,21 @@ async def handle_incoming(msg: dict, client: httpx.AsyncClient) -> None: # Group chat: only registered chats if is_group: - chat_id = sender # group JID - if not is_registered_chat(chat_id): + group_jid = sender # group JID like 123456@g.us + if not is_registered_chat(group_jid): return - # Group messages — skip for now (can be enhanced later) - return - - # Private chat: check admin - phone = sender.split("@")[0] - if not is_admin(phone): - _security_log.warning( - "Unauthorized WhatsApp DM from %s (%s): %.100s", - phone, push_name, text, - ) - return - - # Use phone number as channel ID - channel_id = f"wa-{phone}" + # Use group JID as channel ID + channel_id = f"wa-{group_jid.split('@')[0]}" + else: + # Private chat: check admin + phone = sender.split("@")[0] + if not is_admin(phone): + _security_log.warning( + "Unauthorized WhatsApp DM from %s (%s): %.100s", + phone, push_name, text, + ) + return + channel_id = f"wa-{phone}" # Handle slash commands locally for immediate response if text.startswith("/"): @@ -175,15 +173,19 @@ async def handle_incoming(msg: dict, client: httpx.AsyncClient) -> None: await send_whatsapp(client, sender, reply) return + # Identify sender for logging/routing + participant = msg.get("participant") or sender + user_id = participant.split("@")[0] + # Route to Claude via router (handles /model and regular messages) - log.info("Message from %s (%s): %.50s", phone, push_name, text) + log.info("Message from %s (%s): %.50s", user_id, push_name, text) try: response, _is_cmd = await asyncio.to_thread( - route_message, channel_id, phone, text + route_message, channel_id, user_id, text ) await send_whatsapp(client, sender, response) except Exception as e: - log.error("Error handling message from %s: %s", phone, e) + log.error("Error handling message from %s: %s", user_id, e) await send_whatsapp(client, sender, "Sorry, an error occurred.") diff --git a/tests/test_whatsapp.py b/tests/test_whatsapp.py index 2c545bc..5ea3ea0 100644 --- a/tests/test_whatsapp.py +++ b/tests/test_whatsapp.py @@ -276,17 +276,19 @@ class TestHandleIncoming: mock_route.assert_not_called() @pytest.mark.asyncio - async def test_group_registered_skip(self, _set_owned): + async def test_group_registered_routed(self, _set_owned): client = _mock_client() + client.post.return_value = _mock_httpx_response(json_data={"ok": True}) msg = { "from": "group123@g.us", + "participant": "5511999990000@s.whatsapp.net", "text": "Hello", "pushName": "User", "isGroup": True, } - with patch("src.adapters.whatsapp.route_message") as mock_route: + with patch("src.adapters.whatsapp.route_message", return_value=("Hi!", False)) as mock_route: await handle_incoming(msg, client) - mock_route.assert_not_called() + mock_route.assert_called_once() @pytest.mark.asyncio async def test_clear_command(self, _set_owned):