Spaces:
Sleeping
Sleeping
| """ | |
| 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() | |