from typing import Dict, Any, Optional import httpx import hashlib import hmac import logging from app.config import settings logger = logging.getLogger(__name__) class WhatsAppService: def __init__(self): self.api_token = settings.WHATSAPP_API_TOKEN self.phone_number_id = settings.WHATSAPP_PHONE_NUMBER_ID self.api_base = f"https://graph.facebook.com/v18.0/{self.phone_number_id}" def verify_webhook(self, mode: str, token: str, challenge: str) -> Optional[str]: if mode == "subscribe" and token == settings.WHATSAPP_WEBHOOK_VERIFY_TOKEN: return challenge return None def verify_signature(self, body: bytes, signature: str) -> bool: if not signature: return False expected = hmac.new( settings.WHATSAPP_API_TOKEN.encode(), body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(f"sha256={expected}", signature) async def send_text(self, to: str, text: str) -> bool: if not self.api_token or not self.phone_number_id: logger.warning("WhatsApp not configured") return False async with httpx.AsyncClient() as client: resp = await client.post( f"{self.api_base}/messages", headers={ "Authorization": f"Bearer {self.api_token}", "Content-Type": "application/json", }, json={ "messaging_product": "whatsapp", "to": to, "type": "text", "text": {"body": text}, }, timeout=15, ) if resp.status_code != 200: logger.error(f"WhatsApp send failed: {resp.text}") return False return True async def send_template(self, to: str, template_name: str, params: Dict[str, str]) -> bool: if not self.api_token or not self.phone_number_id: return False components = [ { "type": "body", "parameters": [ {"type": "text", "text": v} for v in params.values() ], } ] async with httpx.AsyncClient() as client: resp = await client.post( f"{self.api_base}/messages", headers={"Authorization": f"Bearer {self.api_token}", "Content-Type": "application/json"}, json={ "messaging_product": "whatsapp", "to": to, "type": "template", "template": { "name": template_name, "language": {"code": "en"}, "components": components, }, }, timeout=15, ) return resp.status_code == 200 async def send_media(self, to: str, media_url: str, media_type: str = "image", caption: Optional[str] = None) -> bool: if not self.api_token or not self.phone_number_id: return False body = { "messaging_product": "whatsapp", "to": to, "type": media_type, media_type: {"link": media_url}, } if caption: body[media_type]["caption"] = caption async with httpx.AsyncClient() as client: resp = await client.post( f"{self.api_base}/messages", headers={"Authorization": f"Bearer {self.api_token}", "Content-Type": "application/json"}, json=body, timeout=30, ) if resp.status_code != 200: logger.error(f"WhatsApp media send failed: {resp.text}") return False return True async def mark_as_read(self, message_id: str) -> bool: if not self.api_token: return False async with httpx.AsyncClient() as client: resp = await client.post( f"{self.api_base}/messages", headers={"Authorization": f"Bearer {self.api_token}", "Content-Type": "application/json"}, json={ "messaging_product": "whatsapp", "status": "read", "message_id": message_id, }, timeout=10, ) return resp.status_code == 200 def parse_webhook(self, body: Dict) -> Optional[Dict]: try: entry = body.get("entry", [{}])[0] change = entry.get("changes", [{}])[0] value = change.get("value", {}) messages = value.get("messages", []) if not messages: return None msg = messages[0] msg_type = msg.get("type", "text") content = "" if msg_type == "text": content = msg.get("text", {}).get("body", "") elif msg_type in ("image", "document", "audio", "video"): media = msg.get(msg_type, {}) content = media.get("caption", "") or media.get("filename", "") or f"[{msg_type}]" return { "from": msg.get("from"), "text": content, "msg_id": msg.get("id"), "timestamp": msg.get("timestamp"), "type": msg_type, "profile_name": value.get("contacts", [{}])[0].get("profile", {}).get("name"), } except Exception as e: logger.warning(f"Failed to parse WhatsApp webhook: {e}") return None def _build_headers(self) -> Dict[str, str]: return { "Authorization": f"Bearer {self.api_token}", "Content-Type": "application/json", }