package services import ( "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strings" "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 } // PeekFacturaAttachment consulta el archivo pendiente de un chat sin removerlo // (se usa para leer su contenido y pasárselo al modelo antes de que la tool // adjuntar_factura lo consuma de verdad). func PeekFacturaAttachment(chatID int64) (*FacturaAttachment, bool) { facturaAttachmentsMu.Lock() defer facturaAttachmentsMu.Unlock() a, ok := facturaAttachments[chatID] if !ok || time.Since(a.StagedAt) > facturaAttachmentTTL { return nil, false } return a, true } // ResolverMimeType usa el mime_type que mandó Telegram si vino, y si no, // adivina por la extensión del archivo (Claude solo acepta ciertos tipos). func ResolverMimeType(mimeType, filename string) string { if mimeType != "" { return mimeType } switch strings.ToLower(filepath.Ext(filename)) { case ".pdf": return "application/pdf" case ".jpg", ".jpeg": return "image/jpeg" case ".png": return "image/png" case ".webp": return "image/webp" case ".gif": return "image/gif" default: return "application/octet-stream" } }