""" whatsapp_client.py — Iris Support WhatsApp Bot (Twilio Edition) Thin wrapper around the Twilio REST + TwiML APIs, mirroring the pattern proven in PriceLystAI. Customers talk to Iris support entirely through WhatsApp; this module handles inbound parsing, media download (with Twilio auth), and outbound sending (text with auto-chunking, plus image/video/document media by public URL). Inbound: Twilio POSTs form fields to /webhook (From, Body, MediaUrl0, ...). Outbound: client.messages.create(...) via the Twilio Python SDK. All Twilio credentials come from env vars: TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_WHATSAPP_FROM """ import os import logging import requests from typing import Optional, Dict, Any from twilio.rest import Client from twilio.twiml.messaging_response import MessagingResponse from twilio.request_validator import RequestValidator logger = logging.getLogger("iris-whatsapp") # ── Credentials ────────────────────────────────────────────────────────────── TWILIO_ACCOUNT_SID = os.environ["TWILIO_ACCOUNT_SID"] TWILIO_AUTH_TOKEN = os.environ["TWILIO_AUTH_TOKEN"] _client = Client(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN) _validator = RequestValidator(TWILIO_AUTH_TOKEN) TWILIO_MAX_CHARS = 1550 # Hard Twilio WA limit is 1600; keep a 50-char buffer. def _to_wa(number: str) -> str: """Ensure a number has the whatsapp: prefix.""" if not number: return number return number if number.startswith("whatsapp:") else f"whatsapp:{number}" TWILIO_WHATSAPP_FROM = _to_wa(os.environ["TWILIO_WHATSAPP_FROM"]) # ── Outbound ───────────────────────────────────────────────────────────────── def _split_message(text: str, limit: int = TWILIO_MAX_CHARS) -> list: """Split long text into <=limit chunks, preferring paragraph/word boundaries.""" if len(text) <= limit: return [text] chunks, remaining = [], text while len(remaining) > limit: chunk = remaining[:limit] split_at = chunk.rfind("\n\n") if split_at < limit // 2: split_at = chunk.rfind("\n") if split_at < limit // 3: split_at = chunk.rfind(" ") if split_at <= 0: split_at = limit chunks.append(remaining[:split_at].rstrip()) remaining = remaining[split_at:].lstrip() if remaining: chunks.append(remaining) return chunks def send_text_message(recipient_id: str, text: str) -> bool: """Send a plain text WhatsApp message, auto-splitting past the Twilio char limit.""" if not recipient_id: return False success = True for i, chunk in enumerate(_split_message(text)): try: msg = _client.messages.create( from_=TWILIO_WHATSAPP_FROM, to=_to_wa(recipient_id), body=chunk, ) logger.info("Text sent to %s | part %d | SID=%s", recipient_id, i + 1, msg.sid) except Exception as e: logger.error("send_text_message failed for %s: %s", recipient_id, e) success = False return success def send_media(recipient_id: str, media_url: str, caption: str = "") -> bool: """Send any media (image/video/audio/pdf) by public URL, with optional caption.""" if not (recipient_id and media_url): return False try: kwargs = {"from_": TWILIO_WHATSAPP_FROM, "to": _to_wa(recipient_id), "media_url": [media_url]} if caption: kwargs["body"] = caption msg = _client.messages.create(**kwargs) logger.info("Media sent to %s | SID=%s | url=%s", recipient_id, msg.sid, media_url) return True except Exception as e: logger.error("send_media failed for %s: %s", recipient_id, e) return False def send_image_message(recipient_id: str, image_url: str, caption: str = "") -> bool: return send_media(recipient_id, image_url, caption) def send_video_message(recipient_id: str, video_url: str, caption: str = "") -> bool: return send_media(recipient_id, video_url, caption) # ── Quick-reply buttons ────────────────────────────────────────────────────── # Real WhatsApp buttons require a pre-approved Twilio Content template. When one is # configured we use it; otherwise we fall back to clean numbered/emoji text so the # bot keeps working in the sandbox. TWILIO_USE_CONTENT_BUTTONS = os.environ.get("TWILIO_USE_CONTENT_BUTTONS", "false").lower() == "true" TWILIO_BUTTON_CONTENT_SID = os.environ.get("TWILIO_BUTTON_CONTENT_SID", "") def _send_content_buttons(recipient_id: str, body: str, options: list) -> bool: """ Send real WhatsApp quick-reply buttons via a Twilio Content template. The template carries STATIC button titles (e.g. "👍 Helpful" / "👎 Not helpful") and a single body variable {{1}}, so we only substitute the body here. The `options` arg is unused for the template path — it drives the text fallback. """ if not (TWILIO_USE_CONTENT_BUTTONS and TWILIO_BUTTON_CONTENT_SID): return False import json as _json variables = {"1": body} try: msg = _client.messages.create( from_=TWILIO_WHATSAPP_FROM, to=_to_wa(recipient_id), content_sid=TWILIO_BUTTON_CONTENT_SID, content_variables=_json.dumps(variables), ) logger.info("Content buttons sent to %s | SID=%s", recipient_id, msg.sid) return True except Exception as e: logger.error("Content button send failed for %s; falling back to text: %s", recipient_id, e) return False def send_buttons_as_text(recipient_id: str, body: str, options: list) -> bool: """ Offer up to 3 choices. Uses Twilio Content buttons when configured, otherwise sends the options as readable text the user can reply to. """ opts = [str(o) for o in (options or []) if str(o).strip()][:3] if _send_content_buttons(recipient_id, body, opts): return True lines = [body, ""] + opts + ["", "_Reply with one of the options above._"] return send_text_message(recipient_id, "\n".join(lines)) # ── TwiML ──────────────────────────────────────────────────────────────────── def twiml_empty() -> str: """Empty TwiML — we send messages asynchronously via the REST client.""" return str(MessagingResponse()) # ── Inbound ────────────────────────────────────────────────────────────────── def get_message_details(form: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Parse Twilio inbound webhook form fields into a normalised dict.""" try: msg_sid = form.get("MessageSid", "") from_raw = form.get("From", "") # When a customer taps a quick-reply button, Twilio sends the title in # ButtonText (Body may be empty) — fall back to it so feedback is captured. body = (form.get("Body", "") or form.get("ButtonText", "") or "").strip() num_media = int(form.get("NumMedia", 0) or 0) if not from_raw or not msg_sid: logger.warning("Incomplete Twilio webhook: %s", dict(form)) return None mobile = from_raw.replace("whatsapp:", "").strip() details: Dict[str, Any] = {"id": msg_sid, "from": mobile, "type": "text", "text": body} if num_media > 0: media_url = form.get("MediaUrl0", "") media_type = form.get("MediaContentType0", "") if "image" in media_type: details.update({"type": "image", "image_url": media_url, "caption": body, "text": None}) elif "audio" in media_type or "ogg" in media_type: details.update({"type": "audio", "audio_url": media_url, "text": None}) elif "pdf" in media_type or "document" in media_type: details.update({"type": "document", "document_url": media_url, "caption": body, "text": None}) elif "video" in media_type: # Customer-sent video — treat its caption as the text query. details.update({"type": "text", "text": body or "I sent a video"}) else: details.update({"type": "text", "text": body or media_url}) return details except Exception as e: logger.error("get_message_details error: %s | form=%s", e, dict(form)) return None def download_media(media_url: str, save_path: str) -> Optional[str]: """Download Twilio-hosted media (requires HTTP Basic auth with the Twilio creds).""" try: os.makedirs(os.path.dirname(save_path), exist_ok=True) resp = requests.get( media_url, auth=(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN), stream=True, timeout=30, ) resp.raise_for_status() with open(save_path, "wb") as f: for chunk in resp.iter_content(chunk_size=8192): f.write(chunk) logger.info("Media downloaded to %s", save_path) return save_path except Exception as e: logger.error("download_media failed: %s", e) return None def validate_signature(url: str, params: Dict[str, Any], signature: str) -> bool: """Validate the X-Twilio-Signature header against the request URL + params.""" try: return _validator.validate(url, params, signature) except Exception as e: logger.error("Signature validation error: %s", e) return False