Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions app/config.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"

Expand Down
89 changes: 75 additions & 14 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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}
30 changes: 30 additions & 0 deletions app/messaging.py
Original file line number Diff line number Diff line change
@@ -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()
66 changes: 66 additions & 0 deletions app/meta_client.py
Original file line number Diff line number Diff line change
@@ -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=<hex>

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)
40 changes: 31 additions & 9 deletions app/twilio_client.py
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions tests/test_signature_validation.py
Original file line number Diff line number Diff line change
@@ -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
Loading