feat: OCR y Whisper como herramientas opcionales por canal en uMind

Cada canal (Telegram/WhatsApp) de un agente ahora puede activar, de forma
independiente, que los audios entrantes se transcriban con el Whisper ASR
propio y que a las imágenes entrantes se les extraiga texto con el
servicio OCR propio, antes de pasarle el mensaje al agente. Antes esos
mensajes se ignoraban en silencio.

- UmindCanal gana usar_whisper_audio/usar_ocr_imagenes (default off).
- WhatsApp: se descarga el media vía Graph API (resolución de URL + fetch
  con el mismo access_token del canal) y se enruta a Whisper/OCR según type.
- Telegram: se descarga el archivo vía getFile + CDN de archivos del bot,
  mismo enrutamiento para voice/audio/photo.
- Panel: checkboxes en el alta de canal y toggles inline por canal ya
  creado, en la tab Canales del agente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-13 10:35:48 -05:00
co-authored by Claude Sonnet 5
parent d493d6dee6
commit 84506a98e2
10 changed files with 332 additions and 47 deletions
+45 -2
View File
@@ -182,6 +182,7 @@ async function copiarWidget() {
function canalVacio() {
return {
tipo: 'telegram', bot_token: '', phone_number_id: '', access_token: '', app_secret: '', verify_token: '',
usar_whisper_audio: false, usar_ocr_imagenes: false,
}
}
@@ -206,7 +207,10 @@ async function guardarCanal() {
verify_token: canalForm.value.verify_token,
}
try {
await api.post('/app/umind/canales', { agente_id: agenteIdNum.value, tipo: canalForm.value.tipo, credenciales, activo: true })
await api.post('/app/umind/canales', {
agente_id: agenteIdNum.value, tipo: canalForm.value.tipo, credenciales, activo: true,
usar_whisper_audio: canalForm.value.usar_whisper_audio, usar_ocr_imagenes: canalForm.value.usar_ocr_imagenes,
})
showCanalForm.value = false
await cargarCanales()
} catch (e) {
@@ -215,7 +219,26 @@ async function guardarCanal() {
}
async function toggleCanal(c) {
await api.put(`/app/umind/canales/${c.ID}`, { activo: !c.activo, credenciales: {} })
await api.put(`/app/umind/canales/${c.ID}`, {
activo: !c.activo, credenciales: {},
usar_whisper_audio: c.usar_whisper_audio, usar_ocr_imagenes: c.usar_ocr_imagenes,
})
await cargarCanales()
}
async function toggleCanalWhisper(c) {
await api.put(`/app/umind/canales/${c.ID}`, {
activo: c.activo, credenciales: {},
usar_whisper_audio: !c.usar_whisper_audio, usar_ocr_imagenes: c.usar_ocr_imagenes,
})
await cargarCanales()
}
async function toggleCanalOcr(c) {
await api.put(`/app/umind/canales/${c.ID}`, {
activo: c.activo, credenciales: {},
usar_whisper_audio: c.usar_whisper_audio, usar_ocr_imagenes: !c.usar_ocr_imagenes,
})
await cargarCanales()
}
@@ -497,6 +520,16 @@ onMounted(async () => {
<button class="text-red-500 hover:text-red-700" @click="eliminarCanal(c)">Eliminar</button>
</div>
</div>
<div class="flex gap-4 mt-2 text-xs">
<label class="flex items-center gap-1.5 text-gray-600 dark:text-gray-300 cursor-pointer">
<input type="checkbox" :checked="c.usar_whisper_audio" @change="toggleCanalWhisper(c)" class="rounded border-gray-300 dark:border-gray-700 text-brand focus:ring-brand" />
Transcribir audios (Whisper)
</label>
<label class="flex items-center gap-1.5 text-gray-600 dark:text-gray-300 cursor-pointer">
<input type="checkbox" :checked="c.usar_ocr_imagenes" @change="toggleCanalOcr(c)" class="rounded border-gray-300 dark:border-gray-700 text-brand focus:ring-brand" />
Leer texto de imágenes (OCR)
</label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 break-all">
Webhook: <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">{{ c.webhook_url }}</code>
</p>
@@ -542,6 +575,16 @@ onMounted(async () => {
<input v-model="canalForm.verify_token" required class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm" />
</div>
</template>
<div class="flex flex-col gap-2 pt-1">
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300 cursor-pointer">
<input type="checkbox" v-model="canalForm.usar_whisper_audio" class="rounded border-gray-300 dark:border-gray-700 text-brand focus:ring-brand" />
Transcribir audios con Whisper
</label>
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300 cursor-pointer">
<input type="checkbox" v-model="canalForm.usar_ocr_imagenes" class="rounded border-gray-300 dark:border-gray-700 text-brand focus:ring-brand" />
Leer texto de imágenes con OCR
</label>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400" @click="showCanalForm = false">Cancelar</button>
<button type="submit" class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg">Guardar</button>
+5
View File
@@ -28,6 +28,11 @@ type UmindCanal struct {
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"`
// Si están activos, los mensajes de voz/audio e imágenes que llegan por
// este canal se transcriben (Whisper) o se les extrae el texto (OCR)
// antes de pasarlos al agente, en vez de ignorarse.
UsarWhisperAudio bool `json:"usar_whisper_audio" gorm:"column:usar_whisper_audio;default:false"`
UsarOcrImagenes bool `json:"usar_ocr_imagenes" gorm:"column:usar_ocr_imagenes;default:false"`
}
func (UmindCanal) TableName() string { return "umind_canales" }
@@ -1,9 +1,12 @@
package services
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
@@ -37,6 +40,92 @@ func ProcesarMensajeTelegramUmind(canal *models.UmindCanal, chatID int64, texto
return (&TelegramService{}).SendMessageWithToken(chatID, respuesta, botToken)
}
// ProcesarMediaTelegramUmind atiende notas de voz/audio y fotos entrantes:
// si el canal tiene la conversión habilitada, descarga el archivo vía
// getFile, lo pasa por Whisper/OCR y responde igual que un mensaje de texto.
// Si no está habilitada, se ignora en silencio.
func ProcesarMediaTelegramUmind(canal *models.UmindCanal, chatID int64, fileID, tipo string) error {
agente, err := models.GetUmindAgenteByID(canal.AgenteID)
if err != nil || !agente.Activo {
return fmt.Errorf("agente no encontrado o inactivo: %w", err)
}
credenciales, err := DescifrarCredencialesCanal(canal.CredencialesEnc)
if err != nil {
return fmt.Errorf("credenciales del canal corruptas: %w", err)
}
botToken := credenciales["bot_token"]
if botToken == "" {
return fmt.Errorf("el canal no tiene bot_token configurado")
}
var texto string
switch tipo {
case "audio":
if !canal.UsarWhisperAudio {
return nil
}
data, err := descargarArchivoTelegram(botToken, fileID)
if err != nil {
return fmt.Errorf("no se pudo descargar el audio de Telegram: %w", err)
}
texto, err = TranscribirAudioSelfHosted(data, "audio.ogg")
if err != nil {
return err
}
case "image":
if !canal.UsarOcrImagenes {
return nil
}
data, err := descargarArchivoTelegram(botToken, fileID)
if err != nil {
return fmt.Errorf("no se pudo descargar la imagen de Telegram: %w", err)
}
texto, err = ExtraerTextoOCR(data, "image/jpeg")
if err != nil {
return err
}
default:
return nil
}
if strings.TrimSpace(texto) == "" {
return fmt.Errorf("no se pudo extraer texto del %s recibido", tipo)
}
sessionID := fmt.Sprintf("tg:%d", chatID)
respuesta, err := ProcessWidgetMessage(agente, sessionID, texto)
if err != nil {
return fmt.Errorf("error del agente: %w", err)
}
return (&TelegramService{}).SendMessageWithToken(chatID, respuesta, botToken)
}
// descargarArchivoTelegram resuelve el file_path de un file_id (getFile) y
// descarga el archivo desde el CDN de archivos de Telegram.
func descargarArchivoTelegram(botToken, fileID string) ([]byte, error) {
getFileURL := fmt.Sprintf("https://api.telegram.org/bot%s/getFile?file_id=%s", botToken, url.QueryEscape(fileID))
resp, err := telegramHTTPClient.Get(getFileURL)
if err != nil {
return nil, fmt.Errorf("no se pudo consultar getFile: %w", err)
}
defer resp.Body.Close()
var out struct {
Result struct {
FilePath string `json:"file_path"`
} `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Result.FilePath == "" {
return nil, fmt.Errorf("respuesta inesperada de getFile")
}
fileURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", botToken, out.Result.FilePath)
resp2, err := telegramHTTPClient.Get(fileURL)
if err != nil {
return nil, fmt.Errorf("no se pudo descargar el archivo: %w", err)
}
defer resp2.Body.Close()
return io.ReadAll(io.LimitReader(resp2.Body, 20*1024*1024))
}
// RegistrarWebhookTelegram le dice a Telegram a qué URL mandar los updates
// del bot — se llama una vez al crear el canal (o al reconfigurar el token).
func RegistrarWebhookTelegram(botToken, webhookURL string) error {
+101 -1
View File
@@ -7,6 +7,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
@@ -44,11 +45,67 @@ func ProcesarMensajeWhatsAppUmind(canal *models.UmindCanal, from, texto string)
if err != nil || !agente.Activo {
return fmt.Errorf("agente no encontrado o inactivo: %w", err)
}
credenciales, err := DescifrarCredencialesCanal(canal.CredencialesEnc)
if err != nil {
return fmt.Errorf("credenciales del canal corruptas: %w", err)
}
return responderWhatsApp(agente, credenciales, from, texto)
}
// ProcesarMediaWhatsAppUmind atiende audios/imágenes entrantes: si el canal
// tiene la conversión habilitada, descarga el archivo desde la Graph API,
// lo pasa por Whisper/OCR y sigue el mismo camino que un mensaje de texto.
// Si la conversión no está habilitada para ese tipo, se ignora en silencio
// (mismo comportamiento de antes de que existiera esta función).
func ProcesarMediaWhatsAppUmind(canal *models.UmindCanal, from, mediaID, tipo string) error {
agente, err := models.GetUmindAgenteByID(canal.AgenteID)
if err != nil || !agente.Activo {
return fmt.Errorf("agente no encontrado o inactivo: %w", err)
}
credenciales, err := DescifrarCredencialesCanal(canal.CredencialesEnc)
if err != nil {
return fmt.Errorf("credenciales del canal corruptas: %w", err)
}
var texto string
switch tipo {
case "audio":
if !canal.UsarWhisperAudio {
return nil
}
data, _, err := descargarMediaWhatsApp(credenciales["access_token"], mediaID)
if err != nil {
return fmt.Errorf("no se pudo descargar el audio de WhatsApp: %w", err)
}
texto, err = TranscribirAudioSelfHosted(data, "audio.ogg")
if err != nil {
return err
}
case "image":
if !canal.UsarOcrImagenes {
return nil
}
data, mimeType, err := descargarMediaWhatsApp(credenciales["access_token"], mediaID)
if err != nil {
return fmt.Errorf("no se pudo descargar la imagen de WhatsApp: %w", err)
}
if mimeType == "" {
mimeType = "image/jpeg"
}
texto, err = ExtraerTextoOCR(data, mimeType)
if err != nil {
return err
}
default:
return nil
}
if strings.TrimSpace(texto) == "" {
return fmt.Errorf("no se pudo extraer texto del %s recibido", tipo)
}
return responderWhatsApp(agente, credenciales, from, texto)
}
func responderWhatsApp(agente *models.UmindAgente, credenciales map[string]string, from, texto string) error {
phoneNumberID := credenciales["phone_number_id"]
accessToken := credenciales["access_token"]
if phoneNumberID == "" || accessToken == "" {
@@ -64,6 +121,49 @@ func ProcesarMensajeWhatsAppUmind(canal *models.UmindCanal, from, texto string)
return enviarMensajeWhatsApp(phoneNumberID, accessToken, from, respuesta)
}
// descargarMediaWhatsApp resuelve la URL temporal de un media_id (paso 1) y
// descarga el archivo (paso 2) — la Graph API de WhatsApp requiere ambos, y
// las dos llamadas necesitan el mismo Bearer token del canal.
func descargarMediaWhatsApp(accessToken, mediaID string) ([]byte, string, error) {
if accessToken == "" || mediaID == "" {
return nil, "", fmt.Errorf("access_token/media_id vacíos")
}
metaURL := fmt.Sprintf("https://graph.facebook.com/%s/%s", whatsappGraphAPIVersion, mediaID)
req, err := http.NewRequest(http.MethodGet, metaURL, nil)
if err != nil {
return nil, "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := umindWhatsappHTTPClient.Do(req)
if err != nil {
return nil, "", fmt.Errorf("no se pudo consultar el media en WhatsApp: %w", err)
}
defer resp.Body.Close()
var meta struct {
URL string `json:"url"`
MimeType string `json:"mime_type"`
}
if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil || meta.URL == "" {
return nil, "", fmt.Errorf("respuesta inesperada al consultar el media de WhatsApp")
}
req2, err := http.NewRequest(http.MethodGet, meta.URL, nil)
if err != nil {
return nil, "", err
}
req2.Header.Set("Authorization", "Bearer "+accessToken)
resp2, err := umindWhatsappHTTPClient.Do(req2)
if err != nil {
return nil, "", fmt.Errorf("no se pudo descargar el archivo de WhatsApp: %w", err)
}
defer resp2.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp2.Body, 20*1024*1024))
if err != nil {
return nil, "", err
}
return data, meta.MimeType, nil
}
func enviarMensajeWhatsApp(phoneNumberID, accessToken, to, texto string) error {
payload := map[string]interface{}{
"messaging_product": "whatsapp",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>uMind — Orquestador</title>
<script type="module" crossorigin src="/orchestrator/assets/index-CUIRjjam.js"></script>
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-CntnaNBS.css">
<script type="module" crossorigin src="/orchestrator/assets/index-D1KpwOlP.js"></script>
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-a_0kTstc.css">
</head>
<body class="bg-gray-50">
<div id="app"></div>
@@ -15,9 +15,16 @@ type umindTgChat struct {
ID int64 `json:"id"`
}
type umindTgFileRef struct {
FileID string `json:"file_id"`
}
type umindTgMessage struct {
Chat umindTgChat `json:"chat"`
Text string `json:"text"`
Chat umindTgChat `json:"chat"`
Text string `json:"text"`
Voice *umindTgFileRef `json:"voice"`
Audio *umindTgFileRef `json:"audio"`
Photo []umindTgFileRef `json:"photo"`
}
type umindTgUpdate struct {
@@ -39,12 +46,23 @@ func UmindTelegramWebhook(c *fiber.Ctx) error {
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)
}
msg := update.Message
if err := services.ProcesarMensajeTelegramUmind(canal, update.Message.Chat.ID, texto); err != nil {
switch {
case msg.Voice != nil && msg.Voice.FileID != "":
err = services.ProcesarMediaTelegramUmind(canal, msg.Chat.ID, msg.Voice.FileID, "audio")
case msg.Audio != nil && msg.Audio.FileID != "":
err = services.ProcesarMediaTelegramUmind(canal, msg.Chat.ID, msg.Audio.FileID, "audio")
case len(msg.Photo) > 0:
err = services.ProcesarMediaTelegramUmind(canal, msg.Chat.ID, msg.Photo[len(msg.Photo)-1].FileID, "image")
default:
texto := strings.TrimSpace(msg.Text)
if texto == "" {
return c.SendStatus(fiber.StatusOK)
}
err = services.ProcesarMensajeTelegramUmind(canal, msg.Chat.ID, texto)
}
if err != nil {
log.Printf("[UMIND_TELEGRAM] canal %d: %v", canal.ID, err)
models.RegistrarEventoUmind(canal.AgenteID, "error", "canal_telegram", "Error procesando un mensaje de Telegram", err.Error())
}
@@ -53,12 +71,18 @@ func UmindTelegramWebhook(c *fiber.Ctx) error {
// ─── WhatsApp ────────────────────────────────────────────────────────────────
type umindWaMediaRef struct {
ID string `json:"id"`
}
type umindWaMessage struct {
From string `json:"from"`
Type string `json:"type"`
Text struct {
Body string `json:"body"`
} `json:"text"`
Image umindWaMediaRef `json:"image"`
Audio umindWaMediaRef `json:"audio"`
}
type umindWaValue struct {
@@ -130,10 +154,28 @@ func UmindWhatsAppWebhook(c *fiber.Ctx) error {
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) == "" {
var err error
switch msg.Type {
case "text":
texto := strings.TrimSpace(msg.Text.Body)
if texto == "" {
continue
}
err = services.ProcesarMensajeWhatsAppUmind(canal, msg.From, texto)
case "audio":
if msg.Audio.ID == "" {
continue
}
err = services.ProcesarMediaWhatsAppUmind(canal, msg.From, msg.Audio.ID, "audio")
case "image":
if msg.Image.ID == "" {
continue
}
err = services.ProcesarMediaWhatsAppUmind(canal, msg.From, msg.Image.ID, "image")
default:
continue
}
if err := services.ProcesarMensajeWhatsAppUmind(canal, msg.From, strings.TrimSpace(msg.Text.Body)); err != nil {
if err != nil {
log.Printf("[UMIND_WHATSAPP] canal %d: %v", canal.ID, err)
models.RegistrarEventoUmind(canal.AgenteID, "error", "canal_whatsapp", "Error procesando un mensaje de WhatsApp", err.Error())
}
+12 -6
View File
@@ -463,16 +463,19 @@ func GetUmindCanalesHandler(c *fiber.Ctx) error {
out[i] = fiber.Map{
"ID": canal.ID, "agente_id": canal.AgenteID, "tipo": canal.Tipo, "activo": canal.Activo,
"webhook_url": webhookURL, "ultimo_error": canal.UltimoError,
"usar_whisper_audio": canal.UsarWhisperAudio, "usar_ocr_imagenes": canal.UsarOcrImagenes,
}
}
return c.JSON(fiber.Map{"items": out})
}
type umindCanalReq struct {
AgenteID uint `json:"agente_id"`
Tipo string `json:"tipo"`
Credenciales map[string]string `json:"credenciales"`
Activo bool `json:"activo"`
AgenteID uint `json:"agente_id"`
Tipo string `json:"tipo"`
Credenciales map[string]string `json:"credenciales"`
Activo bool `json:"activo"`
UsarWhisperAudio bool `json:"usar_whisper_audio"`
UsarOcrImagenes bool `json:"usar_ocr_imagenes"`
}
func CreateUmindCanalHandler(c *fiber.Ctx) error {
@@ -502,7 +505,10 @@ func CreateUmindCanalHandler(c *fiber.Ctx) error {
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
canal := &models.UmindCanal{AgenteID: req.AgenteID, Tipo: req.Tipo, Activo: true, CredencialesEnc: credencialesEnc}
canal := &models.UmindCanal{
AgenteID: req.AgenteID, Tipo: req.Tipo, Activo: true, CredencialesEnc: credencialesEnc,
UsarWhisperAudio: req.UsarWhisperAudio, UsarOcrImagenes: req.UsarOcrImagenes,
}
if err := models.CreateUmindCanal(canal); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
@@ -525,7 +531,7 @@ func UpdateUmindCanalHandler(c *fiber.Ctx) error {
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}
updates := map[string]interface{}{"activo": req.Activo, "usar_whisper_audio": req.UsarWhisperAudio, "usar_ocr_imagenes": req.UsarOcrImagenes}
if len(req.Credenciales) > 0 {
enc, err := services.CifrarCredencialesCanal(req.Credenciales)
if err != nil {