feat: orquestador uMind (SPA Vue) + tools custom + canales Telegram/WhatsApp

SPA nueva en /orchestrator (Vue 3 + Vite, servida por el mismo binario Go
bajo /orchestrator para que la cookie de sesión funcione sin tocar CORS),
reemplaza al panel Alpine.js como punto de entrada del menú.

Backend, todo aditivo sobre el motor de uMind ya existente:
- UmindHerramienta: tools custom por tenant que llaman un webhook HTTP,
  integradas al loop de function-calling existente. Cliente HTTP con
  guardas SSRF (bloqueo de IPs privadas/loopback/link-local resuelto en el
  momento de conectar, no antes, para cerrar la ventana de DNS rebinding)
  que no existían en el proyecto.
- UmindCanal: Telegram y WhatsApp Business Cloud API como canales
  adicionales del mismo agente que ya atiende el widget web, ambos
  reusando ProcessWidgetMessage. WhatsApp valida X-Hub-Signature-256.
  Credenciales cifradas en reposo con el mismo AES-GCM+APP_KEY que ya usa
  el proyecto para la contraseña SMTP (primer uso para secretos de uMind).
- Se conecta middlewares.Limit() (rate limiter que existía pero no se
  usaba en ningún lado) al widget público y a los webhooks nuevos.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-11 22:16:20 -05:00
