package services import ( "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strings" "sync" "time" ) // TelegramAttachment es un archivo que un usuario envió por Telegram y que queda // "pendiente" hasta que el agente lo asocie a una factura o a un documento de // proyecto (o expire). type TelegramAttachment struct { Path string OriginalName string MimeType string StagedAt time.Time } // adjuntoHTTPClient limita cuánto se espera al descargar un archivo de Telegram. var adjuntoHTTPClient = &http.Client{Timeout: 60 * time.Second} const telegramAttachmentTTL = 15 * time.Minute var ( telegramAttachmentsMu sync.Mutex telegramAttachments = map[int64]*TelegramAttachment{} ) // 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 := adjuntoHTTPClient.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 := adjuntoHTTPClient.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 } // StageTelegramAttachment registra un archivo ya descargado como pendiente para el // chat, a la espera de que el agente lo asocie a algo (adjuntar_factura o // adjuntar_documento_proyecto). func StageTelegramAttachment(chatID int64, path, originalName, mimeType string) { telegramAttachmentsMu.Lock() defer telegramAttachmentsMu.Unlock() telegramAttachments[chatID] = &TelegramAttachment{ Path: path, OriginalName: originalName, MimeType: mimeType, StagedAt: time.Now(), } } // PopTelegramAttachment retorna y remueve el archivo pendiente de un chat, si // existe y no expiró (si expiró, borra el archivo temporal del disco). func PopTelegramAttachment(chatID int64) (*TelegramAttachment, bool) { telegramAttachmentsMu.Lock() defer telegramAttachmentsMu.Unlock() a, ok := telegramAttachments[chatID] if !ok { return nil, false } delete(telegramAttachments, chatID) if time.Since(a.StagedAt) > telegramAttachmentTTL { _ = os.Remove(a.Path) return nil, false } return a, true } // PeekTelegramAttachment 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 PeekTelegramAttachment(chatID int64) (*TelegramAttachment, bool) { telegramAttachmentsMu.Lock() defer telegramAttachmentsMu.Unlock() a, ok := telegramAttachments[chatID] if !ok || time.Since(a.StagedAt) > telegramAttachmentTTL { 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" } } // esMimePrevisualizablePorClaude indica si la API de Claude puede recibir ese // tipo de archivo como contenido visible (bloque "image" o "document"). Un // tipo fuera de esta lista (docx, xlsx, zip, etc.) mandado igual como bloque // "image" hace que Anthropic responda 400 y tumbe la respuesta del agente. func esMimePrevisualizablePorClaude(mime string) bool { switch mime { case "application/pdf", "image/jpeg", "image/png", "image/webp", "image/gif": return true default: return false } }