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>
141 lines
4.5 KiB
Go
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 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)
|
|
}
|