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}) }