fix: seguridad de pagos y accesos, integración PayPal y Coolify ampliado

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>
This commit is contained in:
Lizandro GD
2026-08-03 03:20:00 +00:00
co-authored by Claude Opus 5
parent 5607836261
commit c8e5afceb2
26 changed files with 1163 additions and 235 deletions
+29 -1
View File
@@ -236,14 +236,42 @@ func DeleteContrato(id uint) error {
return db.Delete(&c).Error
}
// GuardarEnlacePago persiste el enlace de pago Bold en el contrato.
// GuardarEnlacePago persiste el enlace de cobro de un ciclo en el contrato.
//
// Generar un enlace nuevo significa que empieza un ciclo de cobro nuevo, así que
// también se reinicia pago_confirmado. Sin esto el flag quedaba en true para
// siempre desde el primer pago: en la segunda renovación el cliente pagaba, pero
// GetContratosConEnlacePendiente lo excluía del polling y MarcarContratoPagado
// salía por "ya estaba pagado" sin extender la fecha de vencimiento.
func GuardarEnlacePago(contratoID uint, linkID, url string) error {
return app.Http.Database.DB.Model(&Contrato{}).Where("id = ?", contratoID).Updates(map[string]interface{}{
"enlace_pago": url,
"enlace_pago_link_id": linkID,
"pago_confirmado": false,
"fecha_pago": nil,
}).Error
}
// MarcarContratosVencidos pasa a estado "vencido" los contratos activos sin
// auto-renovación cuya fecha de vencimiento quedó atrás hace más de los días de
// gracia indicados.
//
// Sin esto ningún contrato salía nunca de "activo", y el cron de vencidos les
// seguía mandando correo con enlace de pago todos los días indefinidamente.
func MarcarContratosVencidos(diasGracia int) (int64, error) {
limite := time.Now().AddDate(0, 0, -diasGracia)
result := app.Http.Database.DB.Model(&Contrato{}).
Where("estado = ? AND fecha_vencimiento < ? AND pago_confirmado = ?", "activo", limite, false).
Update("estado", "vencido")
if result.Error != nil {
return 0, result.Error
}
if result.RowsAffected > 0 {
log.Printf("[CONTRATOS] %d contrato(s) pasaron a estado 'vencido' tras %d días sin pago", result.RowsAffected, diasGracia)
}
return result.RowsAffected, nil
}
// LimpiarEnlacePago borra el enlace vigente cuando se registra un pago aprobado,
// para que la próxima notificación genere un link nuevo.
func LimpiarEnlacePago(contratoID uint) error {
+16 -2
View File
@@ -109,10 +109,20 @@ func ProcesarVencimientosProximos() {
log.Println("[CRON] Vencimientos próximos finalizado")
}
// diasGraciaVencimiento es cuántos días se sigue insistiendo a un contrato ya
// vencido antes de darlo por perdido y dejar de notificar.
const diasGraciaVencimiento = 30
// ProcesarYaVencidos procesa reglas de tipo "ya_vencido"
func ProcesarYaVencidos() {
log.Println("[CRON] Procesando contratos ya vencidos...")
// Cerrar primero los que llevan demasiado tiempo vencidos sin pagar, para que
// dejen de recibir el correo diario de forma indefinida.
if _, err := models.MarcarContratosVencidos(diasGraciaVencimiento); err != nil {
log.Printf("[CRON] Error marcando contratos vencidos: %v", err)
}
reglas, err := models.GetReglasByTipoEvento("ya_vencido")
if err != nil {
log.Printf("[CRON] Error obteniendo reglas ya_vencido: %v", err)
@@ -280,8 +290,12 @@ func VerificarSaludServidores() {
// 2. Recursos (CPU / RAM / Disco) al límite
if srv.MetricasJson != "" {
var m struct {
CPU struct{ Porcentaje float64 `json:"porcentaje"` } `json:"cpu"`
RAM struct{ Porcentaje float64 `json:"porcentaje"` } `json:"ram"`
CPU struct {
Porcentaje float64 `json:"porcentaje"`
} `json:"cpu"`
RAM struct {
Porcentaje float64 `json:"porcentaje"`
} `json:"ram"`
Discos []struct {
Porcentaje float64 `json:"porcentaje"`
Ruta string `json:"ruta"`
+12 -8
View File
@@ -83,13 +83,17 @@ type PlanUpdated struct {
}
type PagoRequest struct {
Currency string `json:"currency"`
Amount float64 `json:"amount"`
OrderID int `json:"order_id"`
Description string `json:"description"`
SuccessURL string `json:"success_url"`
BackURL string `json:"back_url"`
NotificationURL string `json:"notification_url"`
Currency string `json:"currency"`
Amount float64 `json:"amount"`
// OrderID es la referencia con la que después se reconcilia el pago.
// Debe ir en formato "contrato-{id}": es lo que espera el webhook
// (dlocal_controller.go) y la verificación por API para vincular el pago con
// su contrato. Antes se enviaba el ID numérico y el pago nunca se asociaba.
OrderID string `json:"order_id"`
Description string `json:"description"`
SuccessURL string `json:"success_url"`
BackURL string `json:"back_url"`
NotificationURL string `json:"notification_url"`
}
// Llama al endpoint /v1/me de DLocal usando la configuración
@@ -117,7 +121,7 @@ func GetDLocalMe(cfg dlocalConfig) ([]byte, error) {
req.Header.Set("Authorization", authToken)
// Ejecutar la solicitud
client := &http.Client{}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
+5 -2
View File
@@ -22,6 +22,9 @@ type TelegramAttachment struct {
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 (
@@ -33,7 +36,7 @@ var (
// 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
resp, err := adjuntoHTTPClient.Get(getFileURL) //nolint:noctx
if err != nil {
return "", fmt.Errorf("no se pudo consultar el archivo en Telegram: %w", err)
}
@@ -50,7 +53,7 @@ func DescargarDocumentoTelegram(botToken, fileID, originalName, mimeType string)
}
downloadURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", botToken, result.Result.FilePath)
fresp, err := http.Get(downloadURL) //nolint:noctx
fresp, err := adjuntoHTTPClient.Get(downloadURL) //nolint:noctx
if err != nil {
return "", fmt.Errorf("no se pudo descargar el archivo: %w", err)
}
+353
View File
@@ -0,0 +1,353 @@
package services
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// ─── Endpoints de PayPal ──────────────────────────────────────────────────────
const (
paypalBaseSandbox = "https://api-m.sandbox.paypal.com"
paypalBaseLive = "https://api-m.paypal.com"
)
// paypalCredenciales devuelve la URL base y las credenciales según el modo activo.
func paypalCredenciales(cfg *models.PaypalConfig) (baseURL, clientID, clientSecret string) {
if strings.EqualFold(cfg.Modo, "live") {
return paypalBaseLive, cfg.ClientIDProd, cfg.ClientSecretProd
}
return paypalBaseSandbox, cfg.ClientIDSandbox, cfg.ClientSecretSandbox
}
var paypalHTTPClient = &http.Client{Timeout: 30 * time.Second}
// ─── Autenticación ────────────────────────────────────────────────────────────
type paypalTokenResp struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
Error string `json:"error"`
ErrorDesc string `json:"error_description"`
}
// PaypalAccessToken pide un token OAuth2 con las credenciales de la config activa.
func PaypalAccessToken(cfg *models.PaypalConfig) (string, error) {
baseURL, clientID, clientSecret := paypalCredenciales(cfg)
if clientID == "" || clientSecret == "" {
return "", fmt.Errorf("PayPal: faltan credenciales para el modo '%s'", cfg.Modo)
}
form := url.Values{}
form.Set("grant_type", "client_credentials")
req, err := http.NewRequest("POST", baseURL+"/v1/oauth2/token", strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.SetBasicAuth(clientID, clientSecret)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := paypalHTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("PayPal: no se pudo conectar: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
var out paypalTokenResp
if err := json.Unmarshal(raw, &out); err != nil {
return "", fmt.Errorf("PayPal: respuesta de token inesperada (%d)", resp.StatusCode)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 || out.AccessToken == "" {
detalle := out.ErrorDesc
if detalle == "" {
detalle = out.Error
}
if detalle == "" {
detalle = string(raw)
}
return "", fmt.Errorf("PayPal: autenticación rechazada (%d): %s", resp.StatusCode, detalle)
}
return out.AccessToken, nil
}
// paypalRequest hace una llamada autenticada a la API de PayPal.
func paypalRequest(cfg *models.PaypalConfig, token, method, endpoint string, body interface{}) ([]byte, int, error) {
baseURL, _, _ := paypalCredenciales(cfg)
var reqBody io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, 0, err
}
reqBody = bytes.NewReader(b)
}
req, err := http.NewRequest(method, baseURL+endpoint, reqBody)
if err != nil {
return nil, 0, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := paypalHTTPClient.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("PayPal: error de conexión: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
return raw, resp.StatusCode, nil
}
// ─── Creación de orden de pago ────────────────────────────────────────────────
type paypalOrderResp struct {
ID string `json:"id"`
Status string `json:"status"`
Links []struct {
Href string `json:"href"`
Rel string `json:"rel"`
} `json:"links"`
Message string `json:"message"`
Details []struct {
Issue string `json:"issue"`
Description string `json:"description"`
} `json:"details"`
}
// PaypalCrearOrden crea una orden de cobro y devuelve (orderID, urlDeAprobacion).
// La referencia debe ir en formato "contrato-{id}": es lo que permite reconciliar
// después el pago con su contrato, igual que en Bold y dLocal.
func PaypalCrearOrden(cfg *models.PaypalConfig, referencia, descripcion, moneda string, monto float64) (string, string, error) {
token, err := PaypalAccessToken(cfg)
if err != nil {
return "", "", err
}
returnURL := cfg.ReturnURL
if returnURL == "" {
returnURL = absAppURL("/pago-exitoso?ref=" + referencia)
}
cancelURL := cfg.CancelURL
if cancelURL == "" {
cancelURL = absAppURL("/pago-cancelado?ref=" + referencia)
}
orden := map[string]interface{}{
"intent": "CAPTURE",
"purchase_units": []map[string]interface{}{{
"reference_id": referencia,
"custom_id": referencia,
"description": truncarPaypal(descripcion, 127),
"amount": map[string]string{
"currency_code": moneda,
"value": fmt.Sprintf("%.2f", monto),
},
}},
"application_context": map[string]string{
"return_url": returnURL,
"cancel_url": cancelURL,
"shipping_preference": "NO_SHIPPING",
"user_action": "PAY_NOW",
},
}
raw, status, err := paypalRequest(cfg, token, "POST", "/v2/checkout/orders", orden)
if err != nil {
return "", "", err
}
var out paypalOrderResp
if err := json.Unmarshal(raw, &out); err != nil {
return "", "", fmt.Errorf("PayPal: respuesta inesperada al crear la orden (%d)", status)
}
if status < 200 || status >= 300 || out.ID == "" {
return "", "", fmt.Errorf("PayPal rechazó la orden (%d): %s", status, detallePaypal(out))
}
for _, l := range out.Links {
if l.Rel == "approve" || l.Rel == "payer-action" {
return out.ID, l.Href, nil
}
}
return out.ID, "", fmt.Errorf("PayPal creó la orden %s pero no devolvió enlace de aprobación", out.ID)
}
// PaypalCapturarOrden cobra una orden ya aprobada por el cliente.
// Devuelve true si quedó capturada (o ya lo estaba).
func PaypalCapturarOrden(cfg *models.PaypalConfig, orderID string) (bool, error) {
token, err := PaypalAccessToken(cfg)
if err != nil {
return false, err
}
raw, status, err := paypalRequest(cfg, token, "POST", "/v2/checkout/orders/"+orderID+"/capture", map[string]interface{}{})
if err != nil {
return false, err
}
var out paypalOrderResp
_ = json.Unmarshal(raw, &out)
if out.Status == "COMPLETED" {
return true, nil
}
// PayPal responde 422 ORDER_ALREADY_CAPTURED si ya se cobró: es éxito, no error.
for _, d := range out.Details {
if d.Issue == "ORDER_ALREADY_CAPTURED" {
return true, nil
}
}
if status < 200 || status >= 300 {
return false, fmt.Errorf("PayPal no pudo capturar la orden (%d): %s", status, detallePaypal(out))
}
return false, nil
}
// PaypalConsultarOrden devuelve el estado actual de una orden.
func PaypalConsultarOrden(cfg *models.PaypalConfig, orderID string) (string, error) {
token, err := PaypalAccessToken(cfg)
if err != nil {
return "", err
}
raw, status, err := paypalRequest(cfg, token, "GET", "/v2/checkout/orders/"+orderID, nil)
if err != nil {
return "", err
}
var out paypalOrderResp
if err := json.Unmarshal(raw, &out); err != nil {
return "", fmt.Errorf("PayPal: respuesta inesperada (%d)", status)
}
if status < 200 || status >= 300 {
return "", fmt.Errorf("PayPal (%d): %s", status, detallePaypal(out))
}
return out.Status, nil
}
// ─── Verificación de webhook ──────────────────────────────────────────────────
// PaypalVerificarWebhook comprueba contra PayPal que la notificación es auténtica.
// Sin esto cualquiera podría enviar un "pago completado" falso.
func PaypalVerificarWebhook(cfg *models.PaypalConfig, headers map[string]string, rawBody []byte) (bool, error) {
if cfg.WebhookID == "" {
return false, fmt.Errorf("PayPal: falta configurar el Webhook ID para poder verificar las notificaciones")
}
token, err := PaypalAccessToken(cfg)
if err != nil {
return false, err
}
var evento json.RawMessage = rawBody
payload := map[string]interface{}{
"auth_algo": headers["paypal-auth-algo"],
"cert_url": headers["paypal-cert-url"],
"transmission_id": headers["paypal-transmission-id"],
"transmission_sig": headers["paypal-transmission-sig"],
"transmission_time": headers["paypal-transmission-time"],
"webhook_id": cfg.WebhookID,
"webhook_event": evento,
}
raw, status, err := paypalRequest(cfg, token, "POST", "/v1/notifications/verify-webhook-signature", payload)
if err != nil {
return false, err
}
var out struct {
VerificationStatus string `json:"verification_status"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return false, fmt.Errorf("PayPal: respuesta inesperada al verificar la firma (%d)", status)
}
return out.VerificationStatus == "SUCCESS", nil
}
// PaypalEvento es la parte de la notificación que interesa para reconciliar.
type PaypalEvento struct {
ID string `json:"id"`
EventType string `json:"event_type"`
ResourceID string
Referencia string
}
// ParsePaypalWebhook extrae el tipo de evento y la referencia del contrato.
func ParsePaypalWebhook(rawBody []byte) (*PaypalEvento, error) {
var body struct {
ID string `json:"id"`
EventType string `json:"event_type"`
Resource struct {
ID string `json:"id"`
CustomID string `json:"custom_id"`
PurchaseUnits []struct {
ReferenceID string `json:"reference_id"`
CustomID string `json:"custom_id"`
} `json:"purchase_units"`
SupplementaryData struct {
RelatedIDs struct {
OrderID string `json:"order_id"`
} `json:"related_ids"`
} `json:"supplementary_data"`
} `json:"resource"`
}
if err := json.Unmarshal(rawBody, &body); err != nil {
return nil, fmt.Errorf("PayPal: no se pudo interpretar la notificación: %w", err)
}
ev := &PaypalEvento{ID: body.ID, EventType: body.EventType, ResourceID: body.Resource.ID}
// La referencia puede venir en custom_id (capturas) o en purchase_units (órdenes).
ev.Referencia = body.Resource.CustomID
if ev.Referencia == "" {
for _, pu := range body.Resource.PurchaseUnits {
if pu.CustomID != "" {
ev.Referencia = pu.CustomID
break
}
if pu.ReferenceID != "" {
ev.Referencia = pu.ReferenceID
break
}
}
}
// En PAYMENT.CAPTURE.* el ID del recurso es la captura, no la orden.
if body.Resource.SupplementaryData.RelatedIDs.OrderID != "" {
ev.ResourceID = body.Resource.SupplementaryData.RelatedIDs.OrderID
}
return ev, nil
}
// ─── helpers ──────────────────────────────────────────────────────────────────
func detallePaypal(out paypalOrderResp) string {
if len(out.Details) > 0 {
d := out.Details[0]
if d.Description != "" {
return d.Issue + ": " + d.Description
}
return d.Issue
}
if out.Message != "" {
return out.Message
}
return "sin detalle"
}
func truncarPaypal(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max]
}
+51 -6
View File
@@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"log"
"math"
"strings"
"text/template"
"time"
@@ -328,11 +330,49 @@ func ObtenerOCrearEnlacePago(contrato *models.Contrato, gateway string) string {
switch gateway {
case "dlocal":
return obtenerEnlaceDlocal(contrato)
case "paypal":
return obtenerEnlacePaypal(contrato)
default: // "bold"
return obtenerEnlaceBold(contrato)
}
}
// obtenerEnlacePaypal crea una orden de pago en PayPal y persiste el enlace.
func obtenerEnlacePaypal(contrato *models.Contrato) string {
cfg, err := models.GetPaypalConfig()
if err != nil {
log.Printf("[PAYPAL] sin config activa para contrato %d", contrato.ID)
return ""
}
desc := fmt.Sprintf("Renovación contrato #%d", contrato.ID)
if len(contrato.Servicios) > 0 {
desc = fmt.Sprintf("Renovación: %s", contrato.Servicios[0].Nombre)
}
referencia := fmt.Sprintf("contrato-%d", contrato.ID)
orderID, urlAprobacion, err := PaypalCrearOrden(cfg, referencia, desc, monedaDeContrato(contrato), contrato.PrecioAcordado)
if err != nil {
log.Printf("[PAYPAL] error creando orden para contrato %d: %v", contrato.ID, err)
return ""
}
_ = models.GuardarEnlacePago(contrato.ID, orderID, urlAprobacion)
contrato.EnlacePago = urlAprobacion
contrato.EnlacePagoLinkID = orderID
return urlAprobacion
}
// monedaDeContrato devuelve la moneda del contrato, con COP como valor por
// defecto. Antes la moneda iba fija a "COP" en ambas pasarelas: un contrato
// pactado en USD generaba un cobro por esa cifra en pesos (500 USD → 500 COP).
func monedaDeContrato(contrato *models.Contrato) string {
if m := strings.ToUpper(strings.TrimSpace(contrato.Moneda)); m != "" {
return m
}
return "COP"
}
// obtenerEnlaceBold crea un payment link en Bold y lo persiste en el contrato.
func obtenerEnlaceBold(contrato *models.Contrato) string {
cfg, err := models.GetBoldConfig()
@@ -349,8 +389,8 @@ func obtenerEnlaceBold(contrato *models.Contrato) string {
req := BoldPaymentLinkRequest{
AmountType: "CLOSE",
Amount: BoldAmountField{
Currency: "COP",
TotalAmount: int64(contrato.PrecioAcordado),
Currency: monedaDeContrato(contrato),
TotalAmount: int64(math.Round(contrato.PrecioAcordado)),
},
Description: desc,
Reference: fmt.Sprintf("contrato-%d", contrato.ID),
@@ -382,13 +422,18 @@ func obtenerEnlaceDlocal(contrato *models.Contrato) string {
desc = fmt.Sprintf("Renovación: %s", contrato.Servicios[0].Nombre)
}
referencia := fmt.Sprintf("contrato-%d", contrato.ID)
pagoReq := PagoRequest{
Currency: "COP",
Currency: monedaDeContrato(contrato),
Amount: contrato.PrecioAcordado,
OrderID: int(contrato.ID),
OrderID: referencia,
Description: desc,
SuccessURL: cfg.UrlProd + "/pago-exitoso",
BackURL: cfg.UrlProd + "/pago-cancelado",
// Las URLs de retorno y notificación son las del panel, no las de la API
// de dLocal: antes apuntaban a cfg.UrlProd y el cliente terminaba en una
// página inexistente. Sin notification_url, dLocal nunca llamaba al webhook.
SuccessURL: absAppURL("/pago-exitoso?ref=" + referencia),
BackURL: absAppURL("/pago-cancelado?ref=" + referencia),
NotificationURL: absAppURL("/webhooks/dlocal"),
}
body, err := CreatePago(*cfg, pagoReq)
+208 -73
View File
@@ -97,44 +97,68 @@ func agentTools() []agentTool {
tool("sistema_info", "Información general del sistema y recursos disponibles.", obj(nil, nil)),
// ── Coolify ──────────────────────────────────────────────────────────
tool("coolify_instancias", "Lista las instancias de Coolify configuradas.", obj(nil, nil)),
tool("coolify_apps", "Lista las aplicaciones de una instancia Coolify.",
obj(map[string]agentToolParam{"config_id": num("ID de la instancia Coolify")}, []string{"config_id"})),
tool("coolify_servicios", "Lista los servicios (stacks) de una instancia Coolify.",
obj(map[string]agentToolParam{"config_id": num("ID de la instancia Coolify")}, []string{"config_id"})),
tool("coolify_servidores", "Lista los servidores de una instancia Coolify.",
obj(map[string]agentToolParam{"config_id": num("ID de la instancia Coolify")}, []string{"config_id"})),
tool("coolify_deploy", "Despliega una aplicación en Coolify.",
tool("coolify_instancias", "Lista las instancias de Coolify configuradas. Úsala primero para saber qué config_id existe si el usuario no lo especifica.", obj(nil, nil)),
tool("coolify_listar", "Lista recursos de una instancia Coolify: aplicaciones, servicios, bases de datos, servidores, proyectos, deployments recientes o miembros del equipo.",
obj(map[string]agentToolParam{
"config_id": num("ID de la instancia Coolify"),
"uuid": str("UUID de la aplicación"),
"force": agentToolParam{Type: "boolean", Description: "Forzar rebuild desde cero"},
}, []string{"config_id", "uuid"})),
tool("coolify_restart", "Reinicia una aplicación en Coolify.",
"tipo": agentToolParam{
Type: "string",
Description: "Qué listar",
Enum: []string{"apps", "servicios", "bases_datos", "servidores", "proyectos", "deployments", "equipo"},
},
}, []string{"config_id", "tipo"})),
tool("coolify_detalle", "Muestra el detalle de un recurso concreto de Coolify por su UUID.",
obj(map[string]agentToolParam{
"config_id": num("ID de la instancia Coolify"),
"tipo": agentToolParam{
Type: "string",
Description: "Tipo de recurso",
Enum: []string{"app", "servicio", "base_datos", "servidor", "proyecto", "deployment"},
},
"uuid": str("UUID del recurso"),
}, []string{"config_id", "tipo", "uuid"})),
tool("coolify_accion", "Ejecuta una acción sobre una aplicación, servicio o base de datos en Coolify: iniciar, detener, reiniciar o desplegar. 'deploy' solo aplica a aplicaciones.",
obj(map[string]agentToolParam{
"config_id": num("ID de la instancia Coolify"),
"tipo": agentToolParam{
Type: "string",
Description: "Sobre qué se ejecuta la acción",
Enum: []string{"app", "servicio", "base_datos"},
},
"uuid": str("UUID del recurso"),
"accion": agentToolParam{
Type: "string",
Description: "Acción a ejecutar",
Enum: []string{"start", "stop", "restart", "deploy"},
},
"force": agentToolParam{Type: "boolean", Description: "Solo para deploy: forzar rebuild desde cero sin usar caché"},
}, []string{"config_id", "tipo", "uuid", "accion"})),
tool("coolify_logs", "Obtiene los logs recientes de una aplicación en Coolify. Útil para diagnosticar por qué algo falla.",
obj(map[string]agentToolParam{
"config_id": num("ID de la instancia Coolify"),
"uuid": str("UUID de la aplicación"),
}, []string{"config_id", "uuid"})),
tool("coolify_stop", "Detiene una aplicación en Coolify.",
tool("coolify_deployments", "Lista los deployments recientes de una aplicación, con su estado (para saber si un despliegue terminó bien).",
obj(map[string]agentToolParam{
"config_id": num("ID de la instancia Coolify"),
"uuid": str("UUID de la aplicación"),
}, []string{"config_id", "uuid"})),
tool("coolify_start", "Inicia una aplicación detenida en Coolify.",
tool("coolify_envs", "Lista las variables de entorno de una aplicación o servicio en Coolify. Los valores pueden contener secretos: nunca los repitas completos en la respuesta salvo que el usuario lo pida explícitamente.",
obj(map[string]agentToolParam{
"config_id": num("ID de la instancia Coolify"),
"uuid": str("UUID de la aplicación"),
}, []string{"config_id", "uuid"})),
tool("coolify_logs", "Obtiene los logs de una aplicación en Coolify.",
"tipo": agentToolParam{Type: "string", Description: "Tipo de recurso", Enum: []string{"app", "servicio"}},
"uuid": str("UUID del recurso"),
}, []string{"config_id", "tipo", "uuid"})),
tool("coolify_servidor_info", "Consulta información de un servidor de Coolify: recursos desplegados, dominios configurados, o valida su conexión.",
obj(map[string]agentToolParam{
"config_id": num("ID de la instancia Coolify"),
"uuid": str("UUID de la aplicación"),
}, []string{"config_id", "uuid"})),
tool("coolify_deployments", "Lista los deployments recientes de una aplicación.",
obj(map[string]agentToolParam{
"config_id": num("ID de la instancia Coolify"),
"uuid": str("UUID de la aplicación"),
}, []string{"config_id", "uuid"})),
"uuid": str("UUID del servidor"),
"info": agentToolParam{
Type: "string",
Description: "Qué consultar",
Enum: []string{"recursos", "dominios", "validar"},
},
}, []string{"config_id", "uuid", "info"})),
// ── Facturas ─────────────────────────────────────────────────────────
tool("listar_facturas", "Lista facturas. Puede filtrar por estado: pendiente, pagada, vencida, cancelada.",
@@ -420,55 +444,118 @@ func runTool(chatID int64, name string, a map[string]interface{}) (interface{},
}
return out, nil
case "coolify_apps", "coolify_servicios", "coolify_servidores",
"coolify_logs", "coolify_deployments":
configID := uint(getInt("config_id", 0))
if configID == 0 {
return nil, fmt.Errorf("config_id requerido")
}
cfg, err := models.GetCoolifyConfigByID(configID)
if err != nil {
return nil, fmt.Errorf("instancia Coolify #%d no encontrada", configID)
}
endpoint := map[string]string{
"coolify_apps": "/applications",
"coolify_servicios": "/services",
"coolify_servidores": "/servers",
}[name]
if endpoint == "" {
uuid := getStr("uuid")
if uuid == "" {
return nil, fmt.Errorf("uuid requerido")
}
endpoint = map[string]string{
"coolify_logs": "/applications/" + uuid + "/logs",
"coolify_deployments": "/applications/" + uuid + "/deployments",
}[name]
}
return coolifyCall("GET", endpoint, nil, cfg)
case "coolify_deploy":
configID := uint(getInt("config_id", 0))
uuid := getStr("uuid")
force := getBool("force")
if configID == 0 || uuid == "" {
return nil, fmt.Errorf("config_id y uuid requeridos")
}
cfg, err := models.GetCoolifyConfigByID(configID)
case "coolify_listar":
cfg, err := coolifyConfigDe(getInt("config_id", 0))
if err != nil {
return nil, err
}
ep := fmt.Sprintf("/deploy?uuid=%s&force=%v", uuid, force)
return coolifyCall("POST", ep, nil, cfg)
endpoint, ok := map[string]string{
"apps": "/applications",
"servicios": "/services",
"bases_datos": "/databases",
"servidores": "/servers",
"proyectos": "/projects",
"deployments": "/deployments",
"equipo": "/teams/current/members",
}[getStr("tipo")]
if !ok {
return nil, fmt.Errorf("tipo inválido: usa apps, servicios, bases_datos, servidores, proyectos, deployments o equipo")
}
return coolifyCall("GET", endpoint, nil, cfg)
case "coolify_restart":
return coolifyAppAction(getInt("config_id", 0), getStr("uuid"), "restart")
case "coolify_detalle":
cfg, err := coolifyConfigDe(getInt("config_id", 0))
if err != nil {
return nil, err
}
uuid := getStr("uuid")
if uuid == "" {
return nil, fmt.Errorf("uuid requerido")
}
base, ok := coolifyBasePorTipo(getStr("tipo"))
if !ok {
if getStr("tipo") == "deployment" {
return coolifyCall("GET", "/deployments/"+uuid, nil, cfg)
}
return nil, fmt.Errorf("tipo inválido: usa app, servicio, base_datos, servidor, proyecto o deployment")
}
return coolifyCall("GET", base+"/"+uuid, nil, cfg)
case "coolify_stop":
return coolifyAppAction(getInt("config_id", 0), getStr("uuid"), "stop")
case "coolify_accion":
cfg, err := coolifyConfigDe(getInt("config_id", 0))
if err != nil {
return nil, err
}
uuid := getStr("uuid")
if uuid == "" {
return nil, fmt.Errorf("uuid requerido")
}
tipo := getStr("tipo")
accion := getStr("accion")
case "coolify_start":
return coolifyAppAction(getInt("config_id", 0), getStr("uuid"), "start")
if accion == "deploy" {
if tipo != "app" {
return nil, fmt.Errorf("'deploy' solo aplica a aplicaciones; para %s usa start, stop o restart", tipo)
}
return coolifyCall("POST", fmt.Sprintf("/deploy?uuid=%s&force=%v", uuid, getBool("force")), nil, cfg)
}
if accion != "start" && accion != "stop" && accion != "restart" {
return nil, fmt.Errorf("acción inválida: usa start, stop, restart o deploy")
}
base, ok := coolifyBasePorTipo(tipo)
if !ok || tipo == "servidor" || tipo == "proyecto" {
return nil, fmt.Errorf("tipo inválido para una acción: usa app, servicio o base_datos")
}
return coolifyCall("GET", fmt.Sprintf("%s/%s/%s", base, uuid, accion), nil, cfg)
case "coolify_logs", "coolify_deployments":
cfg, err := coolifyConfigDe(getInt("config_id", 0))
if err != nil {
return nil, err
}
uuid := getStr("uuid")
if uuid == "" {
return nil, fmt.Errorf("uuid requerido")
}
sufijo := "/logs"
if name == "coolify_deployments" {
sufijo = "/deployments"
}
return coolifyCall("GET", "/applications/"+uuid+sufijo, nil, cfg)
case "coolify_envs":
cfg, err := coolifyConfigDe(getInt("config_id", 0))
if err != nil {
return nil, err
}
uuid := getStr("uuid")
if uuid == "" {
return nil, fmt.Errorf("uuid requerido")
}
base, ok := coolifyBasePorTipo(getStr("tipo"))
if !ok || (getStr("tipo") != "app" && getStr("tipo") != "servicio") {
return nil, fmt.Errorf("tipo inválido: las variables de entorno solo se consultan de app o servicio")
}
return coolifyCall("GET", base+"/"+uuid+"/envs", nil, cfg)
case "coolify_servidor_info":
cfg, err := coolifyConfigDe(getInt("config_id", 0))
if err != nil {
return nil, err
}
uuid := getStr("uuid")
if uuid == "" {
return nil, fmt.Errorf("uuid requerido")
}
sufijo, ok := map[string]string{
"recursos": "/resources",
"dominios": "/domains",
"validar": "/validate",
}[getStr("info")]
if !ok {
return nil, fmt.Errorf("info inválida: usa recursos, dominios o validar")
}
return coolifyCall("GET", "/servers/"+uuid+sufijo, nil, cfg)
// ── Clientes ───────────────────────────────────────────────────────────
case "listar_facturas":
@@ -1001,6 +1088,23 @@ func coolifyCall(method, endpoint string, body []byte, cfg *models.CoolifyConfig
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
// Coolify devuelve el detalle del error en el body; sin revisar el status,
// un 401/404/500 se reportaría al usuario como si la acción hubiera salido bien.
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
detalle := strings.TrimSpace(string(raw))
if len(detalle) > 300 {
detalle = detalle[:300]
}
switch resp.StatusCode {
case http.StatusUnauthorized, http.StatusForbidden:
return nil, fmt.Errorf("Coolify rechazó la petición (%d): el API token de la instancia es inválido o no tiene permisos", resp.StatusCode)
case http.StatusNotFound:
return nil, fmt.Errorf("Coolify no encontró el recurso (404) en %s — revisa el uuid", endpoint)
default:
return nil, fmt.Errorf("Coolify respondió %d: %s", resp.StatusCode, detalle)
}
}
var result interface{}
if err := json.Unmarshal(raw, &result); err != nil {
return map[string]string{"raw": string(raw)}, nil
@@ -1008,15 +1112,46 @@ func coolifyCall(method, endpoint string, body []byte, cfg *models.CoolifyConfig
return result, nil
}
func coolifyAppAction(configIDInt int, uuid, action string) (interface{}, error) {
if configIDInt == 0 || uuid == "" {
return nil, fmt.Errorf("config_id y uuid requeridos")
// coolifyConfigDe resuelve la instancia de Coolify a usar. Si no se indicó
// config_id y solo hay una instancia activa configurada, la toma por defecto
// para que el usuario no tenga que saberse los IDs de memoria.
func coolifyConfigDe(configIDInt int) (*models.CoolifyConfig, error) {
if configIDInt > 0 {
cfg, err := models.GetCoolifyConfigByID(uint(configIDInt))
if err != nil {
return nil, fmt.Errorf("instancia Coolify #%d no encontrada", configIDInt)
}
return cfg, nil
}
cfg, err := models.GetCoolifyConfigByID(uint(configIDInt))
cfgs, err := models.GetAllCoolifyConfigs()
if err != nil {
return nil, err
}
return coolifyCall("GET", "/applications/"+uuid+"/"+action, nil, cfg)
var activas []models.CoolifyConfig
for _, c := range cfgs {
if c.Activo {
activas = append(activas, c)
}
}
if len(activas) == 1 {
return &activas[0], nil
}
if len(activas) == 0 {
return nil, fmt.Errorf("no hay instancias de Coolify activas configuradas")
}
return nil, fmt.Errorf("hay varias instancias de Coolify: indica cuál usar con config_id (consulta coolify_instancias)")
}
// coolifyBasePorTipo mapea el tipo de recurso al prefijo de la API de Coolify.
func coolifyBasePorTipo(tipo string) (string, bool) {
base, ok := map[string]string{
"app": "/applications",
"servicio": "/services",
"base_datos": "/databases",
"servidor": "/servers",
"proyecto": "/projects",
}[tipo]
return base, ok
}
// ─── Tipos Anthropic ─────────────────────────────────────────────────────────
@@ -1341,7 +1476,7 @@ COMPORTAMIENTO:
- Para listar datos, muestra los más relevantes en formato legible
- 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
- En Coolify: si solo hay una instancia activa se usa automáticamente, no hace falta preguntar por config_id; si hay varias, consulta coolify_instancias y pregunta cuál. Para actuar sobre algo necesitas su UUID: búscalo con coolify_listar por nombre antes de llamar a coolify_accion. Reiniciar/desplegar es rutinario, hazlo directo; detener algo en producción sí confírmalo antes. Si un despliegue falla, revisa coolify_logs y coolify_deployments para explicar la causa concreta
- Cuando el mensaje empiece con "[Documento adjunto recibido: ...]", el usuario acaba de enviar un PDF o foto por Telegram y puedes leerlo directamente (está adjunto a este mismo mensaje, no es solo un nombre de archivo). Decide primero de qué se trata el archivo antes de guardarlo:
· Si es una factura/cuenta de cobro → identifica cliente, monto y número, resuelve el cliente_id con listar_clientes, y llama a adjuntar_factura
· Si es un documento de un proyecto (contrato, orden de servicio, entregable, etc.) → identifica a qué proyecto corresponde (usa listar_proyectos si hace falta) y llama a adjuntar_documento_proyecto
+8 -3
View File
@@ -6,8 +6,13 @@ import (
"fmt"
"net/http"
"os"
"time"
)
// telegramHTTPClient evita que una llamada colgada a Telegram deje la
// goroutine bloqueada indefinidamente (http.Get/Post no tienen timeout).
var telegramHTTPClient = &http.Client{Timeout: 20 * time.Second}
type TelegramService struct {
BotToken string // exportado para uso desde otros paquetes
}
@@ -44,7 +49,7 @@ func (ts *TelegramService) SendMessage(chatID interface{}, message string) error
return fmt.Errorf("error marshalling request body: %v", err)
}
resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonData))
resp, err := telegramHTTPClient.Post(apiURL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("error sending request to Telegram API: %v", err)
}
@@ -62,7 +67,7 @@ func GetBotUsername(botToken string) string {
if botToken == "" {
return ""
}
resp, err := http.Get(fmt.Sprintf("https://api.telegram.org/bot%s/getMe", botToken))
resp, err := telegramHTTPClient.Get(fmt.Sprintf("https://api.telegram.org/bot%s/getMe", botToken))
if err != nil {
return ""
}
@@ -100,7 +105,7 @@ func UpdatesRecientesDelBot(botToken string) ([]RemitenteReciente, error) {
if botToken == "" {
return nil, fmt.Errorf("bot token vacío")
}
resp, err := http.Get(fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates?limit=100", botToken)) //nolint:noctx
resp, err := telegramHTTPClient.Get(fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates?limit=100", botToken)) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("no se pudo consultar Telegram: %w", err)
}