- Nueva config "whisper" reutilizando /app/ai-config (credenciales/endpoint
validables) y servicio de transcripción compatible con OpenAI/Groq.
- El webhook de Telegram ahora detecta notas de voz/audio, las transcribe
y procesa el texto resultante como si el usuario lo hubiera escrito.
- Fix: subir un archivo adjunto en un comentario de tarea sin escribir
texto era rechazado por el backend ("contenido requerido") aunque el
frontend sí lo permitía.
316 lines
10 KiB
Go
316 lines
10 KiB
Go
package controllers
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
|
)
|
|
|
|
// ─── Tipos del update de Telegram ────────────────────────────────────────────
|
|
|
|
type tgUser struct {
|
|
ID int64 `json:"id"`
|
|
FirstName string `json:"first_name"`
|
|
LastName string `json:"last_name"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
type tgChat struct {
|
|
ID int64 `json:"id"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
type tgDocument struct {
|
|
FileID string `json:"file_id"`
|
|
FileName string `json:"file_name"`
|
|
MimeType string `json:"mime_type"`
|
|
}
|
|
|
|
type tgPhotoSize struct {
|
|
FileID string `json:"file_id"`
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
}
|
|
|
|
type tgVoice struct {
|
|
FileID string `json:"file_id"`
|
|
Duration int `json:"duration"`
|
|
MimeType string `json:"mime_type"`
|
|
}
|
|
|
|
type tgMessage struct {
|
|
MessageID int `json:"message_id"`
|
|
From tgUser `json:"from"`
|
|
Chat tgChat `json:"chat"`
|
|
Text string `json:"text"`
|
|
Caption string `json:"caption"`
|
|
Document *tgDocument `json:"document"`
|
|
Photo []tgPhotoSize `json:"photo"`
|
|
Voice *tgVoice `json:"voice"`
|
|
Audio *tgVoice `json:"audio"`
|
|
}
|
|
|
|
type tgUpdate struct {
|
|
UpdateID int `json:"update_id"`
|
|
Message *tgMessage `json:"message"`
|
|
}
|
|
|
|
// ─── Webhook handler ──────────────────────────────────────────────────────────
|
|
|
|
// TelegramAgentWebhook recibe updates del bot administrador.
|
|
// Telegram llama aquí cuando alguien escribe al bot.
|
|
// Ruta: POST /webhooks/telegram-agent/:bot_token
|
|
func TelegramAgentWebhook(c *fiber.Ctx) error {
|
|
botToken := c.Params("bot_token")
|
|
if botToken == "" {
|
|
return c.SendStatus(400)
|
|
}
|
|
|
|
// Parsear el update
|
|
var update tgUpdate
|
|
if err := c.BodyParser(&update); err != nil {
|
|
return c.SendStatus(200) // siempre 200 a Telegram
|
|
}
|
|
if update.Message == nil {
|
|
return c.SendStatus(200)
|
|
}
|
|
|
|
msg := update.Message
|
|
chatID := msg.Chat.ID
|
|
text := strings.TrimSpace(msg.Text)
|
|
caption := strings.TrimSpace(msg.Caption)
|
|
hasAttachment := msg.Document != nil || len(msg.Photo) > 0
|
|
hasAudio := msg.Voice != nil || msg.Audio != nil
|
|
if text == "" && caption == "" && !hasAttachment && !hasAudio {
|
|
return c.SendStatus(200)
|
|
}
|
|
|
|
// Buscar el config del agente que tenga este bot token
|
|
ai, tgCfg, err := models.GetAgenteBotConfig()
|
|
if err != nil || tgCfg == nil || tgCfg.BotToken != botToken {
|
|
log.Printf("[AGENT_WEBHOOK] Token no corresponde a ningún agente activo")
|
|
return c.SendStatus(200)
|
|
}
|
|
|
|
// Verificar autorización del chat (si hay whitelist configurada)
|
|
if !models.IsAgentAuthChat(chatID) {
|
|
// Si no hay ningún auth configurado, solo responder al chat_id del config
|
|
tgChatIDStr := strings.TrimSpace(tgCfg.ChatID)
|
|
tgChatID, _ := strconv.ParseInt(tgChatIDStr, 10, 64)
|
|
if tgChatID != 0 && chatID != tgChatID {
|
|
sendAgentReply(tgCfg.BotToken, chatID, "No tienes autorización para usar este agente.")
|
|
return c.SendStatus(200)
|
|
}
|
|
}
|
|
|
|
// Comandos de control (sincrónicos, respuesta inmediata)
|
|
if text == "/reset" || text == "/limpiar" || text == "/start" {
|
|
_ = models.ClearAgentHistory(chatID)
|
|
sendAgentReply(tgCfg.BotToken, chatID, "Historial borrado. Empezamos de cero.")
|
|
return c.SendStatus(200)
|
|
}
|
|
|
|
// Documento o foto adjunto: se descarga y queda "pendiente" para que el
|
|
// agente lo asocie a una factura (tool adjuntar_factura) según lo que diga
|
|
// el caption o lo que responda el usuario a continuación.
|
|
if hasAttachment {
|
|
fileID, fileName, mimeType := "", "documento", ""
|
|
switch {
|
|
case msg.Document != nil:
|
|
fileID = msg.Document.FileID
|
|
if msg.Document.FileName != "" {
|
|
fileName = msg.Document.FileName
|
|
}
|
|
mimeType = msg.Document.MimeType
|
|
case len(msg.Photo) > 0:
|
|
// Telegram manda varias resoluciones de la misma foto; la última es la de mayor calidad.
|
|
best := msg.Photo[len(msg.Photo)-1]
|
|
fileID = best.FileID
|
|
fileName = "foto.jpg"
|
|
mimeType = "image/jpeg"
|
|
}
|
|
if fileID != "" {
|
|
path, dlErr := services.DescargarDocumentoTelegram(tgCfg.BotToken, fileID, fileName, mimeType)
|
|
if dlErr != nil {
|
|
log.Printf("[AGENT_WEBHOOK] Error descargando adjunto: %v", dlErr)
|
|
sendAgentReply(tgCfg.BotToken, chatID, "No pude descargar el archivo que enviaste, intenta de nuevo.")
|
|
return c.SendStatus(200)
|
|
}
|
|
services.StageTelegramAttachment(chatID, path, fileName, mimeType)
|
|
nota := fmt.Sprintf("[Documento adjunto recibido: %s]", fileName)
|
|
switch {
|
|
case caption != "":
|
|
text = nota + " " + caption
|
|
case text == "":
|
|
text = nota + " ¿A qué cliente corresponde y cuál es el monto de la factura?"
|
|
default:
|
|
text = nota + " " + text
|
|
}
|
|
}
|
|
}
|
|
|
|
// Nota de voz o audio: se descarga, se transcribe con Whisper (config del
|
|
// módulo "whisper" en /app/ai-config) y el texto resultante se procesa como
|
|
// si el usuario lo hubiera escrito.
|
|
if hasAudio {
|
|
voice := msg.Voice
|
|
if voice == nil {
|
|
voice = msg.Audio
|
|
}
|
|
fileName := "audio.ogg"
|
|
mimeType := voice.MimeType
|
|
path, dlErr := services.DescargarDocumentoTelegram(tgCfg.BotToken, voice.FileID, fileName, mimeType)
|
|
if dlErr != nil {
|
|
log.Printf("[AGENT_WEBHOOK] Error descargando audio: %v", dlErr)
|
|
sendAgentReply(tgCfg.BotToken, chatID, "No pude descargar el audio que enviaste, intenta de nuevo.")
|
|
return c.SendStatus(200)
|
|
}
|
|
defer os.Remove(path)
|
|
|
|
whisperCfg, wErr := models.GetWhisperConfig()
|
|
if wErr != nil {
|
|
log.Printf("[AGENT_WEBHOOK] Whisper no configurado: %v", wErr)
|
|
sendAgentReply(tgCfg.BotToken, chatID, "No pude transcribir el audio: no hay ninguna configuración de Whisper activa. Ve a /app/ai-config, crea o edita una config con módulo 'Transcripción de audio (Whisper)' y credenciales válidas de OpenAI o Groq.")
|
|
return c.SendStatus(200)
|
|
}
|
|
|
|
transcripcion, tErr := services.TranscribirAudio(whisperCfg, path)
|
|
if tErr != nil {
|
|
log.Printf("[AGENT_WEBHOOK] Error transcribiendo audio: %v", tErr)
|
|
sendAgentReply(tgCfg.BotToken, chatID, fmt.Sprintf("No pude transcribir el audio: %s", tErr.Error()))
|
|
return c.SendStatus(200)
|
|
}
|
|
log.Printf("[AGENT_WEBHOOK] Audio transcrito (chat %d): %s", chatID, transcripcion)
|
|
|
|
if caption != "" {
|
|
text = transcripcion + " " + caption
|
|
} else {
|
|
text = transcripcion
|
|
}
|
|
}
|
|
|
|
// Procesar en goroutine para responder 200 inmediatamente a Telegram
|
|
go func() {
|
|
response, err := services.ProcessAgentMessage(chatID, text, ai)
|
|
if err != nil {
|
|
log.Printf("[AGENT] Error procesando mensaje: %v", err)
|
|
response = fmt.Sprintf("Error interno: %s", err.Error())
|
|
}
|
|
if response == "" {
|
|
return
|
|
}
|
|
if sendErr := sendAgentReply(tgCfg.BotToken, chatID, response); sendErr != nil {
|
|
log.Printf("[AGENT] Error enviando respuesta: %v", sendErr)
|
|
}
|
|
}()
|
|
|
|
return c.SendStatus(200)
|
|
}
|
|
|
|
func sendAgentReply(botToken string, chatID int64, text string) error {
|
|
svc := &services.TelegramService{BotToken: botToken}
|
|
return svc.SendMessage(chatID, services.FormatearParaTelegram(text))
|
|
}
|
|
|
|
// ─── CRUD de chats autorizados ────────────────────────────────────────────────
|
|
|
|
// AgentAuthView renderiza la pantalla de administración de chats autorizados.
|
|
func AgentAuthView(c *fiber.Ctx) error {
|
|
return c.Render("automatizacion/chats_autorizados", fiber.Map{
|
|
"user": c.Locals("user"),
|
|
"modules": c.Locals("userModules"),
|
|
}, "layouts/main")
|
|
}
|
|
|
|
// AgentAuthRecientes consulta los últimos mensajes recibidos por el bot del
|
|
// agente (getUpdates) y devuelve los remitentes distintos que todavía no están
|
|
// autorizados, para que el admin pueda autorizarlos con un clic sin tener que
|
|
// buscar el chat_id a mano.
|
|
func AgentAuthRecientes(c *fiber.Ctx) error {
|
|
tgCfg, err := models.GetAgentTelegramConfig()
|
|
if err != nil || tgCfg.BotToken == "" {
|
|
return c.Status(400).JSON(fiber.Map{"error": "No hay un bot de Telegram configurado para el agente"})
|
|
}
|
|
autorizados, _ := models.GetAllAgentAuth()
|
|
yaAutorizado := map[int64]bool{}
|
|
for _, a := range autorizados {
|
|
yaAutorizado[a.ChatID] = true
|
|
}
|
|
|
|
remitentes, err := services.UpdatesRecientesDelBot(tgCfg.BotToken)
|
|
if err != nil {
|
|
return c.Status(502).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
type item struct {
|
|
ChatID int64 `json:"chat_id"`
|
|
Nombre string `json:"nombre"`
|
|
Mensaje string `json:"mensaje"`
|
|
}
|
|
seen := map[int64]bool{}
|
|
out := make([]item, 0, len(remitentes))
|
|
for _, r := range remitentes {
|
|
if seen[r.ChatID] || yaAutorizado[r.ChatID] {
|
|
continue
|
|
}
|
|
seen[r.ChatID] = true
|
|
out = append(out, item{ChatID: r.ChatID, Nombre: r.Nombre, Mensaje: r.Mensaje})
|
|
}
|
|
return c.JSON(fiber.Map{"items": out})
|
|
}
|
|
|
|
func AgentAuthList(c *fiber.Ctx) error {
|
|
items, err := models.GetAllAgentAuth()
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"items": items})
|
|
}
|
|
|
|
func AgentAuthCreate(c *fiber.Ctx) error {
|
|
type Req struct {
|
|
ChatID int64 `json:"chat_id"`
|
|
Nombre string `json:"nombre"`
|
|
}
|
|
var req Req
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
if req.ChatID == 0 {
|
|
return c.Status(400).JSON(fiber.Map{"error": "chat_id requerido"})
|
|
}
|
|
if err := models.CreateAgentAuth(req.ChatID, req.Nombre); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.Status(201).JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
func AgentAuthDelete(c *fiber.Ctx) error {
|
|
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
|
|
if err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "id inválido"})
|
|
}
|
|
if err := models.DeleteAgentAuth(uint(id)); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
// AgentHistoryClear borra el historial de conversación de un chat.
|
|
func AgentHistoryClear(c *fiber.Ctx) error {
|
|
chatID, err := strconv.ParseInt(c.Params("chat_id"), 10, 64)
|
|
if err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "chat_id inválido"})
|
|
}
|
|
if err := models.ClearAgentHistory(chatID); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|