co-authored by Claude Sonnet 5
parent aaf36b33ce
commit da0bffe661
30 changed files with 2105 additions and 26 deletions
@@ -0,0 +1,140 @@
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 tenant.
// 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)
}
+272
View File
@@ -1,11 +1,14 @@
package controllers
import (
"fmt"
"math"
"regexp"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
@@ -213,3 +216,272 @@ func GetUmindHistorialHandler(c *fiber.Ctx) error {
}
return c.JSON(fiber.Map{"items": items})
}
// ─── Tools custom (webhooks) ───────────────────────────────────────────────
var umindNombreToolRegex = regexp.MustCompile(`^[a-z][a-z0-9_]{2,63}$`)
func GetUmindHerramientasHandler(c *fiber.Ctx) error {
tenantID, err := strconv.ParseUint(c.Query("tenant_id"), 10, 64)
if err != nil || tenantID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
}
items, err := models.GetUmindHerramientasByTenant(uint(tenantID))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// No se devuelve el secreto cifrado ni el descifrado — solo si hay uno configurado.
out := make([]fiber.Map, len(items))
for i, h := range items {
out[i] = fiber.Map{
"ID": h.ID, "tenant_id": h.TenantID, "nombre": h.Nombre, "descripcion": h.Descripcion,
"parametros_json": h.ParametrosJSON, "url": h.URL, "auth_header_nombre": h.AuthHeaderNombre,
"auth_configurado": h.AuthHeaderValorEnc != "", "activa": h.Activa,
}
}
return c.JSON(fiber.Map{"items": out})
}
type umindHerramientaReq struct {
TenantID uint `json:"tenant_id"`
Nombre string `json:"nombre"`
Descripcion string `json:"descripcion"`
Parametros []models.UmindHerramientaParametro `json:"parametros"`
URL string `json:"url"`
AuthHeaderNombre string `json:"auth_header_nombre"`
AuthHeaderValor *string `json:"auth_header_valor"` // nil = no tocar (en updates)
Activa bool `json:"activa"`
}
func CreateUmindHerramientaHandler(c *fiber.Ctx) error {
var req umindHerramientaReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if req.TenantID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
}
if !umindNombreToolRegex.MatchString(req.Nombre) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "el nombre debe ser minúsculas/números/guion_bajo, empezar con letra (3-64 caracteres)"})
}
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(req.URL)), "https://") {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "la URL debe ser https"})
}
parametrosJSON, err := models.ParametrosToJSON(req.Parametros)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "parametros inválidos"})
}
var authEnc string
if req.AuthHeaderValor != nil && *req.AuthHeaderValor != "" {
authEnc, err = services.CifrarSecretoUmind(*req.AuthHeaderValor)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
}
h := &models.UmindHerramienta{
TenantID: req.TenantID, Nombre: req.Nombre, Descripcion: strings.TrimSpace(req.Descripcion),
ParametrosJSON: parametrosJSON, URL: strings.TrimSpace(req.URL),
AuthHeaderNombre: strings.TrimSpace(req.AuthHeaderNombre), AuthHeaderValorEnc: authEnc, Activa: true,
}
if err := models.CreateUmindHerramienta(h); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": h.ID})
}
func UpdateUmindHerramientaHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
var req umindHerramientaReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if !umindNombreToolRegex.MatchString(req.Nombre) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "el nombre debe ser minúsculas/números/guion_bajo, empezar con letra (3-64 caracteres)"})
}
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(req.URL)), "https://") {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "la URL debe ser https"})
}
parametrosJSON, err := models.ParametrosToJSON(req.Parametros)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "parametros inválidos"})
}
updates := map[string]interface{}{
"nombre": req.Nombre, "descripcion": strings.TrimSpace(req.Descripcion),
"parametros_json": parametrosJSON, "url": strings.TrimSpace(req.URL),
"auth_header_nombre": strings.TrimSpace(req.AuthHeaderNombre), "activa": req.Activa,
}
if req.AuthHeaderValor != nil {
if *req.AuthHeaderValor == "" {
updates["auth_header_valor_enc"] = ""
} else {
enc, err := services.CifrarSecretoUmind(*req.AuthHeaderValor)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
updates["auth_header_valor_enc"] = enc
}
}
if err := models.UpdateUmindHerramienta(uint(id), updates); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func DeleteUmindHerramientaHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := models.DeleteUmindHerramienta(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// ─── Canales (Telegram / WhatsApp) ─────────────────────────────────────────
func GetUmindCanalesHandler(c *fiber.Ctx) error {
tenantID, err := strconv.ParseUint(c.Query("tenant_id"), 10, 64)
if err != nil || tenantID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
}
items, err := models.GetUmindCanalesByTenant(uint(tenantID))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
out := make([]fiber.Map, len(items))
for i, canal := range items {
webhookURL := ""
if canal.Tipo == "telegram" {
webhookURL = fmt.Sprintf("%s/webhooks/umind-telegram/%s", app.Http.Server.Url, canal.WebhookSecret)
} else if canal.Tipo == "whatsapp" {
webhookURL = fmt.Sprintf("%s/webhooks/umind-whatsapp/%s", app.Http.Server.Url, canal.WebhookSecret)
}
out[i] = fiber.Map{
"ID": canal.ID, "tenant_id": canal.TenantID, "tipo": canal.Tipo, "activo": canal.Activo,
"webhook_url": webhookURL, "ultimo_error": canal.UltimoError,
}
}
return c.JSON(fiber.Map{"items": out})
}
type umindCanalReq struct {
TenantID uint `json:"tenant_id"`
Tipo string `json:"tipo"`
Credenciales map[string]string `json:"credenciales"`
Activo bool `json:"activo"`
}
func CreateUmindCanalHandler(c *fiber.Ctx) error {
var req umindCanalReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if req.TenantID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
}
switch req.Tipo {
case "telegram":
if strings.TrimSpace(req.Credenciales["bot_token"]) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bot_token requerido"})
}
case "whatsapp":
for _, k := range []string{"phone_number_id", "access_token", "app_secret", "verify_token"} {
if strings.TrimSpace(req.Credenciales[k]) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": k + " requerido"})
}
}
default:
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tipo debe ser telegram o whatsapp"})
}
credencialesEnc, err := services.CifrarCredencialesCanal(req.Credenciales)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
canal := &models.UmindCanal{TenantID: req.TenantID, Tipo: req.Tipo, Activo: true, CredencialesEnc: credencialesEnc}
if err := models.CreateUmindCanal(canal); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if req.Tipo == "telegram" {
webhookURL := fmt.Sprintf("%s/webhooks/umind-telegram/%s", app.Http.Server.Url, canal.WebhookSecret)
if err := services.RegistrarWebhookTelegram(req.Credenciales["bot_token"], webhookURL); err != nil {
models.UpdateUmindCanal(canal.ID, map[string]interface{}{"ultimo_error": err.Error()})
}
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": canal.ID, "webhook_secret": canal.WebhookSecret})
}
func UpdateUmindCanalHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
var req umindCanalReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
updates := map[string]interface{}{"activo": req.Activo}
if len(req.Credenciales) > 0 {
enc, err := services.CifrarCredencialesCanal(req.Credenciales)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
updates["credenciales_enc"] = enc
updates["ultimo_error"] = ""
}
if err := models.UpdateUmindCanal(uint(id), updates); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func DeleteUmindCanalHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := models.DeleteUmindCanal(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// ─── Chat de prueba ─────────────────────────────────────────────────────────
// UmindChatPruebaHandler deja que el staff pruebe el agente de un tenant
// directo desde el panel, sin pasar por site_key/dominio (ya está gateado
// por la sesión con la que se llega acá).
func UmindChatPruebaHandler(c *fiber.Ctx) error {
var req struct {
TenantID uint `json:"tenant_id"`
SessionID string `json:"session_id"`
Mensaje string `json:"mensaje"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if strings.TrimSpace(req.Mensaje) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "mensaje requerido"})
}
tenant, err := models.GetUmindTenantByID(req.TenantID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant no encontrado"})
}
sessionID := strings.TrimSpace(req.SessionID)
if sessionID == "" {
sessionID = "staff-preview:" + strconv.FormatUint(uint64(extraerUserID(c)), 10)
}
respuesta, err := services.ProcessWidgetMessage(tenant, sessionID, strings.TrimSpace(req.Mensaje))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"session_id": sessionID, "respuesta": respuesta})
}