IrisSupport / whatsapp_client.py
rairo's picture
Align quick-reply buttons with static Content template
4935b11
Raw
History Blame Contribute Delete
9.96 kB
"""
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