fix WhatsApp group chat support and self-message handling

Bridge: allow fromMe messages in groups, include participant field in
message queue, bind to 0.0.0.0 for network access, QR served as HTML.

Adapter: process registered group messages (route to Claude), extract
participant for user identification, fix unbound 'phone' variable.

Tested end-to-end: WhatsApp group chat with Claude working. 442 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MoltBot Service
2026-02-13 22:31:22 +00:00
parent 80502b7931
commit 624eb095f1
5 changed files with 51 additions and 34 deletions

View File

@@ -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

View File

@@ -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 = `<!DOCTYPE html>
<html><head><title>WhatsApp QR</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>body{display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#111;flex-direction:column;font-family:sans-serif;color:#fff}
img{width:400px;height:400px;border-radius:12px}p{margin-top:16px;opacity:.6}</style></head>
<body><img src="${currentQR}" alt="QR Code"/><p>Scan with WhatsApp &rarr; Linked Devices</p></body></html>`;
res.type('html').send(html);
});
app.post('/send', async (req, res) => {

View File

@@ -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

View File

@@ -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.")

View File

@@ -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):