From ee9a2f09d6aa959525f0e1260ce813024deaf919 Mon Sep 17 00:00:00 2001 From: Lizandro GD Date: Mon, 13 Jul 2026 19:28:59 +0000 Subject: [PATCH] feat: notificaciones Telegram en webhook de Coolify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CoolifyWebhook parsea el payload y envΓ­a mensaje formateado a Telegram - Detecta estado (βœ… success / ❌ fail / πŸš€ deploying / ⏹ stop / πŸ”„ restart) - Ruta /webhooks/coolify/:config_id identifica de quΓ© instancia viene - Notifica a todos los chats autorizados del agente bot - GetAgentTelegramConfig() en models para obtener el bot activo Co-Authored-By: Claude Sonnet 4.6 --- pkg/models/telegram_agent.go | 14 +++ rest/controllers/coolify_controller.go | 131 ++++++++++++++++++++++++- rest/routes/publicas.go | 3 + 3 files changed, 146 insertions(+), 2 deletions(-) diff --git a/pkg/models/telegram_agent.go b/pkg/models/telegram_agent.go index 845bce0..bc1e032 100644 --- a/pkg/models/telegram_agent.go +++ b/pkg/models/telegram_agent.go @@ -1,6 +1,8 @@ package models import ( + "fmt" + "github.com/sujit-baniya/fiber-boilerplate/app" "gorm.io/gorm" ) @@ -81,3 +83,15 @@ func CreateAgentAuth(chatID int64, nombre string) error { func DeleteAgentAuth(id uint) error { return app.Http.Database.DB.Delete(&TelegramAgentAuth{}, id).Error } + +// GetAgentTelegramConfig retorna el TelegramConfig vinculado al agente bot activo. +func GetAgentTelegramConfig() (*TelegramConfig, error) { + var ai AiConfig + if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ?", true, true).First(&ai).Error; err != nil { + return nil, fmt.Errorf("no hay agente bot configurado") + } + if ai.TelegramConfigID == nil { + return nil, fmt.Errorf("el agente no tiene Telegram configurado") + } + return GetTelegramConfigByID(*ai.TelegramConfigID) +} diff --git a/rest/controllers/coolify_controller.go b/rest/controllers/coolify_controller.go index 6a0b322..d80a82d 100644 --- a/rest/controllers/coolify_controller.go +++ b/rest/controllers/coolify_controller.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "net/url" "strconv" @@ -12,6 +13,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/pkg/services" ) // filterQueryParam elimina un parΓ‘metro especΓ­fico del querystring. @@ -346,12 +348,137 @@ func CoolifyTeamMembers(c *fiber.Ctx) error { return coolifyProxy(c, http.MethodGet, "/teams/current/members") } -// CoolifyWebhook recibe notificaciones push de Coolify (deployments, etc.) -// Acepta el payload y devuelve 200; el procesamiento puede extenderse aquΓ­. +// CoolifyWebhook recibe notificaciones push de Coolify y las reenvΓ­a a Telegram. +// Configurar en Coolify β†’ Settings β†’ Notifications β†’ Add Notification β†’ Webhook +// URL: https://admin.u-site.app/webhooks/coolify/:config_id func CoolifyWebhook(c *fiber.Ctx) error { + body := c.Body() + log.Printf("[COOLIFY_WEBHOOK] config_id=%s payload=%s", c.Params("config_id"), string(body)) + + if len(body) == 0 { + return c.SendStatus(fiber.StatusOK) + } + + // Identificar la instancia Coolify si viene en la ruta + var instanceName string + if idStr := c.Params("config_id"); idStr != "" { + if id, err := strconv.ParseUint(idStr, 10, 32); err == nil { + if cfg, err := models.GetCoolifyConfigByID(uint(id)); err == nil { + instanceName = cfg.Nombre + } + } + } + + go sendCoolifyTelegramNotif(body, instanceName) return c.SendStatus(fiber.StatusOK) } +// sendCoolifyTelegramNotif parsea el payload de Coolify y lo envΓ­a a Telegram. +func sendCoolifyTelegramNotif(body []byte, instanceName string) { + // Intentar parsear como JSON + var payload map[string]interface{} + isJSON := json.Unmarshal(body, &payload) == nil + + // Extraer campos con mΓΊltiples nombres posibles (Coolify cambia formato segΓΊn versiΓ³n) + get := func(keys ...string) string { + if !isJSON { + return "" + } + for _, k := range keys { + if v, ok := payload[k]; ok { + if s, ok := v.(string); ok && s != "" { + return s + } + } + } + return "" + } + + appName := get("application_name", "name", "app_name", "resource_name") + appUUID := get("application_uuid", "uuid", "resource_uuid") + status := get("status", "type", "event", "deployment_status") + message := get("message", "description", "text", "body") + fqdn := get("fqdn", "url", "application_url", "application_fqdn") + serverName := get("server_name", "server") + + // Si no pudimos parsear como JSON, tratar el body entero como mensaje + if !isJSON { + message = strings.TrimSpace(string(body)) + if len(message) > 500 { + message = message[:500] + "…" + } + } + + // Emoji segΓΊn estado + emoji := "πŸ””" + statusLower := strings.ToLower(status + " " + message) + switch { + case strings.Contains(statusLower, "success") || strings.Contains(statusLower, "exitoso") || strings.Contains(statusLower, "finished"): + emoji = "βœ…" + case strings.Contains(statusLower, "fail") || strings.Contains(statusLower, "error") || strings.Contains(statusLower, "fallo"): + emoji = "❌" + case strings.Contains(statusLower, "running") || strings.Contains(statusLower, "deploying") || strings.Contains(statusLower, "start") || strings.Contains(statusLower, "building"): + emoji = "πŸš€" + case strings.Contains(statusLower, "stop") || strings.Contains(statusLower, "detenido"): + emoji = "⏹" + case strings.Contains(statusLower, "restart") || strings.Contains(statusLower, "reinici"): + emoji = "πŸ”„" + } + + // Construir el mensaje + lines := []string{fmt.Sprintf("%s Coolify", emoji)} + if instanceName != "" { + lines = append(lines, fmt.Sprintf("Instancia: %s", instanceName)) + } + if appName != "" { + lines = append(lines, fmt.Sprintf("App: %s", appName)) + } + if appUUID != "" && appUUID != appName { + lines = append(lines, fmt.Sprintf("UUID: %s", appUUID)) + } + if serverName != "" { + lines = append(lines, fmt.Sprintf("Servidor: %s", serverName)) + } + if status != "" { + lines = append(lines, fmt.Sprintf("Estado: %s", status)) + } + if message != "" && message != status { + if len(message) > 300 { + message = message[:300] + "…" + } + lines = append(lines, fmt.Sprintf("ℹ️ %s", message)) + } + if fqdn != "" { + lines = append(lines, fmt.Sprintf("🌐 %s", fqdn)) + } + + text := strings.Join(lines, "\n") + + // Obtener el bot del agente (TelegramConfig ID que tenga agent bot activo) + tgCfg, err := models.GetAgentTelegramConfig() + if err != nil { + log.Printf("[COOLIFY_WEBHOOK] Sin config Telegram para notificar: %v", err) + return + } + + // Enviar a todos los chats autorizados del agente + auths, _ := models.GetAllAgentAuth() + if len(auths) == 0 { + log.Printf("[COOLIFY_WEBHOOK] Sin chats autorizados para notificar") + return + } + + svc := &services.TelegramService{BotToken: tgCfg.BotToken} + for _, auth := range auths { + if !auth.Activo { + continue + } + if err := svc.SendMessage(auth.ChatID, text); err != nil { + log.Printf("[COOLIFY_WEBHOOK] Error enviando a chat %d: %v", auth.ChatID, err) + } + } +} + // ─── CRUD de instancias Coolify ─────────────────────────────────────────────── func CoolifyListConfigs(c *fiber.Ctx) error { diff --git a/rest/routes/publicas.go b/rest/routes/publicas.go index 5dd3ac9..bedf8d7 100755 --- a/rest/routes/publicas.go +++ b/rest/routes/publicas.go @@ -24,7 +24,10 @@ func RutasPublicas(web fiber.Router) { web.Post("/webhooks/saas-in/:token", controllers.SaasWebhookInHandler) web.Get("/webhooks/saas-in/:token", controllers.SaasWebhookInHandler) // algunos SaaS verifican con GET // Coolify notifica eventos de deployment/restart aquΓ­ (configurar en Coolify β†’ Settings β†’ Notifications β†’ Webhook) + // Ruta genΓ©rica web.Post("/webhooks/coolify", controllers.CoolifyWebhook) + // Ruta por instancia: incluye el config_id para identificar de quΓ© Coolify viene + web.Post("/webhooks/coolify/:config_id", controllers.CoolifyWebhook) // ─── PΓ‘gina de confirmaciΓ³n de pago ─────────────────────────────────── web.Get("/pago-exitoso", apiControllers.PagoExitosoPage)