diff --git a/pkg/services/factura_attachment_service.go b/pkg/services/factura_attachment_service.go new file mode 100644 index 0000000..9950876 --- /dev/null +++ b/pkg/services/factura_attachment_service.go @@ -0,0 +1,102 @@ +package services + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sync" + "time" +) + +// FacturaAttachment es un archivo que un usuario envió por Telegram y que queda +// "pendiente" hasta que el agente lo asocie a una factura (o expire). +type FacturaAttachment struct { + Path string + OriginalName string + MimeType string + StagedAt time.Time +} + +const facturaAttachmentTTL = 15 * time.Minute + +var ( + facturaAttachmentsMu sync.Mutex + facturaAttachments = map[int64]*FacturaAttachment{} +) + +// DescargarDocumentoTelegram descarga un archivo de Telegram (por file_id) usando +// la API getFile + el endpoint de descarga, y lo deja en un directorio temporal. +func DescargarDocumentoTelegram(botToken, fileID, originalName, mimeType string) (string, error) { + getFileURL := fmt.Sprintf("https://api.telegram.org/bot%s/getFile?file_id=%s", botToken, fileID) + resp, err := http.Get(getFileURL) //nolint:noctx + if err != nil { + return "", fmt.Errorf("no se pudo consultar el archivo en Telegram: %w", err) + } + defer resp.Body.Close() + + var result struct { + OK bool `json:"ok"` + Result struct { + FilePath string `json:"file_path"` + } `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || !result.OK || result.Result.FilePath == "" { + return "", fmt.Errorf("Telegram no devolvió la ruta del archivo") + } + + downloadURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", botToken, result.Result.FilePath) + fresp, err := http.Get(downloadURL) //nolint:noctx + if err != nil { + return "", fmt.Errorf("no se pudo descargar el archivo: %w", err) + } + defer fresp.Body.Close() + + dir := "uploads/facturas/tmp" + if err := os.MkdirAll(dir, 0755); err != nil { + return "", fmt.Errorf("no se pudo crear el directorio temporal: %w", err) + } + ext := filepath.Ext(originalName) + if ext == "" { + ext = filepath.Ext(result.Result.FilePath) + } + localPath := filepath.Join(dir, fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)) + out, err := os.Create(localPath) + if err != nil { + return "", fmt.Errorf("no se pudo guardar el archivo: %w", err) + } + defer out.Close() + if _, err := io.Copy(out, fresp.Body); err != nil { + return "", fmt.Errorf("error escribiendo el archivo: %w", err) + } + return localPath, nil +} + +// StageFacturaAttachment registra un archivo ya descargado como pendiente para el +// chat, a la espera de que el agente lo asocie a una factura con la tool adjuntar_factura. +func StageFacturaAttachment(chatID int64, path, originalName, mimeType string) { + facturaAttachmentsMu.Lock() + defer facturaAttachmentsMu.Unlock() + facturaAttachments[chatID] = &FacturaAttachment{ + Path: path, OriginalName: originalName, MimeType: mimeType, StagedAt: time.Now(), + } +} + +// PopFacturaAttachment retorna y remueve el archivo pendiente de un chat, si +// existe y no expiró (si expiró, borra el archivo temporal del disco). +func PopFacturaAttachment(chatID int64) (*FacturaAttachment, bool) { + facturaAttachmentsMu.Lock() + defer facturaAttachmentsMu.Unlock() + a, ok := facturaAttachments[chatID] + if !ok { + return nil, false + } + delete(facturaAttachments, chatID) + if time.Since(a.StagedAt) > facturaAttachmentTTL { + _ = os.Remove(a.Path) + return nil, false + } + return a, true +} diff --git a/pkg/services/telegram_agent_service.go b/pkg/services/telegram_agent_service.go index 47042bb..0d9e8ca 100644 --- a/pkg/services/telegram_agent_service.go +++ b/pkg/services/telegram_agent_service.go @@ -7,6 +7,8 @@ import ( "io" "log" "net/http" + "os" + "path/filepath" "strings" "time" @@ -137,6 +139,13 @@ func agentTools() []agentTool { "search": str("Búsqueda por número, cliente o descripción"), "estado": agentToolParam{Type: "string", Description: "pendiente | pagada | vencida | cancelada", Enum: []string{"pendiente", "pagada", "vencida", "cancelada", ""}}, }, nil)), + tool("adjuntar_factura", "Guarda como factura el documento (PDF/foto) que el usuario acaba de enviar por Telegram en este chat. Solo funciona si hay un archivo adjunto pendiente; si no lo hay, pide al usuario que lo reenvíe.", + obj(map[string]agentToolParam{ + "cliente_id": num("ID del cliente al que pertenece la factura (usa listar_clientes si no lo sabes)"), + "numero": str("Número de factura, si el usuario lo indicó"), + "monto": num("Monto de la factura, si el usuario lo indicó"), + "descripcion": str("Descripción breve, ej: 'Factura mensual octubre'"), + }, []string{"cliente_id"})), // ── Clientes ───────────────────────────────────────────────────────── tool("listar_clientes", "Lista clientes con paginación y búsqueda.", @@ -312,8 +321,8 @@ func agentTools() []agentTool { // ─── Ejecución de herramientas ──────────────────────────────────────────────── -func executeAgentTool(name string, args map[string]interface{}) string { - result, err := runTool(name, args) +func executeAgentTool(chatID int64, name string, args map[string]interface{}) string { + result, err := runTool(chatID, name, args) if err != nil { return fmt.Sprintf(`{"error": %q}`, err.Error()) } @@ -321,7 +330,7 @@ func executeAgentTool(name string, args map[string]interface{}) string { return string(b) } -func runTool(name string, a map[string]interface{}) (interface{}, error) { +func runTool(chatID int64, name string, a map[string]interface{}) (interface{}, error) { getInt := func(key string, def int) int { if v, ok := a[key]; ok { switch x := v.(type) { @@ -456,6 +465,42 @@ func runTool(name string, a map[string]interface{}) (interface{}, error) { } return map[string]interface{}{"items": items, "total": total, "page": page}, nil + case "adjuntar_factura": + att, ok := PopFacturaAttachment(chatID) + if !ok { + return nil, fmt.Errorf("no hay ningún documento pendiente en este chat; pide al usuario que lo envíe de nuevo") + } + clienteID := uint(getInt("cliente_id", 0)) + if clienteID == 0 { + return nil, fmt.Errorf("cliente_id requerido") + } + f := &models.Factura{ + ClienteID: clienteID, + Numero: getStr("numero"), + Descripcion: getStr("descripcion"), + Monto: float64(getInt("monto", 0)), + Estado: "pendiente", + FechaEmision: time.Now(), + Visible: true, + } + if err := models.CreateFactura(f); err != nil { + _ = os.Remove(att.Path) + return nil, err + } + finalDir := "uploads/facturas" + if err := os.MkdirAll(finalDir, 0755); err != nil { + return nil, err + } + ext := filepath.Ext(att.OriginalName) + finalPath := filepath.Join(finalDir, fmt.Sprintf("%d_telegram%s", f.ID, ext)) + if err := os.Rename(att.Path, finalPath); err != nil { + return nil, fmt.Errorf("no se pudo guardar el archivo: %w", err) + } + if err := models.UpdateFacturaArchivo(f.ID, finalPath, att.OriginalName); err != nil { + return nil, err + } + return map[string]interface{}{"ok": true, "factura_id": f.ID}, nil + case "listar_clientes": page := getInt("page", 1) search := getStr("search") @@ -1192,6 +1237,7 @@ COMPORTAMIENTO: - Los valores monetarios son en COP (pesos colombianos) - Si una herramienta falla, explica el error y sugiere alternativas - Para acciones en Coolify, primero usa coolify_instancias para saber qué IDs hay disponibles si el usuario no lo especifica +- Cuando el mensaje empiece con "[Documento adjunto recibido: ...]", el usuario acaba de enviar un PDF o foto por Telegram. Identifica el cliente (usa listar_clientes si hace falta) y el monto de la factura a partir de lo que escribió; si falta alguno de los dos, pregúntalo antes de guardar. Cuando tengas cliente y monto, usa la tool adjuntar_factura para guardarlo — no la uses si el mensaje no menciona ningún documento adjunto COMANDOS ESPECIALES (el usuario puede escribirlos): - /reset — olvidar el historial de esta conversación @@ -1296,7 +1342,7 @@ Comandos: /reset /instancias /ayuda`, nil _ = json.Unmarshal([]byte(tc.Function.Arguments), &toolArgs) log.Printf("[AGENT] Ejecutando tool: %s args: %s", tc.Function.Name, tc.Function.Arguments) - toolResult := executeAgentTool(tc.Function.Name, toolArgs) + toolResult := executeAgentTool(chatID, tc.Function.Name, toolArgs) // Agregar resultado al contexto (solo en memoria, no en BD) messages = append(messages, agentMessage{ diff --git a/rest/controllers/telegram_agent_controller.go b/rest/controllers/telegram_agent_controller.go index ae338da..073a182 100644 --- a/rest/controllers/telegram_agent_controller.go +++ b/rest/controllers/telegram_agent_controller.go @@ -25,11 +25,26 @@ type tgChat struct { 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 tgMessage struct { - MessageID int `json:"message_id"` - From tgUser `json:"from"` - Chat tgChat `json:"chat"` - Text string `json:"text"` + 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"` } type tgUpdate struct { @@ -53,13 +68,18 @@ func TelegramAgentWebhook(c *fiber.Ctx) error { if err := c.BodyParser(&update); err != nil { return c.SendStatus(200) // siempre 200 a Telegram } - if update.Message == nil || strings.TrimSpace(update.Message.Text) == "" { + 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 + if text == "" && caption == "" && !hasAttachment { + return c.SendStatus(200) + } // Buscar el config del agente que tenga este bot token ai, tgCfg, err := models.GetAgenteBotConfig() @@ -86,6 +106,45 @@ func TelegramAgentWebhook(c *fiber.Ctx) error { 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.StageFacturaAttachment(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 + } + } + } + // Procesar en goroutine para responder 200 inmediatamente a Telegram go func() { response, err := services.ProcessAgentMessage(chatID, text, ai)