""" WhatsApp Business API Integration =================================== Handles inbound webhook messages and outbound replies via the Meta WhatsApp Cloud API (v18+). In the POC demo, all send/receive calls are stubbed and logged. In production, set: WHATSAPP_TOKEN = Bearer token from Meta Business Portal WHATSAPP_PHONE_ID = Phone Number ID WHATSAPP_VERIFY_TOKEN = Webhook verification token """ import os import json import logging import hashlib from datetime import datetime from typing import Optional logger = logging.getLogger(__name__) WHATSAPP_API_URL = "https://graph.facebook.com/v18.0/{phone_id}/messages" class WhatsAppClient: def __init__(self): self.token = os.getenv("WHATSAPP_TOKEN", "DEMO_TOKEN") self.phone_id = os.getenv("WHATSAPP_PHONE_ID", "DEMO_PHONE_ID") self.verify = os.getenv("WHATSAPP_VERIFY_TOKEN", "plotweaver_verify") self._demo = self.token == "DEMO_TOKEN" # ── Outbound ───────────────────────────────────────────────────────────── def send_text(self, to: str, body: str) -> dict: """Send a plain text message.""" payload = { "messaging_product": "whatsapp", "recipient_type": "individual", "to": to, "type": "text", "text": {"preview_url": False, "body": body}, } return self._post(payload) def send_audio(self, to: str, audio_url: str) -> dict: """Send a voice note (ogg/opus recommended by Meta).""" payload = { "messaging_product": "whatsapp", "recipient_type": "individual", "to": to, "type": "audio", "audio": {"link": audio_url}, } return self._post(payload) def send_interactive_buttons(self, to: str, body: str, buttons: list[dict]) -> dict: """ Quick-reply buttons for disambiguation. buttons = [{"id": "yes", "title": "Yes ✓"}, {"id": "no", "title": "No ✗"}] """ payload = { "messaging_product": "whatsapp", "to": to, "type": "interactive", "interactive": { "type": "button", "body": {"text": body}, "action": { "buttons": [ {"type": "reply", "reply": btn} for btn in buttons ] }, }, } return self._post(payload) # ── Inbound webhook ────────────────────────────────────────────────────── def verify_webhook(self, mode: str, token: str, challenge: str) -> Optional[str]: """GET verification handshake from Meta.""" if mode == "subscribe" and token == self.verify: logger.info("WhatsApp webhook verified.") return challenge return None def parse_inbound(self, raw_body: dict) -> Optional[dict]: """ Extract relevant fields from inbound webhook payload. Returns normalized event dict or None if not a user message. """ try: entry = raw_body["entry"][0] changes = entry["changes"][0]["value"] message = changes["messages"][0] contact = changes["contacts"][0] event = { "from": message["from"], "name": contact["profile"]["name"], "timestamp": datetime.fromtimestamp(int(message["timestamp"])).isoformat(), "msg_id": message["id"], "type": message["type"], } if message["type"] == "text": event["text"] = message["text"]["body"] elif message["type"] == "audio": event["audio_id"] = message["audio"]["id"] event["mime"] = message["audio"].get("mime_type", "audio/ogg") elif message["type"] == "interactive": event["button_id"] = message["interactive"]["button_reply"]["id"] return event except (KeyError, IndexError) as e: logger.warning(f"Could not parse WhatsApp payload: {e}") return None def download_media(self, media_id: str) -> Optional[bytes]: """Fetch audio bytes for voice messages.""" if self._demo: logger.info(f"[DEMO] Would download media: {media_id}") return b"" # Return empty bytes in demo mode # Production: GET https://graph.facebook.com/v18.0/{media_id} # then fetch the returned URL with Bearer auth raise NotImplementedError("Set WHATSAPP_TOKEN in production.") # ── Internals ───────────────────────────────────────────────────────────── def _post(self, payload: dict) -> dict: if self._demo: logger.info(f"[DEMO] WhatsApp → {payload['to']}: " f"{json.dumps(payload)[:120]} …") return {"status": "demo_ok", "message_id": "demo_" + hashlib.md5( json.dumps(payload).encode()).hexdigest()[:8]} import requests url = WHATSAPP_API_URL.format(phone_id=self.phone_id) headers = {"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"} r = requests.post(url, headers=headers, json=payload, timeout=10) r.raise_for_status() return r.json()