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