diff --git a/app/config.py b/app/config.py index 39fee21..733acc8 100644 --- a/app/config.py +++ b/app/config.py @@ -1,9 +1,9 @@ from pydantic import BaseSettings class Settings(BaseSettings): - TWILIO_SID: str - TWILIO_AUTH: str - TWILIO_WHATSAPP_NUMBER: str + TWILIO_SID: str = "" + TWILIO_AUTH: str = "" + TWILIO_WHATSAPP_NUMBER: str = "" REDIS_URL: str = "redis://localhost:6379/0" OPENAI_API_KEY: str = "" PORT: int = 8000 @@ -22,6 +22,9 @@ class Settings(BaseSettings): META_APP_SECRET: str = "" META_VERIFY_TOKEN: str = "" + # Public webhook URL override (useful when behind proxy / ngrok) + WEBHOOK_PUBLIC_URL: str = "" + class Config: env_file = ".env" diff --git a/app/main.py b/app/main.py index e1747e8..44025f6 100644 --- a/app/main.py +++ b/app/main.py @@ -1,27 +1,84 @@ -from fastapi import FastAPI, Request, Form, HTTPException -import asyncio -import os +from fastapi import FastAPI, Request, HTTPException +import logging from app.classifier import classify_urgency from app.convo_store import ConvoStore from app.reply_generator import generate_reply -from app.twilio_client import send_whatsapp_message +from app.messaging import client as messaging_client from app.config import settings +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + app = FastAPI() store = ConvoStore(redis_url=settings.REDIS_URL) -@app.get("/health") -async def health(): - return {"status": "ok"} +@app.get("/webhook") +async def webhook_verify(request: Request): + # Used for Meta/WhatsApp webhook verification + params = dict(request.query_params) + mode = params.get("hub.mode") or params.get("mode") + challenge = params.get("hub.challenge") or params.get("challenge") + verify_token = params.get("hub.verify_token") or params.get("verify_token") + expected = getattr(settings, "META_VERIFY_TOKEN", "") + if mode == "subscribe" and verify_token == expected: + return int(challenge or 0) + raise HTTPException(status_code=400, detail="Invalid verify token") @app.post("/webhook") async def webhook(request: Request): - # Twilio posts form-encoded data for incoming messages - form = await request.form() - body = form.get("Body") or "" - from_number = form.get("From") or "" + # Twilio posts form-encoded data; Meta posts JSON + headers = {k: v for k, v in request.headers.items()} + + # read raw body for signature checks + raw = await request.body() + + # determine public URL to validate Twilio signatures when behind a proxy + public_url = settings.WEBHOOK_PUBLIC_URL or str(request.url) + + # parse depending on provider + provider = getattr(settings, "WHATSAPP_PROVIDER", "twilio").lower() + + if provider == "meta": + try: + payload = await request.json() + except Exception: + logger.exception("Failed to parse JSON payload from Meta") + raise HTTPException(status_code=400, detail="Malformed JSON payload") + params = payload + # Validate request via messaging client (pass raw body) + valid = messaging_client.validate_request(public_url, params, headers, raw) + if not valid: + logger.warning("Invalid Meta webhook signature") + raise HTTPException(status_code=403, detail="Invalid signature") + # extract message content (WhatsApp Cloud API structure) + try: + entry = payload.get("entry", [])[0] + changes = entry.get("changes", [])[0] + value = changes.get("value", {}) + messages = value.get("messages", []) + if not messages: + return {"status": "no_message"} + message = messages[0] + body = message.get("text", {}).get("body", "") + from_number = "whatsapp:" + message.get("from", "") + except Exception as e: + logger.exception("Failed to parse Meta payload") + raise HTTPException(status_code=400, detail="Malformed payload") + else: + # Twilio + form = await request.form() + body = form.get("Body") or "" + from_number = form.get("From") or "" + params = dict(form) + # Validate request + valid = messaging_client.validate_request(public_url, params, headers) + if not valid: + logger.warning("Invalid Twilio signature") + raise HTTPException(status_code=403, detail="Invalid signature") + if not from_number: raise HTTPException(status_code=400, detail="Missing From") + # Classify urgency urgency, score = classify_urgency(body) # Persist message and metadata @@ -30,9 +87,13 @@ async def webhook(request: Request): if urgency == "high": reply = "I detected this might be urgent. Do you want me to escalate this to support now?" else: - # Use LLM or template to generate contextual reply history = await store.get_history(from_number, limit=10) reply = await generate_reply(body, history) - # Send reply via Twilio - send_whatsapp_message(to=from_number, body=reply) + # Send reply via provider client + try: + messaging_client.send(to=from_number, body=reply) + except Exception: + logger.exception("Failed to send message") + raise HTTPException(status_code=500, detail="Failed to send message") + return {"status": "ok", "urgency": urgency} diff --git a/app/messaging.py b/app/messaging.py new file mode 100644 index 0000000..3846dab --- /dev/null +++ b/app/messaging.py @@ -0,0 +1,30 @@ +import logging +from app.config import settings +from app.twilio_client import TwilioProvider +from app.meta_client import MetaProvider + +logger = logging.getLogger(__name__) + +class MessagingClient: + def __init__(self): + provider = getattr(settings, "WHATSAPP_PROVIDER", "twilio").lower() + if provider == "meta": + self._client = MetaProvider() + else: + self._client = TwilioProvider() + + def send(self, to: str, body: str): + return self._client.send(to=to, body=body) + + def validate_request(self, full_url: str, params, headers: dict, raw_body: bytes = None) -> bool: + # Delegate to provider-specific validation. Meta requires raw_body for HMAC validation. + if hasattr(self._client, "validate_request"): + try: + return self._client.validate_request(full_url, params, headers, raw_body) + except TypeError: + # Some providers expect (full_url, params, headers) without raw_body + return self._client.validate_request(full_url, params, headers) + return False + +# singleton +client = MessagingClient() diff --git a/app/meta_client.py b/app/meta_client.py new file mode 100644 index 0000000..5fe1f70 --- /dev/null +++ b/app/meta_client.py @@ -0,0 +1,66 @@ +import hmac +import hashlib +import logging +import requests +from app.config import settings + +logger = logging.getLogger(__name__) + +class MetaProvider: + """Send WhatsApp messages via Meta (WhatsApp Business Cloud API). + Requires META_PHONE_NUMBER_ID and META_TOKEN in env. + """ + def __init__(self): + self.token = getattr(settings, "META_TOKEN", "") + self.phone_number_id = getattr(settings, "META_PHONE_NUMBER_ID", "") + self.base_url = f"https://graph.facebook.com/v15.0/{self.phone_number_id}/messages" + self.app_secret = getattr(settings, "META_APP_SECRET", "") + + def send(self, to: str, body: str): + if not (self.token and self.phone_number_id): + raise RuntimeError("META_TOKEN or META_PHONE_NUMBER_ID not configured") + headers = {"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"} + payload = { + "messaging_product": "whatsapp", + "to": to.replace("whatsapp:", ""), + "type": "text", + "text": {"body": body} + } + r = requests.post(self.base_url, headers=headers, json=payload, timeout=10) + r.raise_for_status() + return r.json() + + def validate_request(self, full_url: str, params, headers: dict, raw_body: bytes = None) -> bool: + """ + Validate Meta (Facebook) webhook signature. Must compute HMAC-SHA256 over raw request body bytes + and compare to X-Hub-Signature-256 header which is of the form: sha256= + + Args: + full_url: not used for Meta but kept for interface compatibility + params: parsed params/payload (not used for signature check) + headers: request headers mapping + raw_body: raw request body bytes (required) + Returns: + bool indicating whether signature is valid + """ + sig_header = headers.get("X-Hub-Signature-256") or headers.get("x-hub-signature-256") + if not sig_header: + logger.warning("Missing X-Hub-Signature-256 header") + return False + if not self.app_secret: + logger.warning("META_APP_SECRET not configured; rejecting request") + return False + if raw_body is None: + logger.warning("No raw body provided for Meta signature validation") + return False + try: + prefix, signature = sig_header.split("=", 1) + except Exception: + logger.warning("Bad X-Hub-Signature-256 header format") + return False + if prefix.lower() != "sha256": + logger.warning("Unsupported signature algorithm: %s", prefix) + return False + mac = hmac.new(self.app_secret.encode(), msg=raw_body, digestmod=hashlib.sha256) + expected = mac.hexdigest() + return hmac.compare_digest(expected, signature) diff --git a/app/twilio_client.py b/app/twilio_client.py index bd0a01f..03e4e37 100644 --- a/app/twilio_client.py +++ b/app/twilio_client.py @@ -1,12 +1,34 @@ -from twilio.rest import Client +from twilio.rest import Client as TwilioClient +from twilio.request_validator import RequestValidator +import logging from app.config import settings -client = Client(settings.TWILIO_SID, settings.TWILIO_AUTH) +logger = logging.getLogger(__name__) -def send_whatsapp_message(to: str, body: str): - # Twilio expects 'whatsapp:+12345' formatted numbers - client.messages.create( - body=body, - from_=settings.TWILIO_WHATSAPP_NUMBER, - to=to - ) +class TwilioProvider: + def __init__(self): + self.client = TwilioClient(settings.TWILIO_SID, settings.TWILIO_AUTH) + self.validator = RequestValidator(settings.TWILIO_AUTH) if settings.TWILIO_AUTH else None + + def send(self, to: str, body: str): + return self.client.messages.create( + body=body, + from_=settings.TWILIO_WHATSAPP_NUMBER, + to=to + ) + + def validate_request(self, full_url: str, params: dict, headers: dict) -> bool: + """ + Validate Twilio request using the RequestValidator. full_url must be the public-facing URL + (including scheme) that Twilio calls. If the app is behind a proxy, set WEBHOOK_PUBLIC_URL in env + to the public URL. + """ + if not self.validator: + logger.warning("Twilio validator not configured; rejecting request") + return False + signature = headers.get("X-Twilio-Signature") or headers.get("x-twilio-signature") or "" + try: + return self.validator.validate(full_url, params, signature) + except Exception: + logger.exception("Twilio signature validation failed") + return False diff --git a/tests/test_signature_validation.py b/tests/test_signature_validation.py new file mode 100644 index 0000000..91aa654 --- /dev/null +++ b/tests/test_signature_validation.py @@ -0,0 +1,46 @@ +import json +import hmac +import hashlib +from app.meta_client import MetaProvider +from app.twilio_client import TwilioProvider +from app.config import settings + + +# This file contains a few sample headers and payloads used for unit testing signature validation. + +def make_meta_signature(secret: str, body_bytes: bytes) -> str: + mac = hmac.new(secret.encode(), msg=body_bytes, digestmod=hashlib.sha256) + return "sha256=" + mac.hexdigest() + + +def make_twilio_signature(auth_token: str, url: str, params: dict) -> str: + # Twilio RequestValidator can compute this but in unit tests we'll use the validator directly + from twilio.request_validator import RequestValidator + rv = RequestValidator(auth_token) + return rv.compute_signature(url, params) + + +def test_meta_signature_validation(): + secret = "test_app_secret" + payload = {"entry": [{"changes": [{"value": {"messages": [{"from": "14155551234", "text": {"body": "hello"}}]}}]}]} + body = json.dumps(payload).encode() + header = make_meta_signature(secret, body) + provider = MetaProvider() + # inject secret for test + provider.app_secret = secret + headers = {"X-Hub-Signature-256": header} + assert provider.validate_request("", payload, headers, raw_body=body) is True + + +def test_twilio_signature_validation(): + auth = "test_auth_token" + url = "https://example.com/webhook" + params = {"Body": "hello", "From": "whatsapp:+14155551234"} + provider = TwilioProvider() + provider.validator = None + # create a validator for testing + from twilio.request_validator import RequestValidator + provider.validator = RequestValidator(auth) + sig = provider.validator.compute_signature(url, params) + headers = {"X-Twilio-Signature": sig} + assert provider.validate_request(url, params, headers) is True