Files
soft_usite/rest/controllers/api/umind_canal_webhook_controller.go
T
Lizandro GuarnizoandClaude Sonnet 5 f3f2f421d6 feat: uMind pasa a multi-agente por tenant
Un tenant (negocio/sitio, dueño de los dominios permitidos) puede tener
varios UmindAgente independientes (ej. "Ventas", "Soporte"), cada uno con
su propia config de IA, tono, base de conocimiento, tools, canales y
conexión de correo. El site_key también pasa a ser por agente, así cada
uno tiene su propio <script> de widget embebible y su propio color.

Backend:
- Nuevo modelo UmindAgente (pkg/models/umind_agente.go), con SiteKey,
  AiConfigID, Tono, MensajeBienvenida y Color — campos que antes vivían en
  UmindTenant y se sacan de ahí (las columnas viejas quedan huérfanas sin
  usar, no se hace DROP COLUMN).
- UmindDocumento, UmindChunk, UmindHerramienta, UmindCanal, UmindConexion y
  UmindMensaje pasan de TenantID a AgenteID. El campo se agrega sin
  "not null" para no romper el ALTER TABLE en Postgres sobre tablas que ya
  tienen filas (ej. emetropolitana).
- migrations.MigrarUmindAgentes(): idempotente, crea un agente "Principal"
  por cada tenant existente heredando lo que ya tenía configurado, y mueve
  sus datos de tenant_id a agente_id. Corre en cada arranque normal, mismo
  criterio que los Seed* — nada se rompe para los tenants ya en producción.
- Motor del agente, widget, canales (Telegram/WhatsApp) y OAuth de correo
  ahora operan sobre UmindAgente; el tenant solo se consulta para el
  chequeo de dominio permitido y el nombre del negocio que ve el visitante.

Frontend: nueva jerarquía de navegación tenant → lista de agentes
(TenantAgentes.vue) → detalle de un agente (AgenteDetail.vue, antes
TenantDetail.vue) con las mismas 6 tabs de siempre, ahora por agente. El
modal de tenant en el sidebar se achica a nombre/dominios/activo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:30:06 -05:00

141 lines
4.5 KiB
Go

package controllers
import (
"log"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
// ─── Telegram ────────────────────────────────────────────────────────────────
type umindTgChat struct {
ID int64 `json:"id"`
}
type umindTgMessage struct {
Chat umindTgChat `json:"chat"`
Text string `json:"text"`
}
type umindTgUpdate struct {
Message *umindTgMessage `json:"message"`
}
// UmindTelegramWebhook recibe updates del bot de Telegram de un agente.
// Ruta: POST /webhooks/umind-telegram/:webhook_secret
// El webhook_secret es un identificador nuestro (no el bot token real) —
// ver el comentario en models.UmindCanal.
func UmindTelegramWebhook(c *fiber.Ctx) error {
secret := c.Params("webhook_secret")
canal, err := models.GetUmindCanalByWebhookSecret("telegram", secret)
if err != nil {
return c.SendStatus(fiber.StatusOK) // siempre 200 a Telegram, aunque no matchee
}
var update umindTgUpdate
if err := c.BodyParser(&update); err != nil || update.Message == nil {
return c.SendStatus(fiber.StatusOK)
}
texto := strings.TrimSpace(update.Message.Text)
if texto == "" {
return c.SendStatus(fiber.StatusOK)
}
if err := services.ProcesarMensajeTelegramUmind(canal, update.Message.Chat.ID, texto); err != nil {
log.Printf("[UMIND_TELEGRAM] canal %d: %v", canal.ID, err)
}
return c.SendStatus(fiber.StatusOK)
}
// ─── WhatsApp ────────────────────────────────────────────────────────────────
type umindWaMessage struct {
From string `json:"from"`
Type string `json:"type"`
Text struct {
Body string `json:"body"`
} `json:"text"`
}
type umindWaValue struct {
Metadata struct {
PhoneNumberID string `json:"phone_number_id"`
} `json:"metadata"`
Messages []umindWaMessage `json:"messages"`
}
type umindWaChange struct {
Value umindWaValue `json:"value"`
}
type umindWaEntry struct {
Changes []umindWaChange `json:"changes"`
}
type umindWaPayload struct {
Entry []umindWaEntry `json:"entry"`
}
// UmindWhatsAppVerify atiende el handshake de verificación que Meta hace al
// configurar el webhook (hub.mode/hub.verify_token/hub.challenge).
// Ruta: GET /webhooks/umind-whatsapp/:webhook_secret
func UmindWhatsAppVerify(c *fiber.Ctx) error {
secret := c.Params("webhook_secret")
canal, err := models.GetUmindCanalByWebhookSecret("whatsapp", secret)
if err != nil {
return c.SendStatus(fiber.StatusForbidden)
}
credenciales, err := services.DescifrarCredencialesCanal(canal.CredencialesEnc)
if err != nil {
return c.SendStatus(fiber.StatusForbidden)
}
if c.Query("hub.mode") != "subscribe" || c.Query("hub.verify_token") != credenciales["verify_token"] || credenciales["verify_token"] == "" {
return c.SendStatus(fiber.StatusForbidden)
}
return c.SendString(c.Query("hub.challenge"))
}
// UmindWhatsAppWebhook recibe mensajes entrantes de WhatsApp Business Cloud
// API. La única autenticación real acá es la firma HMAC del body — el
// webhook_secret en la URL identifica el canal, pero no alcanza solo.
// Ruta: POST /webhooks/umind-whatsapp/:webhook_secret
func UmindWhatsAppWebhook(c *fiber.Ctx) error {
secret := c.Params("webhook_secret")
canal, err := models.GetUmindCanalByWebhookSecret("whatsapp", secret)
if err != nil {
return c.SendStatus(fiber.StatusOK)
}
credenciales, err := services.DescifrarCredencialesCanal(canal.CredencialesEnc)
if err != nil {
log.Printf("[UMIND_WHATSAPP] canal %d: credenciales corruptas: %v", canal.ID, err)
return c.SendStatus(fiber.StatusOK)
}
if !services.ValidarFirmaWhatsApp(credenciales["app_secret"], c.Body(), c.Get("X-Hub-Signature-256")) {
log.Printf("[UMIND_WHATSAPP] canal %d: firma inválida", canal.ID)
return c.SendStatus(fiber.StatusUnauthorized)
}
var payload umindWaPayload
if err := c.BodyParser(&payload); err != nil {
return c.SendStatus(fiber.StatusOK)
}
for _, entry := range payload.Entry {
for _, change := range entry.Changes {
for _, msg := range change.Value.Messages {
if msg.Type != "text" || strings.TrimSpace(msg.Text.Body) == "" {
continue
}
if err := services.ProcesarMensajeWhatsAppUmind(canal, msg.From, strings.TrimSpace(msg.Text.Body)); err != nil {
log.Printf("[UMIND_WHATSAPP] canal %d: %v", canal.ID, err)
}
}
}
}
return c.SendStatus(fiber.StatusOK)
}