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:
@@ -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
|
||||
|
||||
@@ -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 → Linked Devices</p></body></html>`;
|
||||
res.type('html').send(html);
|
||||
});
|
||||
|
||||
app.post('/send', async (req, res) => {
|
||||
|
||||
11
config.json
11
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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user