Seguridad (crítico): - Los webhooks de Bold y dLocal solo validaban la firma si el atacante la enviaba: sin cabecera se aceptaba cualquier payload. Ahora es obligatoria. - GET /pago-exitoso marcaba contratos como pagados leyendo un query param del navegador. Ahora solo muestra estado; la confirmación la hace la verificación contra la API de la pasarela o el webhook firmado. - /uploads se servía como estático público: se descargaban RUTs, facturas y entregables sabiendo la ruta. Ahora exige sesión. - Los secretos JWT no se podían sobreescribir por entorno (faltaba el tag env:) y su valor estaba en el repo, permitiendo firmarse una sesión de admin. Ahora son configurables y el arranque se detiene si siguen con el valor publicado. - .env y session.db salen del control de versiones. - Query Runner, gestión de usuarios/roles/módulos y seeds quedan restringidos a administradores; antes bastaba con tener sesión. Pasarelas de pago: - dLocal generaba enlaces que nunca se reconciliaban: mandaba el ID numérico en vez de "contrato-N", la URL de retorno apuntaba a la API de dLocal y nunca se enviaba notification_url, así que su webhook jamás se disparaba. - PayPal solo tenía pantalla de configuración. Se implementa el servicio completo (OAuth, orden, captura, verificación de webhook) y queda seleccionable como pasarela. - La moneda estaba fija en COP: un contrato en USD generaba un cobro por esa cifra en pesos. Contratos: - pago_confirmado nunca volvía a false, así que el segundo ciclo de renovación no se cobraba aunque el cliente pagara. Se reinicia al generar enlace nuevo. - Los contratos vencidos nunca cambiaban de estado y recibían correo a diario de forma indefinida; ahora se cierran tras 30 días de gracia. Otros: - Coolify: coolifyCall ignoraba el status HTTP y reportaba errores como éxito. El agente pasa de 10 a cobertura completa (servicios, bases de datos, variables de entorno, proyectos, equipos y recursos de servidor). - SeedBalanceData ya no corre en cada arranque (recreaba transacciones borradas); ahora se invoca con SEED_BALANCE=1. - Los seeds dejan de devolver permisos revocados en cada despliegue. - Timeouts en las llamadas HTTP a Telegram y dLocal que podían colgarse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
144 lines
4.4 KiB
Go
144 lines
4.4 KiB
Go
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"
|
|
}
|
|
}
|