feat: initial commit — HermesMessages SaaS platform
Backend (FastAPI + Python 3.12): - Multi-tenant auth with JWT: login, register, refresh, Meta OAuth - Business & BusinessConfig management - WhatsApp webhook with HMAC signature verification - Bot engine powered by Claude AI - Calendar availability with Redis caching - Reservations CRUD with status management - Dashboard analytics (stats, agenda, peak hours) - Billing & plan management - Admin panel with platform-wide stats - Async bcrypt via asyncio.to_thread - IntegrityError handling for concurrent registration race conditions Frontend (React 18 + Vite + Tailwind CSS): - Multi-step guided registration form with helper text on every field - Login page with show/hide password toggle - Protected routes with AuthContext - Dashboard with stats cards, bar chart, and daily agenda - Reservations list with search, filters, and inline status actions - Calendar with weekly view, slot availability, and date blocking - Config page: business info, schedules, bot personality - Billing page with plan comparison and usage bar Design system: - Bricolage Grotesque + DM Sans typography - Emerald primary palette with semantic color tokens - scale(0.97) button press feedback, ease-out animations - Skeleton loaders, stagger animations, prefers-reduced-motion support - Accessible: aria-labels, visible focus rings, 4.5:1 contrast Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
0
backend/app/modules/whatsapp/__init__.py
Normal file
0
backend/app/modules/whatsapp/__init__.py
Normal file
21
backend/app/modules/whatsapp/client.py
Normal file
21
backend/app/modules/whatsapp/client.py
Normal file
@ -0,0 +1,21 @@
|
||||
import httpx
|
||||
|
||||
GRAPH_API_VERSION = "v20.0"
|
||||
GRAPH_API_BASE = f"https://graph.facebook.com/{GRAPH_API_VERSION}"
|
||||
|
||||
|
||||
async def send_text_message(phone_number_id: str, access_token: str, to: str, text: str) -> None:
|
||||
url = f"{GRAPH_API_BASE}/{phone_number_id}/messages"
|
||||
payload = {
|
||||
"messaging_product": "whatsapp",
|
||||
"to": to,
|
||||
"type": "text",
|
||||
"text": {"body": text},
|
||||
}
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
76
backend/app/modules/whatsapp/router.py
Normal file
76
backend/app/modules/whatsapp/router.py
Normal file
@ -0,0 +1,76 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Header, Query, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_business
|
||||
from app.modules.whatsapp import schemas, service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/connect", response_model=dict)
|
||||
async def connect(
|
||||
body: schemas.ConnectRequest,
|
||||
business_id: int = Depends(get_current_business),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await service.connect_whatsapp(db, business_id, body)
|
||||
return {"detail": "WhatsApp conectado correctamente"}
|
||||
|
||||
|
||||
@router.get("/status", response_model=schemas.WhatsAppStatusRead)
|
||||
async def get_status(
|
||||
business_id: int = Depends(get_current_business),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from sqlalchemy import select
|
||||
from app.modules.business.models import Business
|
||||
|
||||
result = await db.execute(select(Business).where(Business.id == business_id))
|
||||
business = result.scalar_one_or_none()
|
||||
return schemas.WhatsAppStatusRead(
|
||||
connected=bool(business and business.whatsapp_phone_number_id),
|
||||
phone_number_id=business.whatsapp_phone_number_id if business else None,
|
||||
meta_business_id=business.meta_business_id if business else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/disconnect", status_code=204)
|
||||
async def disconnect(
|
||||
business_id: int = Depends(get_current_business),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await service.disconnect_whatsapp(db, business_id)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.get("/webhook")
|
||||
async def verify_webhook(
|
||||
hub_mode: str | None = Query(default=None, alias="hub.mode"),
|
||||
hub_verify_token: str | None = Query(default=None, alias="hub.verify_token"),
|
||||
hub_challenge: str | None = Query(default=None, alias="hub.challenge"),
|
||||
):
|
||||
"""Verificación inicial del webhook por parte de Meta."""
|
||||
if hub_mode == "subscribe" and hub_verify_token == settings.META_WEBHOOK_VERIFY_TOKEN:
|
||||
return Response(content=hub_challenge, media_type="text/plain")
|
||||
return Response(status_code=403)
|
||||
|
||||
|
||||
@router.post("/webhook", status_code=200)
|
||||
async def receive_webhook(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
x_hub_signature_256: str | None = Header(default=None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Recibe mensajes de Meta, valida firma y despacha al bot de forma asíncrona."""
|
||||
body_bytes = await request.body()
|
||||
service.verify_signature(body_bytes, x_hub_signature_256 or "")
|
||||
|
||||
payload = schemas.WebhookPayload.model_validate_json(body_bytes)
|
||||
background_tasks.add_task(service.dispatch_webhook, db, payload)
|
||||
|
||||
return {"status": "ok"}
|
||||
49
backend/app/modules/whatsapp/schemas.py
Normal file
49
backend/app/modules/whatsapp/schemas.py
Normal file
@ -0,0 +1,49 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ConnectRequest(BaseModel):
|
||||
access_token: str
|
||||
phone_number_id: str
|
||||
meta_business_id: str
|
||||
|
||||
|
||||
class WhatsAppStatusRead(BaseModel):
|
||||
connected: bool
|
||||
phone_number_id: str | None
|
||||
meta_business_id: str | None
|
||||
|
||||
|
||||
# --- Estructuras del payload de Meta ---
|
||||
|
||||
class WhatsAppTextMessage(BaseModel):
|
||||
body: str
|
||||
|
||||
|
||||
class WhatsAppMessage(BaseModel):
|
||||
from_: str
|
||||
id: str
|
||||
type: str
|
||||
text: WhatsAppTextMessage | None = None
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
class WhatsAppValue(BaseModel):
|
||||
messaging_product: str
|
||||
metadata: dict
|
||||
messages: list[WhatsAppMessage] | None = None
|
||||
|
||||
|
||||
class WhatsAppChange(BaseModel):
|
||||
value: WhatsAppValue
|
||||
field: str
|
||||
|
||||
|
||||
class WhatsAppEntry(BaseModel):
|
||||
id: str
|
||||
changes: list[WhatsAppChange]
|
||||
|
||||
|
||||
class WebhookPayload(BaseModel):
|
||||
object: str
|
||||
entry: list[WhatsAppEntry]
|
||||
87
backend/app/modules/whatsapp/service.py
Normal file
87
backend/app/modules/whatsapp/service.py
Normal file
@ -0,0 +1,87 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.modules.business.models import Business
|
||||
from app.modules.whatsapp.schemas import ConnectRequest, WebhookPayload
|
||||
|
||||
|
||||
def verify_signature(payload_bytes: bytes, signature_header: str) -> None:
|
||||
"""Valida X-Hub-Signature-256 enviado por Meta."""
|
||||
if not signature_header or not signature_header.startswith("sha256="):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Firma ausente")
|
||||
|
||||
expected = hmac.new(
|
||||
settings.META_APP_SECRET.encode(),
|
||||
payload_bytes,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(expected, signature_header[len("sha256="):]):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Firma inválida")
|
||||
|
||||
|
||||
async def get_business_by_phone_number_id(
|
||||
db: AsyncSession, phone_number_id: str
|
||||
) -> Business | None:
|
||||
result = await db.execute(
|
||||
select(Business).where(Business.whatsapp_phone_number_id == phone_number_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def connect_whatsapp(
|
||||
db: AsyncSession, business_id: int, data: ConnectRequest
|
||||
) -> Business:
|
||||
result = await db.execute(select(Business).where(Business.id == business_id))
|
||||
business = result.scalar_one_or_none()
|
||||
if not business:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Negocio no encontrado")
|
||||
|
||||
business.whatsapp_access_token = data.access_token
|
||||
business.whatsapp_phone_number_id = data.phone_number_id
|
||||
business.meta_business_id = data.meta_business_id
|
||||
await db.commit()
|
||||
await db.refresh(business)
|
||||
return business
|
||||
|
||||
|
||||
async def disconnect_whatsapp(db: AsyncSession, business_id: int) -> None:
|
||||
result = await db.execute(select(Business).where(Business.id == business_id))
|
||||
business = result.scalar_one_or_none()
|
||||
if not business:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Negocio no encontrado")
|
||||
|
||||
business.whatsapp_access_token = None
|
||||
business.whatsapp_phone_number_id = None
|
||||
business.meta_business_id = None
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def dispatch_webhook(db: AsyncSession, payload: WebhookPayload) -> None:
|
||||
"""Procesa cada mensaje entrante y lo envía al bot engine."""
|
||||
from app.modules.bot_engine.service import process_message
|
||||
|
||||
for entry in payload.entry:
|
||||
for change in entry.changes:
|
||||
if change.field != "messages" or not change.value.messages:
|
||||
continue
|
||||
|
||||
phone_number_id = change.value.metadata.get("phone_number_id")
|
||||
business = await get_business_by_phone_number_id(db, phone_number_id)
|
||||
if not business:
|
||||
continue
|
||||
|
||||
for message in change.value.messages:
|
||||
if message.type != "text" or not message.text:
|
||||
continue
|
||||
await process_message(
|
||||
db=db,
|
||||
phone=message.from_,
|
||||
text=message.text.body,
|
||||
business=business,
|
||||
)
|
||||
Reference in New Issue
Block a user