Spaces:
Sleeping
Sleeping
| """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 --- | |
| 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, | |
| ) | |
| 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, | |
| } | |
| 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] | |