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 10.5** (85c72e4): Rename secrets.py, enhanced /status, usage tracking
|
||||||
- **Stage 11** (d1bb67a): Security Hardening — prompt injection, invocation/security logging, extended doctor
|
- **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 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)
|
### Total teste: 442 PASS (zero failures)
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
- Routes through existing router.py (same as Discord/Telegram)
|
- Routes through existing router.py (same as Discord/Telegram)
|
||||||
- Separate auth: whatsapp.owner + whatsapp.admins (phone numbers)
|
- Separate auth: whatsapp.owner + whatsapp.admins (phone numbers)
|
||||||
- Private chat: admin-only (unauthorized logged to security.log)
|
- 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
|
- Commands: /clear, /status handled inline
|
||||||
- Other commands and messages routed to Claude via route_message
|
- Other commands and messages routed to Claude via route_message
|
||||||
- Message splitting at 4096 chars
|
- Message splitting at 4096 chars
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const QRCode = require('qrcode');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const PORT = 8098;
|
const PORT = 8098;
|
||||||
const HOST = '127.0.0.1';
|
const HOST = '0.0.0.0';
|
||||||
const AUTH_DIR = path.join(__dirname, 'auth');
|
const AUTH_DIR = path.join(__dirname, 'auth');
|
||||||
const MAX_RECONNECT_ATTEMPTS = 5;
|
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||||
|
|
||||||
@@ -84,26 +84,27 @@ async function startConnection() {
|
|||||||
if (type !== 'notify') return;
|
if (type !== 'notify') return;
|
||||||
|
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
// Skip own messages
|
|
||||||
if (msg.key.fromMe) continue;
|
|
||||||
// Skip status broadcasts
|
// Skip status broadcasts
|
||||||
if (msg.key.remoteJid === 'status@broadcast') continue;
|
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
|
// Only text messages
|
||||||
const text = msg.message?.conversation || msg.message?.extendedTextMessage?.text;
|
const text = msg.message?.conversation || msg.message?.extendedTextMessage?.text;
|
||||||
if (!text) continue;
|
if (!text) continue;
|
||||||
|
|
||||||
const isGroup = msg.key.remoteJid.endsWith('@g.us');
|
|
||||||
|
|
||||||
messageQueue.push({
|
messageQueue.push({
|
||||||
from: msg.key.remoteJid,
|
from: msg.key.remoteJid,
|
||||||
|
participant: msg.key.participant || null,
|
||||||
pushName: msg.pushName || null,
|
pushName: msg.pushName || null,
|
||||||
text,
|
text,
|
||||||
timestamp: msg.messageTimestamp,
|
timestamp: msg.messageTimestamp,
|
||||||
id: msg.key.id,
|
id: msg.key.id,
|
||||||
isGroup,
|
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) {
|
if (!currentQR) {
|
||||||
return res.json({ error: 'no QR code available yet' });
|
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) => {
|
app.post('/send', async (req, res) => {
|
||||||
|
|||||||
11
config.json
11
config.json
@@ -13,12 +13,17 @@
|
|||||||
},
|
},
|
||||||
"telegram_channels": {},
|
"telegram_channels": {},
|
||||||
"whatsapp": {
|
"whatsapp": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"bridge_url": "http://127.0.0.1:8098",
|
"bridge_url": "http://127.0.0.1:8098",
|
||||||
"owner": null,
|
"owner": "40723197939",
|
||||||
"admins": []
|
"admins": []
|
||||||
},
|
},
|
||||||
"whatsapp_channels": {},
|
"whatsapp_channels": {
|
||||||
|
"echo-test": {
|
||||||
|
"id": "120363424350922235@g.us",
|
||||||
|
"default_model": "opus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"heartbeat": {
|
"heartbeat": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"interval_minutes": 30
|
"interval_minutes": 30
|
||||||
|
|||||||
@@ -130,23 +130,21 @@ async def handle_incoming(msg: dict, client: httpx.AsyncClient) -> None:
|
|||||||
|
|
||||||
# Group chat: only registered chats
|
# Group chat: only registered chats
|
||||||
if is_group:
|
if is_group:
|
||||||
chat_id = sender # group JID
|
group_jid = sender # group JID like 123456@g.us
|
||||||
if not is_registered_chat(chat_id):
|
if not is_registered_chat(group_jid):
|
||||||
return
|
return
|
||||||
# Group messages — skip for now (can be enhanced later)
|
# Use group JID as channel ID
|
||||||
return
|
channel_id = f"wa-{group_jid.split('@')[0]}"
|
||||||
|
else:
|
||||||
# Private chat: check admin
|
# Private chat: check admin
|
||||||
phone = sender.split("@")[0]
|
phone = sender.split("@")[0]
|
||||||
if not is_admin(phone):
|
if not is_admin(phone):
|
||||||
_security_log.warning(
|
_security_log.warning(
|
||||||
"Unauthorized WhatsApp DM from %s (%s): %.100s",
|
"Unauthorized WhatsApp DM from %s (%s): %.100s",
|
||||||
phone, push_name, text,
|
phone, push_name, text,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
channel_id = f"wa-{phone}"
|
||||||
# Use phone number as channel ID
|
|
||||||
channel_id = f"wa-{phone}"
|
|
||||||
|
|
||||||
# Handle slash commands locally for immediate response
|
# Handle slash commands locally for immediate response
|
||||||
if text.startswith("/"):
|
if text.startswith("/"):
|
||||||
@@ -175,15 +173,19 @@ async def handle_incoming(msg: dict, client: httpx.AsyncClient) -> None:
|
|||||||
await send_whatsapp(client, sender, reply)
|
await send_whatsapp(client, sender, reply)
|
||||||
return
|
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)
|
# 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:
|
try:
|
||||||
response, _is_cmd = await asyncio.to_thread(
|
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)
|
await send_whatsapp(client, sender, response)
|
||||||
except Exception as e:
|
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.")
|
await send_whatsapp(client, sender, "Sorry, an error occurred.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -276,17 +276,19 @@ class TestHandleIncoming:
|
|||||||
mock_route.assert_not_called()
|
mock_route.assert_not_called()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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 = _mock_client()
|
||||||
|
client.post.return_value = _mock_httpx_response(json_data={"ok": True})
|
||||||
msg = {
|
msg = {
|
||||||
"from": "group123@g.us",
|
"from": "group123@g.us",
|
||||||
|
"participant": "5511999990000@s.whatsapp.net",
|
||||||
"text": "Hello",
|
"text": "Hello",
|
||||||
"pushName": "User",
|
"pushName": "User",
|
||||||
"isGroup": True,
|
"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)
|
await handle_incoming(msg, client)
|
||||||
mock_route.assert_not_called()
|
mock_route.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_clear_command(self, _set_owned):
|
async def test_clear_command(self, _set_owned):
|
||||||
|
|||||||
Reference in New Issue
Block a user