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:
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