stage-13: WhatsApp bridge with Baileys + Python adapter
Node.js bridge (bridge/whatsapp/): Baileys client with Express HTTP API on localhost:8098 — QR code linking, message queue, reconnection logic. Python adapter (src/adapters/whatsapp.py): polls bridge every 2s, routes through router.py, separate whatsapp.owner/admins auth, security logging. Integrated in main.py alongside Discord + Telegram via asyncio.gather. CLI: echo whatsapp status/qr. 442 tests pass (32 new, zero failures). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,5 +11,7 @@ logs/
|
||||
*.secret
|
||||
.DS_Store
|
||||
*.swp
|
||||
bridge/whatsapp/node_modules/
|
||||
bridge/whatsapp/auth/
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
124
HANDOFF.md
Normal file
124
HANDOFF.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Echo Core — Session Handoff
|
||||
|
||||
**Data:** 2026-02-13
|
||||
**Proiect:** ~/echo-core/ (inlocuire completa OpenClaw)
|
||||
**Plan complet:** ~/.claude/plans/enumerated-noodling-floyd.md
|
||||
|
||||
---
|
||||
|
||||
## Status curent: Stage 13 — COMPLET. Toate stages finalizate.
|
||||
|
||||
### Stages completate (toate committed):
|
||||
- **Stage 1** (f2973aa): Project Bootstrap — structura, git, venv, copiere fisiere din clawd
|
||||
- **Stage 2** (010580b): Secrets Manager — keyring, CLI `echo secrets set/list/test`
|
||||
- **Stage 3** (339866b): Claude CLI Wrapper — start/resume/clear sessions cu `claude --resume`
|
||||
- **Stage 4** (6cd155b): Discord Bot Minimal — online, /ping, /channel add, /admin add, /setup
|
||||
- **Stage 5** (a1a6ca9): Discord + Claude Chat — conversatii complete, typing indicator, message split
|
||||
- **Stage 6** (5bdceff): Model Selection — /model opus/sonnet/haiku, default per canal
|
||||
- **Stage 7** (09d3de0): CLI Tool — echo status/doctor/restart/logs/sessions/channel/send
|
||||
- **Stage 8** (24a4d87): Cron Scheduler — APScheduler, /cron add/list/run/enable/disable
|
||||
- **Stage 9** (0bc4b8c): Heartbeat — verificari periodice (email, calendar, kb index, git)
|
||||
- **Stage 10** (0ecfa63): Memory Search — Ollama all-minilm embeddings + SQLite semantic search
|
||||
- **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
|
||||
|
||||
### Total teste: 442 PASS (zero failures)
|
||||
|
||||
---
|
||||
|
||||
## Ce a fost implementat in Stage 13:
|
||||
|
||||
1. **bridge/whatsapp/** — Node.js WhatsApp bridge:
|
||||
- Baileys (@whiskeysockets/baileys) — lightweight, no Chromium
|
||||
- Express HTTP server on localhost:8098
|
||||
- Endpoints: GET /status, GET /qr, POST /send, GET /messages
|
||||
- QR code generation as base64 PNG for device linking
|
||||
- Session persistence in bridge/whatsapp/auth/
|
||||
- Reconnection with exponential backoff (max 5 attempts)
|
||||
- Message queue: incoming text messages queued, drained on poll
|
||||
- Graceful shutdown on SIGTERM/SIGINT
|
||||
|
||||
2. **src/adapters/whatsapp.py** — Python WhatsApp adapter:
|
||||
- Polls Node.js bridge every 2s via httpx
|
||||
- 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)
|
||||
- Commands: /clear, /status handled inline
|
||||
- Other commands and messages routed to Claude via route_message
|
||||
- Message splitting at 4096 chars
|
||||
- Wait-for-bridge logic on startup (30 retries, 5s interval)
|
||||
|
||||
3. **main.py** — Concurrent execution:
|
||||
- Discord + Telegram + WhatsApp in same event loop via asyncio.gather
|
||||
- WhatsApp optional: enabled via config.json `whatsapp.enabled`
|
||||
- No new secrets needed (bridge URL configured in config.json)
|
||||
|
||||
4. **config.json** — New sections:
|
||||
- `whatsapp: {enabled, bridge_url, owner, admins}`
|
||||
- `whatsapp_channels: {}`
|
||||
|
||||
5. **cli.py** — New commands:
|
||||
- `echo whatsapp status` — check bridge connection
|
||||
- `echo whatsapp qr` — show QR code instructions
|
||||
|
||||
6. **.gitignore** — Added bridge/whatsapp/node_modules/ and auth/
|
||||
|
||||
---
|
||||
|
||||
## Setup WhatsApp:
|
||||
|
||||
```bash
|
||||
# 1. Install Node.js bridge dependencies:
|
||||
cd ~/echo-core/bridge/whatsapp && npm install
|
||||
|
||||
# 2. Start the bridge:
|
||||
node bridge/whatsapp/index.js
|
||||
# → QR code will appear — scan with WhatsApp (Linked Devices)
|
||||
|
||||
# 3. Enable in config.json:
|
||||
# "whatsapp": {"enabled": true, "bridge_url": "http://127.0.0.1:8098", "owner": "PHONE", "admins": []}
|
||||
|
||||
# 4. Restart Echo Core:
|
||||
echo restart
|
||||
|
||||
# 5. Send a message from WhatsApp to the linked number
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fisiere cheie:
|
||||
|
||||
| Fisier | Descriere |
|
||||
|--------|-----------|
|
||||
| `src/main.py` | Entry point — Discord + Telegram + WhatsApp + scheduler + heartbeat |
|
||||
| `src/claude_session.py` | Claude Code CLI wrapper cu --resume, injection protection |
|
||||
| `src/router.py` | Message routing (comanda vs Claude) |
|
||||
| `src/scheduler.py` | APScheduler cron jobs |
|
||||
| `src/heartbeat.py` | Verificari periodice |
|
||||
| `src/memory_search.py` | Semantic search — Ollama embeddings + SQLite |
|
||||
| `src/credential_store.py` | Credential broker (keyring) |
|
||||
| `src/config.py` | Config loader (config.json) |
|
||||
| `src/adapters/discord_bot.py` | Discord bot cu slash commands |
|
||||
| `src/adapters/telegram_bot.py` | Telegram bot cu commands + inline keyboards |
|
||||
| `src/adapters/whatsapp.py` | WhatsApp adapter — polls Node.js bridge |
|
||||
| `bridge/whatsapp/index.js` | Node.js WhatsApp bridge — Baileys + Express |
|
||||
| `cli.py` | CLI: echo status/doctor/restart/logs/secrets/cron/heartbeat/memory/whatsapp |
|
||||
| `config.json` | Runtime config (channels, telegram_channels, whatsapp, admins, models) |
|
||||
|
||||
## Decizii arhitecturale:
|
||||
- **Claude invocation**: Claude Code CLI cu `--resume` pentru sesiuni persistente
|
||||
- **Credentials**: keyring (nu plain text pe disk), subprocess isolation
|
||||
- **Discord**: slash commands (`/`), canale asociate dinamic
|
||||
- **Telegram**: commands + inline keyboards, @mention/reply in groups
|
||||
- **WhatsApp**: Baileys Node.js bridge + Python polling adapter, separate auth namespace
|
||||
- **Cron**: APScheduler, sesiuni izolate per job, `--allowedTools` per job
|
||||
- **Heartbeat**: verificari periodice, quiet hours (23-08), state tracking
|
||||
- **Memory Search**: Ollama all-minilm (384 dim), SQLite, cosine similarity
|
||||
- **Security**: prompt injection markers, separate security.log, extended doctor
|
||||
- **Concurrency**: Discord + Telegram + WhatsApp in same asyncio event loop via gather
|
||||
|
||||
## Infrastructura:
|
||||
- Ollama: http://10.0.20.161:11434 (all-minilm, llama3.2, nomic-embed-text)
|
||||
2
bridge/whatsapp/.gitignore
vendored
Normal file
2
bridge/whatsapp/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
auth/
|
||||
184
bridge/whatsapp/index.js
Normal file
184
bridge/whatsapp/index.js
Normal file
@@ -0,0 +1,184 @@
|
||||
// NOTE: auth/ directory is in .gitignore — do not commit session data
|
||||
|
||||
const { default: makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion } = require('@whiskeysockets/baileys');
|
||||
const express = require('express');
|
||||
const pino = require('pino');
|
||||
const QRCode = require('qrcode');
|
||||
const path = require('path');
|
||||
|
||||
const PORT = 8098;
|
||||
const HOST = '127.0.0.1';
|
||||
const AUTH_DIR = path.join(__dirname, 'auth');
|
||||
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||
|
||||
const logger = pino({ level: 'warn' });
|
||||
|
||||
let sock = null;
|
||||
let connected = false;
|
||||
let phoneNumber = null;
|
||||
let currentQR = null;
|
||||
let reconnectAttempts = 0;
|
||||
let messageQueue = [];
|
||||
let shuttingDown = false;
|
||||
|
||||
// --- WhatsApp connection ---
|
||||
|
||||
async function startConnection() {
|
||||
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
|
||||
const { version } = await fetchLatestBaileysVersion();
|
||||
|
||||
sock = makeWASocket({
|
||||
version,
|
||||
auth: state,
|
||||
logger,
|
||||
printQRInTerminal: false,
|
||||
defaultQueryTimeoutMs: 60000,
|
||||
});
|
||||
|
||||
sock.ev.on('creds.update', saveCreds);
|
||||
|
||||
sock.ev.on('connection.update', async (update) => {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
|
||||
if (qr) {
|
||||
try {
|
||||
currentQR = await QRCode.toDataURL(qr);
|
||||
console.log('[whatsapp] QR code generated — scan with WhatsApp to link');
|
||||
} catch (err) {
|
||||
console.error('[whatsapp] Failed to generate QR code:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (connection === 'open') {
|
||||
connected = true;
|
||||
currentQR = null;
|
||||
reconnectAttempts = 0;
|
||||
phoneNumber = sock.user?.id?.split(':')[0] || sock.user?.id?.split('@')[0] || null;
|
||||
console.log(`[whatsapp] Connected as ${phoneNumber}`);
|
||||
}
|
||||
|
||||
if (connection === 'close') {
|
||||
connected = false;
|
||||
phoneNumber = null;
|
||||
const statusCode = lastDisconnect?.error?.output?.statusCode;
|
||||
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
|
||||
|
||||
console.log(`[whatsapp] Disconnected (status: ${statusCode})`);
|
||||
|
||||
if (shouldReconnect && !shuttingDown) {
|
||||
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
||||
reconnectAttempts++;
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
|
||||
console.log(`[whatsapp] Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
|
||||
setTimeout(startConnection, delay);
|
||||
} else {
|
||||
console.error(`[whatsapp] Max reconnect attempts reached (${MAX_RECONNECT_ATTEMPTS})`);
|
||||
}
|
||||
} else if (statusCode === DisconnectReason.loggedOut) {
|
||||
console.log('[whatsapp] Logged out — delete auth/ and restart to re-link');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sock.ev.on('messages.upsert', ({ messages, type }) => {
|
||||
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;
|
||||
// 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,
|
||||
pushName: msg.pushName || null,
|
||||
text,
|
||||
timestamp: msg.messageTimestamp,
|
||||
id: msg.key.id,
|
||||
isGroup,
|
||||
});
|
||||
|
||||
console.log(`[whatsapp] Message from ${msg.pushName || msg.key.remoteJid}: ${text.substring(0, 80)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Express API ---
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/status', (_req, res) => {
|
||||
res.json({
|
||||
connected,
|
||||
phone: phoneNumber,
|
||||
qr: connected ? null : currentQR,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/qr', (_req, res) => {
|
||||
if (connected) {
|
||||
return res.json({ error: 'already connected' });
|
||||
}
|
||||
if (!currentQR) {
|
||||
return res.json({ error: 'no QR code available yet' });
|
||||
}
|
||||
res.json({ qr: currentQR });
|
||||
});
|
||||
|
||||
app.post('/send', async (req, res) => {
|
||||
const { to, text } = req.body || {};
|
||||
|
||||
if (!to || !text) {
|
||||
return res.status(400).json({ ok: false, error: 'missing "to" or "text" in body' });
|
||||
}
|
||||
if (!connected || !sock) {
|
||||
return res.status(503).json({ ok: false, error: 'not connected to WhatsApp' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sock.sendMessage(to, { text });
|
||||
res.json({ ok: true, id: result.key.id });
|
||||
} catch (err) {
|
||||
console.error('[whatsapp] Send failed:', err.message);
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/messages', (_req, res) => {
|
||||
const messages = messageQueue.splice(0);
|
||||
res.json({ messages });
|
||||
});
|
||||
|
||||
// --- Startup ---
|
||||
|
||||
const server = app.listen(PORT, HOST, () => {
|
||||
console.log(`[whatsapp] Bridge API listening on http://${HOST}:${PORT}`);
|
||||
startConnection().catch((err) => {
|
||||
console.error('[whatsapp] Failed to start connection:', err.message);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Graceful shutdown ---
|
||||
|
||||
function shutdown(signal) {
|
||||
console.log(`[whatsapp] Received ${signal}, shutting down...`);
|
||||
shuttingDown = true;
|
||||
if (sock) {
|
||||
sock.end(undefined);
|
||||
}
|
||||
server.close(() => {
|
||||
console.log('[whatsapp] HTTP server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
// Force exit after 5s
|
||||
setTimeout(() => process.exit(1), 5000);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
2519
bridge/whatsapp/package-lock.json
generated
Normal file
2519
bridge/whatsapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
bridge/whatsapp/package.json
Normal file
15
bridge/whatsapp/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "echo-whatsapp-bridge",
|
||||
"version": "1.0.0",
|
||||
"description": "WhatsApp bridge for Echo Core using Baileys",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@whiskeysockets/baileys": "^6.7.16",
|
||||
"express": "^4.21.0",
|
||||
"pino": "^9.6.0",
|
||||
"qrcode": "^1.5.4"
|
||||
}
|
||||
}
|
||||
90
cli.py
90
cli.py
@@ -522,6 +522,86 @@ def cmd_heartbeat(args):
|
||||
print(run_heartbeat())
|
||||
|
||||
|
||||
def cmd_whatsapp(args):
|
||||
"""Handle whatsapp subcommand."""
|
||||
if args.whatsapp_action == "status":
|
||||
_whatsapp_status()
|
||||
elif args.whatsapp_action == "qr":
|
||||
_whatsapp_qr()
|
||||
|
||||
|
||||
def _whatsapp_status():
|
||||
"""Check WhatsApp bridge connection status."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
cfg_file = CONFIG_FILE
|
||||
bridge_url = "http://127.0.0.1:8098"
|
||||
try:
|
||||
text = cfg_file.read_text(encoding="utf-8")
|
||||
cfg = json.loads(text)
|
||||
bridge_url = cfg.get("whatsapp", {}).get("bridge_url", bridge_url)
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
pass
|
||||
|
||||
try:
|
||||
req = urllib.request.urlopen(f"{bridge_url}/status", timeout=5)
|
||||
data = json.loads(req.read().decode())
|
||||
except (urllib.error.URLError, OSError) as e:
|
||||
print(f"Bridge not reachable at {bridge_url}")
|
||||
print(f" Error: {e}")
|
||||
return
|
||||
|
||||
connected = data.get("connected", False)
|
||||
phone = data.get("phone", "unknown")
|
||||
has_qr = data.get("qr", False)
|
||||
|
||||
if connected:
|
||||
print(f"Status: CONNECTED")
|
||||
print(f"Phone: {phone}")
|
||||
elif has_qr:
|
||||
print(f"Status: WAITING FOR QR SCAN")
|
||||
print(f"Run 'echo whatsapp qr' for QR code instructions.")
|
||||
else:
|
||||
print(f"Status: DISCONNECTED")
|
||||
print(f"Start the bridge and scan the QR code to connect.")
|
||||
|
||||
|
||||
def _whatsapp_qr():
|
||||
"""Show QR code instructions from the bridge."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
cfg_file = CONFIG_FILE
|
||||
bridge_url = "http://127.0.0.1:8098"
|
||||
try:
|
||||
text = cfg_file.read_text(encoding="utf-8")
|
||||
cfg = json.loads(text)
|
||||
bridge_url = cfg.get("whatsapp", {}).get("bridge_url", bridge_url)
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
pass
|
||||
|
||||
try:
|
||||
req = urllib.request.urlopen(f"{bridge_url}/qr", timeout=5)
|
||||
data = json.loads(req.read().decode())
|
||||
except (urllib.error.URLError, OSError) as e:
|
||||
print(f"Bridge not reachable at {bridge_url}")
|
||||
print(f" Error: {e}")
|
||||
return
|
||||
|
||||
qr = data.get("qr")
|
||||
if not qr:
|
||||
if data.get("connected"):
|
||||
print("Already connected — no QR code needed.")
|
||||
else:
|
||||
print("No QR code available yet. Wait for the bridge to initialize.")
|
||||
return
|
||||
|
||||
print("QR code is available at the bridge.")
|
||||
print(f"Open {bridge_url}/qr in a browser to scan,")
|
||||
print("or check the bridge terminal output for the QR code.")
|
||||
|
||||
|
||||
def cmd_secrets(args):
|
||||
"""Handle secrets subcommand."""
|
||||
if args.secrets_action == "set":
|
||||
@@ -664,6 +744,13 @@ def main():
|
||||
cron_disable_p = cron_sub.add_parser("disable", help="Disable a job")
|
||||
cron_disable_p.add_argument("name", help="Job name")
|
||||
|
||||
# whatsapp
|
||||
whatsapp_parser = sub.add_parser("whatsapp", help="WhatsApp bridge commands")
|
||||
whatsapp_sub = whatsapp_parser.add_subparsers(dest="whatsapp_action")
|
||||
|
||||
whatsapp_sub.add_parser("status", help="Check bridge connection status")
|
||||
whatsapp_sub.add_parser("qr", help="Show QR code instructions")
|
||||
|
||||
# Parse and dispatch
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -693,6 +780,9 @@ def main():
|
||||
"secrets": lambda a: (
|
||||
cmd_secrets(a) if a.secrets_action else (secrets_parser.print_help() or sys.exit(0))
|
||||
),
|
||||
"whatsapp": lambda a: (
|
||||
cmd_whatsapp(a) if a.whatsapp_action else (whatsapp_parser.print_help() or sys.exit(0))
|
||||
),
|
||||
}
|
||||
|
||||
handler = dispatch.get(args.command)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Echo",
|
||||
"default_model": "opus",
|
||||
"owner": "949388626146517022",
|
||||
"admins": []
|
||||
"admins": ["5040014994"]
|
||||
},
|
||||
"channels": {
|
||||
"echo-core": {
|
||||
@@ -12,6 +12,13 @@
|
||||
}
|
||||
},
|
||||
"telegram_channels": {},
|
||||
"whatsapp": {
|
||||
"enabled": false,
|
||||
"bridge_url": "http://127.0.0.1:8098",
|
||||
"owner": null,
|
||||
"admins": []
|
||||
},
|
||||
"whatsapp_channels": {},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval_minutes": 30
|
||||
|
||||
242
src/adapters/whatsapp.py
Normal file
242
src/adapters/whatsapp.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""WhatsApp adapter for Echo Core — connects to Node.js bridge."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from src.config import Config
|
||||
from src.router import route_message
|
||||
from src.claude_session import clear_session, get_active_session
|
||||
|
||||
log = logging.getLogger("echo-core.whatsapp")
|
||||
_security_log = logging.getLogger("echo-core.security")
|
||||
|
||||
# Module-level config reference, set by run_whatsapp()
|
||||
_config: Config | None = None
|
||||
_bridge_url: str = "http://127.0.0.1:8098"
|
||||
_running: bool = False
|
||||
|
||||
VALID_MODELS = {"opus", "sonnet", "haiku"}
|
||||
|
||||
|
||||
def _get_config() -> Config:
|
||||
"""Return the module-level config, raising if not initialized."""
|
||||
if _config is None:
|
||||
raise RuntimeError("WhatsApp adapter not initialized — call run_whatsapp() first")
|
||||
return _config
|
||||
|
||||
|
||||
# --- Authorization helpers ---
|
||||
|
||||
|
||||
def is_owner(phone: str) -> bool:
|
||||
"""Check if phone number matches config whatsapp.owner."""
|
||||
owner = _get_config().get("whatsapp.owner")
|
||||
return phone == str(owner) if owner else False
|
||||
|
||||
|
||||
def is_admin(phone: str) -> bool:
|
||||
"""Check if phone number is owner or in whatsapp admins list."""
|
||||
if is_owner(phone):
|
||||
return True
|
||||
admins = _get_config().get("whatsapp.admins", [])
|
||||
return phone in admins
|
||||
|
||||
|
||||
def is_registered_chat(chat_id: str) -> bool:
|
||||
"""Check if a WhatsApp chat is in any registered channel entry."""
|
||||
channels = _get_config().get("whatsapp_channels", {})
|
||||
return any(ch.get("id") == chat_id for ch in channels.values())
|
||||
|
||||
|
||||
# --- Message splitting helper ---
|
||||
|
||||
|
||||
def split_message(text: str, limit: int = 4096) -> list[str]:
|
||||
"""Split text into chunks that fit WhatsApp's message limit."""
|
||||
if len(text) <= limit:
|
||||
return [text]
|
||||
|
||||
chunks = []
|
||||
while text:
|
||||
if len(text) <= limit:
|
||||
chunks.append(text)
|
||||
break
|
||||
split_at = text.rfind("\n", 0, limit)
|
||||
if split_at == -1:
|
||||
split_at = limit
|
||||
chunks.append(text[:split_at])
|
||||
text = text[split_at:].lstrip("\n")
|
||||
return chunks
|
||||
|
||||
|
||||
# --- Bridge communication ---
|
||||
|
||||
|
||||
async def poll_messages(client: httpx.AsyncClient) -> list[dict]:
|
||||
"""Poll bridge for new messages."""
|
||||
try:
|
||||
resp = await client.get(f"{_bridge_url}/messages", timeout=10)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
return data.get("messages", [])
|
||||
except Exception as e:
|
||||
log.debug("Bridge poll error: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
async def send_whatsapp(client: httpx.AsyncClient, to: str, text: str) -> bool:
|
||||
"""Send a message via the bridge."""
|
||||
try:
|
||||
for chunk in split_message(text):
|
||||
resp = await client.post(
|
||||
f"{_bridge_url}/send",
|
||||
json={"to": to, "text": chunk},
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code != 200 or not resp.json().get("ok"):
|
||||
log.error("Failed to send to %s: %s", to, resp.text)
|
||||
return False
|
||||
return True
|
||||
except Exception as e:
|
||||
log.error("Send error: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
async def get_bridge_status(client: httpx.AsyncClient) -> dict | None:
|
||||
"""Get bridge connection status."""
|
||||
try:
|
||||
resp = await client.get(f"{_bridge_url}/status", timeout=5)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# --- Message handler ---
|
||||
|
||||
|
||||
async def handle_incoming(msg: dict, client: httpx.AsyncClient) -> None:
|
||||
"""Process a single incoming WhatsApp message."""
|
||||
sender = msg.get("from", "")
|
||||
text = msg.get("text", "").strip()
|
||||
push_name = msg.get("pushName", "unknown")
|
||||
is_group = msg.get("isGroup", False)
|
||||
|
||||
if not text:
|
||||
return
|
||||
|
||||
# Group chat: only registered chats
|
||||
if is_group:
|
||||
chat_id = sender # group JID
|
||||
if not is_registered_chat(chat_id):
|
||||
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}"
|
||||
|
||||
# Handle slash commands locally for immediate response
|
||||
if text.startswith("/"):
|
||||
cmd = text.split()[0].lower()
|
||||
if cmd == "/clear":
|
||||
cleared = clear_session(channel_id)
|
||||
reply = "Session cleared." if cleared else "No active session."
|
||||
await send_whatsapp(client, sender, reply)
|
||||
return
|
||||
if cmd == "/status":
|
||||
session = get_active_session(channel_id)
|
||||
if session:
|
||||
model = session.get("model", "?")
|
||||
sid = session.get("session_id", "?")[:8]
|
||||
count = session.get("message_count", 0)
|
||||
in_tok = session.get("total_input_tokens", 0)
|
||||
out_tok = session.get("total_output_tokens", 0)
|
||||
reply = (
|
||||
f"Model: {model}\n"
|
||||
f"Session: {sid}\n"
|
||||
f"Messages: {count}\n"
|
||||
f"Tokens: {in_tok} in / {out_tok} out"
|
||||
)
|
||||
else:
|
||||
reply = "No active session."
|
||||
await send_whatsapp(client, sender, reply)
|
||||
return
|
||||
|
||||
# Route to Claude via router (handles /model and regular messages)
|
||||
log.info("Message from %s (%s): %.50s", phone, push_name, text)
|
||||
try:
|
||||
response, _is_cmd = await asyncio.to_thread(
|
||||
route_message, channel_id, phone, text
|
||||
)
|
||||
await send_whatsapp(client, sender, response)
|
||||
except Exception as e:
|
||||
log.error("Error handling message from %s: %s", phone, e)
|
||||
await send_whatsapp(client, sender, "Sorry, an error occurred.")
|
||||
|
||||
|
||||
# --- Main loop ---
|
||||
|
||||
|
||||
async def run_whatsapp(config: Config, bridge_url: str = "http://127.0.0.1:8098"):
|
||||
"""Main WhatsApp polling loop."""
|
||||
global _config, _bridge_url, _running
|
||||
_config = config
|
||||
_bridge_url = bridge_url
|
||||
_running = True
|
||||
|
||||
log.info("WhatsApp adapter starting (bridge: %s)", bridge_url)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Wait for bridge to be ready
|
||||
retries = 0
|
||||
while _running and retries < 30:
|
||||
status = await get_bridge_status(client)
|
||||
if status:
|
||||
if status.get("connected"):
|
||||
log.info("WhatsApp bridge connected (phone: %s)", status.get("phone"))
|
||||
break
|
||||
else:
|
||||
qr = "QR available" if status.get("qr") else "waiting"
|
||||
log.info("WhatsApp bridge not connected yet (%s)", qr)
|
||||
else:
|
||||
log.info("WhatsApp bridge not reachable, retrying...")
|
||||
retries += 1
|
||||
await asyncio.sleep(5)
|
||||
|
||||
if not _running:
|
||||
return
|
||||
|
||||
log.info("WhatsApp adapter polling started")
|
||||
|
||||
# Polling loop
|
||||
while _running:
|
||||
try:
|
||||
messages = await poll_messages(client)
|
||||
for msg in messages:
|
||||
await handle_incoming(msg, client)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
log.error("Polling error: %s", e)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
log.info("WhatsApp adapter stopped")
|
||||
|
||||
|
||||
def stop_whatsapp():
|
||||
"""Signal the polling loop to stop."""
|
||||
global _running
|
||||
_running = False
|
||||
18
src/main.py
18
src/main.py
@@ -118,6 +118,14 @@ def main():
|
||||
else:
|
||||
logger.info("No telegram_token — Telegram bot disabled")
|
||||
|
||||
# WhatsApp adapter (optional — only if whatsapp is enabled in config)
|
||||
whatsapp_enabled = config.get("whatsapp", {}).get("enabled", False)
|
||||
whatsapp_bridge_url = config.get("whatsapp", {}).get("bridge_url", "http://127.0.0.1:8098")
|
||||
if whatsapp_enabled:
|
||||
logger.info("WhatsApp adapter configured (bridge: %s)", whatsapp_bridge_url)
|
||||
else:
|
||||
logger.info("WhatsApp adapter disabled")
|
||||
|
||||
# PID file
|
||||
PID_FILE.write_text(str(os.getpid()))
|
||||
|
||||
@@ -133,7 +141,7 @@ def main():
|
||||
signal.signal(signal.SIGINT, handle_signal)
|
||||
|
||||
async def _run_all():
|
||||
"""Run Discord + Telegram bots concurrently."""
|
||||
"""Run Discord + Telegram + WhatsApp bots concurrently."""
|
||||
tasks = [asyncio.create_task(client.start(token))]
|
||||
if telegram_app:
|
||||
async def _run_telegram():
|
||||
@@ -149,6 +157,14 @@ def main():
|
||||
await telegram_app.stop()
|
||||
await telegram_app.shutdown()
|
||||
tasks.append(asyncio.create_task(_run_telegram()))
|
||||
if whatsapp_enabled:
|
||||
from src.adapters.whatsapp import run_whatsapp, stop_whatsapp
|
||||
async def _run_whatsapp():
|
||||
try:
|
||||
await run_whatsapp(config, whatsapp_bridge_url)
|
||||
except asyncio.CancelledError:
|
||||
stop_whatsapp()
|
||||
tasks.append(asyncio.create_task(_run_whatsapp()))
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
try:
|
||||
|
||||
4
start.sh
Executable file
4
start.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")"
|
||||
source .venv/bin/activate
|
||||
exec python3 src/main.py "$@"
|
||||
429
tests/test_whatsapp.py
Normal file
429
tests/test_whatsapp.py
Normal file
@@ -0,0 +1,429 @@
|
||||
"""Tests for src/adapters/whatsapp.py — WhatsApp adapter."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
|
||||
from src.config import Config
|
||||
from src.adapters import whatsapp
|
||||
from src.adapters.whatsapp import (
|
||||
is_owner,
|
||||
is_admin,
|
||||
is_registered_chat,
|
||||
split_message,
|
||||
poll_messages,
|
||||
send_whatsapp,
|
||||
get_bridge_status,
|
||||
handle_incoming,
|
||||
run_whatsapp,
|
||||
stop_whatsapp,
|
||||
)
|
||||
|
||||
|
||||
# --- Fixtures ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_config(tmp_path):
|
||||
"""Create a Config backed by a temp file with default data."""
|
||||
data = {
|
||||
"bot": {
|
||||
"name": "Echo",
|
||||
"default_model": "sonnet",
|
||||
},
|
||||
"whatsapp": {
|
||||
"owner": None,
|
||||
"admins": [],
|
||||
},
|
||||
"whatsapp_channels": {},
|
||||
}
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps(data, indent=2))
|
||||
return Config(config_file)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def owned_config(tmp_path):
|
||||
"""Config with owner and whatsapp channels set."""
|
||||
data = {
|
||||
"bot": {
|
||||
"name": "Echo",
|
||||
"default_model": "sonnet",
|
||||
},
|
||||
"whatsapp": {
|
||||
"owner": "5511999990000",
|
||||
"admins": ["5511888880000"],
|
||||
},
|
||||
"whatsapp_channels": {
|
||||
"general": {"id": "group123@g.us"},
|
||||
},
|
||||
}
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps(data, indent=2))
|
||||
return Config(config_file)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _set_config(tmp_config):
|
||||
"""Ensure module config is set for each test."""
|
||||
whatsapp._config = tmp_config
|
||||
yield
|
||||
whatsapp._config = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _set_owned(owned_config):
|
||||
"""Set owned config for specific tests."""
|
||||
whatsapp._config = owned_config
|
||||
yield
|
||||
whatsapp._config = None
|
||||
|
||||
|
||||
def _mock_httpx_response(status_code=200, json_data=None, text=""):
|
||||
"""Create a mock httpx.Response."""
|
||||
resp = MagicMock(spec=httpx.Response)
|
||||
resp.status_code = status_code
|
||||
resp.text = text
|
||||
if json_data is not None:
|
||||
resp.json.return_value = json_data
|
||||
return resp
|
||||
|
||||
|
||||
def _mock_client():
|
||||
"""Create a mock httpx.AsyncClient."""
|
||||
client = AsyncMock(spec=httpx.AsyncClient)
|
||||
return client
|
||||
|
||||
|
||||
# --- Authorization helpers ---
|
||||
|
||||
|
||||
class TestIsOwner:
|
||||
def test_is_owner_true(self, _set_owned):
|
||||
assert is_owner("5511999990000") is True
|
||||
|
||||
def test_is_owner_false(self, _set_owned):
|
||||
assert is_owner("9999999999") is False
|
||||
|
||||
def test_is_owner_none_owner(self):
|
||||
assert is_owner("5511999990000") is False
|
||||
|
||||
|
||||
class TestIsAdmin:
|
||||
def test_is_admin_owner_is_admin(self, _set_owned):
|
||||
assert is_admin("5511999990000") is True
|
||||
|
||||
def test_is_admin_listed(self, _set_owned):
|
||||
assert is_admin("5511888880000") is True
|
||||
|
||||
def test_is_admin_not_listed(self, _set_owned):
|
||||
assert is_admin("9999999999") is False
|
||||
|
||||
|
||||
class TestIsRegisteredChat:
|
||||
def test_is_registered_true(self, _set_owned):
|
||||
assert is_registered_chat("group123@g.us") is True
|
||||
|
||||
def test_is_registered_false(self, _set_owned):
|
||||
assert is_registered_chat("unknown@g.us") is False
|
||||
|
||||
def test_is_registered_empty(self):
|
||||
assert is_registered_chat("group123@g.us") is False
|
||||
|
||||
|
||||
# --- split_message ---
|
||||
|
||||
|
||||
class TestSplitMessage:
|
||||
def test_short_message_not_split(self):
|
||||
assert split_message("hello") == ["hello"]
|
||||
|
||||
def test_long_message_split(self):
|
||||
text = "a" * 8192
|
||||
chunks = split_message(text, limit=4096)
|
||||
assert len(chunks) == 2
|
||||
assert all(len(c) <= 4096 for c in chunks)
|
||||
assert "".join(chunks) == text
|
||||
|
||||
def test_split_at_newline(self):
|
||||
text = "line1\n" * 1000
|
||||
chunks = split_message(text, limit=100)
|
||||
assert all(len(c) <= 100 for c in chunks)
|
||||
|
||||
def test_empty_string(self):
|
||||
assert split_message("") == [""]
|
||||
|
||||
|
||||
# --- Bridge communication ---
|
||||
|
||||
|
||||
class TestPollMessages:
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_poll(self):
|
||||
client = _mock_client()
|
||||
messages = [{"from": "123@s.whatsapp.net", "text": "hi"}]
|
||||
client.get.return_value = _mock_httpx_response(
|
||||
json_data={"messages": messages}
|
||||
)
|
||||
result = await poll_messages(client)
|
||||
assert result == messages
|
||||
client.get.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_error_returns_empty(self):
|
||||
client = _mock_client()
|
||||
client.get.side_effect = httpx.ConnectError("bridge down")
|
||||
result = await poll_messages(client)
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestSendWhatsapp:
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_send(self):
|
||||
client = _mock_client()
|
||||
client.post.return_value = _mock_httpx_response(
|
||||
json_data={"ok": True}
|
||||
)
|
||||
result = await send_whatsapp(client, "123@s.whatsapp.net", "hello")
|
||||
assert result is True
|
||||
client.post.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_send(self):
|
||||
client = _mock_client()
|
||||
client.post.return_value = _mock_httpx_response(
|
||||
status_code=500, json_data={"ok": False}, text="Server Error"
|
||||
)
|
||||
result = await send_whatsapp(client, "123@s.whatsapp.net", "hello")
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_message_split_send(self):
|
||||
client = _mock_client()
|
||||
client.post.return_value = _mock_httpx_response(
|
||||
json_data={"ok": True}
|
||||
)
|
||||
long_text = "a" * 8192
|
||||
result = await send_whatsapp(client, "123@s.whatsapp.net", long_text)
|
||||
assert result is True
|
||||
assert client.post.call_count == 2
|
||||
|
||||
|
||||
class TestGetBridgeStatus:
|
||||
@pytest.mark.asyncio
|
||||
async def test_connected(self):
|
||||
client = _mock_client()
|
||||
client.get.return_value = _mock_httpx_response(
|
||||
json_data={"connected": True, "phone": "5511999990000"}
|
||||
)
|
||||
result = await get_bridge_status(client)
|
||||
assert result == {"connected": True, "phone": "5511999990000"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unreachable(self):
|
||||
client = _mock_client()
|
||||
client.get.side_effect = httpx.ConnectError("unreachable")
|
||||
result = await get_bridge_status(client)
|
||||
assert result is None
|
||||
|
||||
|
||||
# --- Message handler ---
|
||||
|
||||
|
||||
class TestHandleIncoming:
|
||||
@pytest.mark.asyncio
|
||||
async def test_private_admin_message(self, _set_owned):
|
||||
client = _mock_client()
|
||||
client.post.return_value = _mock_httpx_response(json_data={"ok": True})
|
||||
msg = {
|
||||
"from": "5511999990000@s.whatsapp.net",
|
||||
"text": "Hello Claude",
|
||||
"pushName": "Owner",
|
||||
"isGroup": False,
|
||||
}
|
||||
with patch("src.adapters.whatsapp.route_message", return_value=("Hi!", False)) as mock_route:
|
||||
await handle_incoming(msg, client)
|
||||
mock_route.assert_called_once()
|
||||
client.post.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_private_unauthorized(self, _set_owned):
|
||||
client = _mock_client()
|
||||
msg = {
|
||||
"from": "9999999999@s.whatsapp.net",
|
||||
"text": "Hello",
|
||||
"pushName": "Stranger",
|
||||
"isGroup": False,
|
||||
}
|
||||
with patch("src.adapters.whatsapp.route_message") as mock_route:
|
||||
await handle_incoming(msg, client)
|
||||
mock_route.assert_not_called()
|
||||
client.post.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_unregistered(self, _set_owned):
|
||||
client = _mock_client()
|
||||
msg = {
|
||||
"from": "unknown@g.us",
|
||||
"text": "Hello",
|
||||
"pushName": "User",
|
||||
"isGroup": True,
|
||||
}
|
||||
with patch("src.adapters.whatsapp.route_message") as mock_route:
|
||||
await handle_incoming(msg, client)
|
||||
mock_route.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_registered_skip(self, _set_owned):
|
||||
client = _mock_client()
|
||||
msg = {
|
||||
"from": "group123@g.us",
|
||||
"text": "Hello",
|
||||
"pushName": "User",
|
||||
"isGroup": True,
|
||||
}
|
||||
with patch("src.adapters.whatsapp.route_message") as mock_route:
|
||||
await handle_incoming(msg, client)
|
||||
mock_route.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_command(self, _set_owned):
|
||||
client = _mock_client()
|
||||
client.post.return_value = _mock_httpx_response(json_data={"ok": True})
|
||||
msg = {
|
||||
"from": "5511999990000@s.whatsapp.net",
|
||||
"text": "/clear",
|
||||
"pushName": "Owner",
|
||||
"isGroup": False,
|
||||
}
|
||||
with patch("src.adapters.whatsapp.clear_session", return_value=True) as mock_clear:
|
||||
await handle_incoming(msg, client)
|
||||
mock_clear.assert_called_once_with("wa-5511999990000")
|
||||
client.post.assert_called_once()
|
||||
sent_json = client.post.call_args[1]["json"]
|
||||
assert "cleared" in sent_json["text"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_command_with_session(self, _set_owned):
|
||||
client = _mock_client()
|
||||
client.post.return_value = _mock_httpx_response(json_data={"ok": True})
|
||||
msg = {
|
||||
"from": "5511999990000@s.whatsapp.net",
|
||||
"text": "/status",
|
||||
"pushName": "Owner",
|
||||
"isGroup": False,
|
||||
}
|
||||
session = {
|
||||
"model": "opus",
|
||||
"session_id": "sess-abc-12345678",
|
||||
"message_count": 5,
|
||||
"total_input_tokens": 1000,
|
||||
"total_output_tokens": 500,
|
||||
}
|
||||
with patch("src.adapters.whatsapp.get_active_session", return_value=session):
|
||||
await handle_incoming(msg, client)
|
||||
client.post.assert_called_once()
|
||||
sent_json = client.post.call_args[1]["json"]
|
||||
assert "opus" in sent_json["text"]
|
||||
assert "5" in sent_json["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_command_no_session(self, _set_owned):
|
||||
client = _mock_client()
|
||||
client.post.return_value = _mock_httpx_response(json_data={"ok": True})
|
||||
msg = {
|
||||
"from": "5511999990000@s.whatsapp.net",
|
||||
"text": "/status",
|
||||
"pushName": "Owner",
|
||||
"isGroup": False,
|
||||
}
|
||||
with patch("src.adapters.whatsapp.get_active_session", return_value=None):
|
||||
await handle_incoming(msg, client)
|
||||
client.post.assert_called_once()
|
||||
sent_json = client.post.call_args[1]["json"]
|
||||
assert "No active session" in sent_json["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_handling(self, _set_owned):
|
||||
client = _mock_client()
|
||||
client.post.return_value = _mock_httpx_response(json_data={"ok": True})
|
||||
msg = {
|
||||
"from": "5511999990000@s.whatsapp.net",
|
||||
"text": "Hello",
|
||||
"pushName": "Owner",
|
||||
"isGroup": False,
|
||||
}
|
||||
with patch("src.adapters.whatsapp.route_message", side_effect=Exception("boom")):
|
||||
await handle_incoming(msg, client)
|
||||
client.post.assert_called_once()
|
||||
sent_json = client.post.call_args[1]["json"]
|
||||
assert "Sorry" in sent_json["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_text_ignored(self, _set_owned):
|
||||
client = _mock_client()
|
||||
msg = {
|
||||
"from": "5511999990000@s.whatsapp.net",
|
||||
"text": "",
|
||||
"pushName": "Owner",
|
||||
"isGroup": False,
|
||||
}
|
||||
with patch("src.adapters.whatsapp.route_message") as mock_route:
|
||||
await handle_incoming(msg, client)
|
||||
mock_route.assert_not_called()
|
||||
client.post.assert_not_called()
|
||||
|
||||
|
||||
# --- Security logging ---
|
||||
|
||||
|
||||
class TestSecurityLogging:
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthorized_dm_logged(self, _set_owned):
|
||||
client = _mock_client()
|
||||
msg = {
|
||||
"from": "9999999999@s.whatsapp.net",
|
||||
"text": "hack attempt",
|
||||
"pushName": "Stranger",
|
||||
"isGroup": False,
|
||||
}
|
||||
with patch.object(whatsapp._security_log, "warning") as mock_log:
|
||||
await handle_incoming(msg, client)
|
||||
mock_log.assert_called_once()
|
||||
assert "Unauthorized" in mock_log.call_args[0][0]
|
||||
|
||||
|
||||
# --- Lifecycle ---
|
||||
|
||||
|
||||
class TestRunWhatsapp:
|
||||
@pytest.mark.asyncio
|
||||
async def test_basic_start_stop(self, tmp_config):
|
||||
"""Test that run_whatsapp sets state and exits when stopped."""
|
||||
mock_client = _mock_client()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
# get_bridge_status returns None → retries, but we stop after first sleep
|
||||
mock_client.get.return_value = _mock_httpx_response(status_code=500)
|
||||
|
||||
async def stop_on_sleep(*args, **kwargs):
|
||||
whatsapp._running = False
|
||||
|
||||
with (
|
||||
patch("src.adapters.whatsapp.httpx.AsyncClient", return_value=mock_client),
|
||||
patch("src.adapters.whatsapp.asyncio.sleep", side_effect=stop_on_sleep),
|
||||
):
|
||||
await run_whatsapp(tmp_config, bridge_url="http://127.0.0.1:9999")
|
||||
|
||||
assert whatsapp._config is tmp_config
|
||||
assert whatsapp._bridge_url == "http://127.0.0.1:9999"
|
||||
|
||||
|
||||
class TestStopWhatsapp:
|
||||
def test_sets_running_false(self):
|
||||
whatsapp._running = True
|
||||
stop_whatsapp()
|
||||
assert whatsapp._running is False
|
||||
Reference in New Issue
Block a user