compliance / phone_verification.py
VeuReu's picture
Upload 3 files
5d081f7 verified
"""Phone verification router for Veureu Compliance.
This module manages:
- Creation of phone verification sessions (triggered from the UI Space "demo").
- Processing of incoming WhatsApp webhooks via Zapier.
- In-memory storage of verification sessions and events (ready to be moved to a real DB).
"""
from __future__ import annotations
import os
import re
from datetime import datetime, timedelta, timezone
from typing import Dict, Any, List, Optional
from fastapi import APIRouter, HTTPException, Header
from pydantic import BaseModel, Field
router = APIRouter(prefix="/phone_verification", tags=["phone_verification"])
# --- In-memory storage (replace with real DB/ORM later) ---
_SESSIONS: Dict[str, Dict[str, Any]] = {}
_EVENTS: List[Dict[str, Any]] = []
# --- Helpers ---
def now_utc() -> datetime:
"""Return current UTC time as timezone-aware datetime."""
return datetime.now(timezone.utc)
def generate_session_code(length: int = 5) -> str:
"""Generate a short session code with A-Z + 0-9.
Example: "XQ7B9".
"""
import random
import string
alphabet = string.ascii_uppercase + string.digits
return "".join(random.choice(alphabet) for _ in range(length))
# --- Pydantic models ---
class CreatePhoneVerificationSessionRequest(BaseModel):
page: str = Field(..., description="Identificador de pàgina o ruta web")
user_id: Optional[str] = Field(None, description="Identificador intern opcional d'usuari")
email: Optional[str] = Field(None, description="Email opcional de l'usuari")
action: Optional[str] = Field(None, description="Identificador opcional del botó/acció")
class CreatePhoneVerificationSessionResponse(BaseModel):
session_code: str
expires_at: datetime
page: str
user_id: Optional[str] = None
email: Optional[str] = None
action: Optional[str] = None
class WhatsAppZapierPayload(BaseModel):
from_phone: str = Field(..., description="Número de telèfon del remitent (format E.164 o similar)")
message_body: str = Field(..., description="Text complet del missatge rebut per WhatsApp")
class VerificationEvent(BaseModel):
session_code: str
phone: str
page: str
action: Optional[str] = None
user_id: Optional[str] = None
email: Optional[str] = None
created_at: datetime
confirmed_at: datetime
# --- Endpoints ---
@router.post("/create_session", response_model=CreatePhoneVerificationSessionResponse)
def create_session(payload: CreatePhoneVerificationSessionRequest) -> CreatePhoneVerificationSessionResponse:
"""Create a new phone verification session.
This is called from the UI Space "demo" whenever the user presses a button
that requires phone validation / terms acceptance.
"""
session_code = generate_session_code(5)
created_at = now_utc()
expires_at = created_at + timedelta(minutes=5)
session_data = {
"session_code": session_code,
"page": payload.page,
"action": payload.action,
"user_id": payload.user_id,
"email": payload.email,
"created_at": created_at,
"expires_at": expires_at,
"confirmed": False,
"confirmed_at": None,
"phone": None,
}
# In-memory storage (replace with DB insert)
_SESSIONS[session_code] = session_data
return CreatePhoneVerificationSessionResponse(
session_code=session_code,
expires_at=expires_at,
page=payload.page,
user_id=payload.user_id,
email=payload.email,
action=payload.action,
)
@router.post("/webhook/whatsapp")
def whatsapp_webhook(
payload: WhatsAppZapierPayload,
x_zapier_token: str = Header(None, alias="X-ZAPIER-TOKEN"),
) -> Dict[str, Any]:
"""Webhook endpoint for incoming WhatsApp messages via Zapier.
Expected Zapier configuration:
- Trigger: "New Incoming WhatsApp Message" (or equivalent).
- Action: Webhooks by Zapier → POST
- URL: https://veureu-compliance.hf.space/phone_verification/webhook/whatsapp
- Headers:
- Content-Type: application/json
- X-ZAPIER-TOKEN: <token_compartit>
- Body (JSON):
{
"from_phone": "+34600123000",
"message_body": "ACEPTO XQ7B9"
}
"""
# 1) Validar token compartit
secret = os.getenv("ZAPIER_WEBHOOK_TOKEN") or os.getenv("X_ZAPIER_TOKEN")
if not secret or x_zapier_token != secret:
raise HTTPException(status_code=401, detail="Invalid Zapier token")
raw_text = payload.message_body.strip()
# 2) Esperar format "ACEPTO <SESSION_CODE>" (case-insensitive, espais flexibles)
# Acceptem formes com "ACEPTO XQ7B9" o text addicional abans/després.
match = re.search(r"acepto\s+([A-Z0-9]{4,8})", raw_text, re.IGNORECASE)
if not match:
return {"status": "error", "reason": "invalid_format", "detail": "Expected 'ACEPTO <CODE>'"}
session_code = match.group(1).upper()
# 3) Buscar sessió
session = _SESSIONS.get(session_code)
if not session:
return {"status": "error", "reason": "unknown_session_code", "session_code": session_code}
now = now_utc()
# 4) Validar expiració i estat
if session.get("confirmed"):
return {
"status": "error",
"reason": "already_confirmed",
"session_code": session_code,
}
expires_at: datetime = session["expires_at"]
if now > expires_at:
return {
"status": "error",
"reason": "expired",
"session_code": session_code,
"expires_at": expires_at,
}
# 5) Marcar sessió com confirmada
session["confirmed"] = True
session["confirmed_at"] = now
session["phone"] = payload.from_phone
event = {
"session_code": session_code,
"phone": payload.from_phone,
"page": session["page"],
"action": session.get("action"),
"user_id": session.get("user_id"),
"email": session.get("email"),
"created_at": session["created_at"],
"confirmed_at": now,
}
_EVENTS.append(event)
message_to_send = (
"✅ Hem registrat la teva acceptació. "
"Gràcies per confirmar els termes d'ús de Veureu."
)
return {
"status": "ok",
"session_code": session_code,
"phone": payload.from_phone,
"page": session["page"],
"action": session.get("action"),
"user_id": session.get("user_id"),
"email": session.get("email"),
"created_at": session["created_at"],
"confirmed_at": now,
"message_to_send": message_to_send,
}
@router.get("/events", response_model=List[VerificationEvent])
def list_events(
phone: Optional[str] = None,
user_id: Optional[str] = None,
email: Optional[str] = None,
page: Optional[str] = None,
action: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> List[VerificationEvent]:
"""Return verification events history, optionally filtered.
This is mainly for internal dashboards or auditing tools.
"""
filtered: List[Dict[str, Any]] = []
for ev in _EVENTS:
if phone and ev.get("phone") != phone:
continue
if user_id and ev.get("user_id") != user_id:
continue
if email and ev.get("email") != email:
continue
if page and ev.get("page") != page:
continue
if action and ev.get("action") != action:
continue
filtered.append(ev)
sliced = filtered[offset : offset + limit]
return [VerificationEvent(**ev) for ev in sliced]