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
+85
View File
@@ -0,0 +1,85 @@
package models
import (
"crypto/rand"
"encoding/hex"
"fmt"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// UmindCanal es un canal de mensajería adicional (Telegram, WhatsApp) que
// alimenta al mismo agente del tenant que ya atiende el widget web. Los
// secretos reales (bot token, access token de WhatsApp, etc.) viven cifrados
// en CredencialesEnc (ver pkg/services/umind_secrets.go) — el modelo solo
// persiste el string ya cifrado, no conoce la clave.
//
// WebhookSecret es un identificador público generado por nosotros, distinto
// del secreto real del proveedor, usado SOLO para enrutar el webhook
// entrante al canal correcto (va en la URL que se registra en
// Telegram/Meta). Evita que el token real del proveedor termine en logs de
// acceso o de un proxy intermedio.
type UmindCanal struct {
gorm.Model
TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index;not null"`
Tipo string `json:"tipo" gorm:"column:tipo;size:20;not null"` // telegram | whatsapp
Activo bool `json:"activo" gorm:"column:activo;default:true"`
WebhookSecret string `json:"webhook_secret" gorm:"column:webhook_secret;uniqueIndex;size:40;not null"`
CredencialesEnc string `json:"-" gorm:"column:credenciales_enc;type:text"`
UltimoError string `json:"ultimo_error" gorm:"column:ultimo_error;type:text"`
}
func (UmindCanal) TableName() string { return "umind_canales" }
func GenerarWebhookSecret() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("no se pudo generar el webhook_secret: %w", err)
}
return "umc_" + hex.EncodeToString(b), nil
}
func CreateUmindCanal(c *UmindCanal) error {
if c.WebhookSecret == "" {
secret, err := GenerarWebhookSecret()
if err != nil {
return err
}
c.WebhookSecret = secret
}
return app.Http.Database.DB.Create(c).Error
}
func GetUmindCanalesByTenant(tenantID uint) ([]UmindCanal, error) {
var items []UmindCanal
err := app.Http.Database.DB.Where("tenant_id = ?", tenantID).Order("id DESC").Find(&items).Error
return items, err
}
func GetUmindCanalByID(id uint) (*UmindCanal, error) {
var c UmindCanal
if err := app.Http.Database.DB.First(&c, id).Error; err != nil {
return nil, err
}
return &c, nil
}
// GetUmindCanalByWebhookSecret resuelve el canal a partir del identificador
// público que viene en la URL del webhook. Solo matchea si está activo.
func GetUmindCanalByWebhookSecret(tipo, webhookSecret string) (*UmindCanal, error) {
var c UmindCanal
err := app.Http.Database.DB.Where("tipo = ? AND webhook_secret = ? AND activo = ?", tipo, webhookSecret, true).First(&c).Error
if err != nil {
return nil, err
}
return &c, nil
}
func UpdateUmindCanal(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&UmindCanal{}).Where("id = ?", id).Updates(updates).Error
}
func DeleteUmindCanal(id uint) error {
return app.Http.Database.DB.Delete(&UmindCanal{}, id).Error
}
+118
View File
@@ -0,0 +1,118 @@
package models
import (
"encoding/json"
"fmt"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// UmindHerramientaMax es el máximo de tools activas por tenant — acota el
// tamaño del prompt (cada tool declarada se manda entera al modelo en cada
// mensaje) y la superficie de webhooks que un tenant puede disparar.
const UmindHerramientaMax = 10
// UmindHerramientaParametro describe un parámetro que el modelo debe
// completar al invocar la tool. Es un JSON Schema simplificado (solo tipos
// primitivos) para que el staff lo pueda armar desde un formulario sin
// escribir JSON a mano.
type UmindHerramientaParametro struct {
Nombre string `json:"nombre"`
Tipo string `json:"tipo"` // string | number | boolean
Descripcion string `json:"descripcion"`
Requerido bool `json:"requerido"`
}
// UmindHerramienta es una tool custom de un tenant: cuando el agente decide
// usarla, se hace un POST a URL con los argumentos que decidió el modelo. El
// valor de AuthHeaderValorEnc viaja cifrado en reposo (ver
// pkg/services/umind_secrets.go) porque es un secreto de terceros que hay
// que poder recuperar tal cual para reenviarlo, a diferencia de una
// contraseña propia que solo necesitamos poder verificar.
type UmindHerramienta struct {
gorm.Model
TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index;not null"`
Nombre string `json:"nombre" gorm:"column:nombre;size:64;not null"` // identificador de function-calling, ej: "consultar_stock"
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text;not null"`
ParametrosJSON string `json:"parametros_json" gorm:"column:parametros_json;type:text"` // []UmindHerramientaParametro
URL string `json:"url" gorm:"column:url;type:text;not null"`
AuthHeaderNombre string `json:"auth_header_nombre" gorm:"column:auth_header_nombre;size:100"` // ej: "Authorization", opcional
AuthHeaderValorEnc string `json:"-" gorm:"column:auth_header_valor_enc;type:text"`
Activa bool `json:"activa" gorm:"column:activa;default:true"`
}
func (UmindHerramienta) TableName() string { return "umind_herramientas" }
func ParametrosToJSON(p []UmindHerramientaParametro) (string, error) {
b, err := json.Marshal(p)
if err != nil {
return "", err
}
return string(b), nil
}
func ParametrosFromJSON(s string) ([]UmindHerramientaParametro, error) {
if s == "" {
return nil, nil
}
var p []UmindHerramientaParametro
if err := json.Unmarshal([]byte(s), &p); err != nil {
return nil, err
}
return p, nil
}
func CreateUmindHerramienta(h *UmindHerramienta) error {
var activas int64
if err := app.Http.Database.DB.Model(&UmindHerramienta{}).
Where("tenant_id = ? AND activa = ?", h.TenantID, true).Count(&activas).Error; err != nil {
return err
}
if activas >= UmindHerramientaMax {
return fmt.Errorf("este tenant ya tiene el máximo de %d tools activas", UmindHerramientaMax)
}
return app.Http.Database.DB.Create(h).Error
}
func GetUmindHerramientasByTenant(tenantID uint) ([]UmindHerramienta, error) {
var items []UmindHerramienta
err := app.Http.Database.DB.Where("tenant_id = ?", tenantID).Order("id DESC").Find(&items).Error
return items, err
}
// GetUmindHerramientasActivas retorna las tools activas del tenant, para
// armar el toolset del agente en cada mensaje.
func GetUmindHerramientasActivas(tenantID uint) ([]UmindHerramienta, error) {
var items []UmindHerramienta
err := app.Http.Database.DB.Where("tenant_id = ? AND activa = ?", tenantID, true).Find(&items).Error
return items, err
}
func GetUmindHerramientaByID(id uint) (*UmindHerramienta, error) {
var h UmindHerramienta
if err := app.Http.Database.DB.First(&h, id).Error; err != nil {
return nil, err
}
return &h, nil
}
// GetUmindHerramientaByNombre resuelve una tool por nombre dentro del
// tenant — así arma la llamada real cuando el modelo pide ejecutar
// "consultar_stock", por ejemplo.
func GetUmindHerramientaByNombre(tenantID uint, nombre string) (*UmindHerramienta, error) {
var h UmindHerramienta
err := app.Http.Database.DB.Where("tenant_id = ? AND nombre = ? AND activa = ?", tenantID, nombre, true).First(&h).Error
if err != nil {
return nil, err
}
return &h, nil
}
func UpdateUmindHerramienta(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&UmindHerramienta{}).Where("id = ?", id).Updates(updates).Error
}
func DeleteUmindHerramienta(id uint) error {
return app.Http.Database.DB.Delete(&UmindHerramienta{}, id).Error
}