feat: agente Telegram con IA + Coolify multi-instancia

- Coolify: soporte multi-instancia (CRUD de configs, ?config_id= en todos
  los endpoints, endpoints expandidos para services/databases/teams/envs)
- AiConfig: campos es_agente_bot + telegram_config_id para marcar qué
  config de IA actúa como cerebro del bot administrador
- TelegramAgentHistory + TelegramAgentAuth: historial de conversación por
  chat_id y whitelist de chats autorizados
- Agent Engine: function calling OpenAI-compatible con 25+ herramientas
  (clientes, contratos, contabilidad, proyectos, tickets, tareas,
  Coolify multi-instancia, servidores, monitores URL)
- Webhook POST /webhooks/telegram-agent/:bot_token (público, sin sesión)
- API /api/v2/agent/auth y /api/v2/agent/history para administrar el agente
- AutoMigrate: AiConfig, TelegramAgentHistory, TelegramAgentAuth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro GD
2026-07-13 18:29:06 +00:00
co-authored by Claude Sonnet 4.6
parent 50812749ea
commit 33cfe4fdb0
10 changed files with 1358 additions and 14 deletions
+138 -2
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -12,6 +14,16 @@ import (
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// filterQueryParam elimina un parámetro específico del querystring.
func filterQueryParam(qs, param string) string {
vals, err := url.ParseQuery(qs)
if err != nil {
return qs
}
vals.Del(param)
return vals.Encode()
}
// ─── helpers internos ────────────────────────────────────────────────────────
// coolifyDo ejecuta una petición a la API de Coolify y devuelve el body crudo.
@@ -39,9 +51,21 @@ func coolifyDo(method, endpoint string, reqBody io.Reader, contentType string, c
return body, resp.StatusCode, nil
}
// coolifyResolveConfig retorna la config a usar según ?config_id= o la activa por defecto.
func coolifyResolveConfig(c *fiber.Ctx) (*models.CoolifyConfig, error) {
if idStr := c.Query("config_id"); idStr != "" {
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
return nil, fmt.Errorf("config_id inválido")
}
return models.GetCoolifyConfigByID(uint(id))
}
return models.GetCoolifyConfig()
}
// coolifyProxy resuelve config, aplica qs de la request y responde al frontend.
func coolifyProxy(c *fiber.Ctx, method, endpoint string) error {
cfg, err := models.GetCoolifyConfig()
cfg, err := coolifyResolveConfig(c)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Configura primero la integración Coolify"})
}
@@ -49,8 +73,18 @@ func coolifyProxy(c *fiber.Ctx, method, endpoint string) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "La integración Coolify está inactiva"})
}
// Filtrar config_id del querystring para no pasarlo a Coolify
qs := string(c.Request().URI().QueryString())
if qs != "" {
filtered := filterQueryParam(qs, "config_id")
if filtered != "" {
qs = filtered
} else {
qs = ""
}
}
ep := endpoint
if qs := string(c.Request().URI().QueryString()); qs != "" {
if qs != "" {
ep = endpoint + "?" + qs
}
@@ -317,3 +351,105 @@ func CoolifyTeamMembers(c *fiber.Ctx) error {
func CoolifyWebhook(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
}
// ─── CRUD de instancias Coolify ───────────────────────────────────────────────
func CoolifyListConfigs(c *fiber.Ctx) error {
items, err := models.GetAllCoolifyConfigs()
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
// Ocultar tokens
type safe struct {
ID uint `json:"id"`
Nombre string `json:"nombre"`
BaseURL string `json:"base_url"`
Activo bool `json:"activo"`
}
out := make([]safe, len(items))
for i, cfg := range items {
out[i] = safe{ID: cfg.ID, Nombre: cfg.Nombre, BaseURL: cfg.BaseURL, Activo: cfg.Activo}
}
return c.JSON(fiber.Map{"items": out})
}
func CoolifyCreateConfig(c *fiber.Ctx) error {
type Req struct {
Nombre string `json:"nombre"`
BaseURL string `json:"base_url"`
ApiToken string `json:"api_token"`
Activo bool `json:"activo"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "body inválido"})
}
req.BaseURL = strings.TrimRight(strings.TrimSpace(req.BaseURL), "/")
if req.BaseURL == "" || req.ApiToken == "" {
return c.Status(400).JSON(fiber.Map{"error": "base_url y api_token son requeridos"})
}
cfg := &models.CoolifyConfig{Nombre: req.Nombre, BaseURL: req.BaseURL, ApiToken: req.ApiToken, Activo: req.Activo}
if err := models.CreateCoolifyConfig(cfg); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(201).JSON(fiber.Map{"ok": true, "id": cfg.ID})
}
func CoolifyUpdateConfig(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "id inválido"})
}
type Req struct {
Nombre string `json:"nombre"`
BaseURL string `json:"base_url"`
ApiToken string `json:"api_token"`
Activo bool `json:"activo"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "body inválido"})
}
req.BaseURL = strings.TrimRight(strings.TrimSpace(req.BaseURL), "/")
cfg, err := models.UpdateCoolifyConfig(uint(id), req.Nombre, req.BaseURL, req.ApiToken, req.Activo)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true, "id": cfg.ID})
}
func CoolifyDeleteConfig(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "id inválido"})
}
if err := models.DeleteCoolifyConfig(uint(id)); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func CoolifyTestConfig(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "id inválido"})
}
cfg, err := models.GetCoolifyConfigByID(uint(id))
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "config no encontrada"})
}
base := strings.TrimRight(cfg.BaseURL, "/")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(base + "/api/health")
if err != nil {
return c.Status(502).JSON(fiber.Map{"error": err.Error()})
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
var result json.RawMessage
if jsonErr := json.Unmarshal(body, &result); jsonErr != nil {
result = json.RawMessage(fmt.Sprintf(`{"raw":%q}`, string(body)))
}
c.Status(resp.StatusCode)
return c.JSON(result)
}
@@ -0,0 +1,154 @@
package controllers
import (
"fmt"
"log"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
// ─── Tipos del update de Telegram ────────────────────────────────────────────
type tgUser struct {
ID int64 `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"username"`
}
type tgChat struct {
ID int64 `json:"id"`
Type string `json:"type"`
}
type tgMessage struct {
MessageID int `json:"message_id"`
From tgUser `json:"from"`
Chat tgChat `json:"chat"`
Text string `json:"text"`
}
type tgUpdate struct {
UpdateID int `json:"update_id"`
Message *tgMessage `json:"message"`
}
// ─── Webhook handler ──────────────────────────────────────────────────────────
// TelegramAgentWebhook recibe updates del bot administrador.
// Telegram llama aquí cuando alguien escribe al bot.
// Ruta: POST /webhooks/telegram-agent/:bot_token
func TelegramAgentWebhook(c *fiber.Ctx) error {
botToken := c.Params("bot_token")
if botToken == "" {
return c.SendStatus(400)
}
// Parsear el update
var update tgUpdate
if err := c.BodyParser(&update); err != nil {
return c.SendStatus(200) // siempre 200 a Telegram
}
if update.Message == nil || strings.TrimSpace(update.Message.Text) == "" {
return c.SendStatus(200)
}
msg := update.Message
chatID := msg.Chat.ID
text := strings.TrimSpace(msg.Text)
// Buscar el config del agente que tenga este bot token
ai, tgCfg, err := models.GetAgenteBotConfig()
if err != nil || tgCfg == nil || tgCfg.BotToken != botToken {
log.Printf("[AGENT_WEBHOOK] Token no corresponde a ningún agente activo")
return c.SendStatus(200)
}
// Verificar autorización del chat (si hay whitelist configurada)
if !models.IsAgentAuthChat(chatID) {
// Si no hay ningún auth configurado, solo responder al chat_id del config
tgChatIDStr := strings.TrimSpace(tgCfg.ChatID)
tgChatID, _ := strconv.ParseInt(tgChatIDStr, 10, 64)
if tgChatID != 0 && chatID != tgChatID {
sendAgentReply(tgCfg.BotToken, chatID, "No tienes autorización para usar este agente.")
return c.SendStatus(200)
}
}
// Procesar en goroutine para responder 200 inmediatamente a Telegram
go func() {
response, err := services.ProcessAgentMessage(chatID, text, ai)
if err != nil {
log.Printf("[AGENT] Error procesando mensaje: %v", err)
response = fmt.Sprintf("Error interno: %s", err.Error())
}
if response == "" {
return
}
if sendErr := sendAgentReply(tgCfg.BotToken, chatID, response); sendErr != nil {
log.Printf("[AGENT] Error enviando respuesta: %v", sendErr)
}
}()
return c.SendStatus(200)
}
func sendAgentReply(botToken string, chatID int64, text string) error {
svc := &services.TelegramService{BotToken: botToken}
return svc.SendMessage(chatID, text)
}
// ─── CRUD de chats autorizados ────────────────────────────────────────────────
func AgentAuthList(c *fiber.Ctx) error {
items, err := models.GetAllAgentAuth()
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"items": items})
}
func AgentAuthCreate(c *fiber.Ctx) error {
type Req struct {
ChatID int64 `json:"chat_id"`
Nombre string `json:"nombre"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "body inválido"})
}
if req.ChatID == 0 {
return c.Status(400).JSON(fiber.Map{"error": "chat_id requerido"})
}
if err := models.CreateAgentAuth(req.ChatID, req.Nombre); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(201).JSON(fiber.Map{"ok": true})
}
func AgentAuthDelete(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "id inválido"})
}
if err := models.DeleteAgentAuth(uint(id)); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// AgentHistoryClear borra el historial de conversación de un chat.
func AgentHistoryClear(c *fiber.Ctx) error {
chatID, err := strconv.ParseInt(c.Params("chat_id"), 10, 64)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "chat_id inválido"})
}
if err := models.ClearAgentHistory(chatID); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
+38 -1
View File
@@ -349,20 +349,57 @@ func AdminApiRoutes(api fiber.Router) {
h.Put("/cloudflare/zones/:zone_id/dns/:record_id", controllers.UpdateCloudflareDNS)
h.Delete("/cloudflare/zones/:zone_id/dns/:record_id", controllers.DeleteCloudflareDNS)
// ─── Coolify ─────────────────────────────────────────────────────────────
// ─── Coolify instancias (CRUD multi-instancia) ───────────────────────────
h.Get("/coolify/configs", controllers.CoolifyListConfigs)
h.Post("/coolify/configs", controllers.CoolifyCreateConfig)
h.Put("/coolify/configs/:id", controllers.CoolifyUpdateConfig)
h.Delete("/coolify/configs/:id", controllers.CoolifyDeleteConfig)
h.Get("/coolify/configs/:id/test", controllers.CoolifyTestConfig)
// ─── Coolify operaciones (todos soportan ?config_id=) ────────────────────
h.Get("/coolify/apps", controllers.CoolifyListApplications)
h.Get("/coolify/apps/:uuid", controllers.CoolifyGetApplication)
h.Get("/coolify/apps/:uuid/logs", controllers.CoolifyApplicationLogs)
h.Get("/coolify/apps/:uuid/envs", controllers.CoolifyApplicationEnvs)
h.Post("/coolify/apps/:uuid/envs", controllers.CoolifyApplicationEnvCreate)
h.Patch("/coolify/apps/:uuid/envs", controllers.CoolifyApplicationEnvUpdateBulk)
h.Delete("/coolify/apps/:uuid/envs/:env_id", controllers.CoolifyApplicationEnvDelete)
h.Get("/coolify/apps/:uuid/start", controllers.CoolifyApplicationStart)
h.Get("/coolify/apps/:uuid/stop", controllers.CoolifyApplicationStop)
h.Get("/coolify/apps/:uuid/restart", controllers.CoolifyApplicationRestart)
h.Get("/coolify/apps/:uuid/deployments", controllers.CoolifyApplicationDeployments)
h.Post("/coolify/apps/:uuid/deploy", controllers.CoolifyApplicationDeploy)
h.Get("/coolify/servers", controllers.CoolifyListServers)
h.Get("/coolify/servers/:uuid", controllers.CoolifyGetServer)
h.Get("/coolify/servers/:uuid/resources", controllers.CoolifyServerResources)
h.Get("/coolify/servers/:uuid/domains", controllers.CoolifyServerDomains)
h.Get("/coolify/servers/:uuid/validate", controllers.CoolifyServerValidate)
h.Get("/coolify/services", controllers.CoolifyListServices)
h.Get("/coolify/services/:uuid", controllers.CoolifyGetService)
h.Get("/coolify/services/:uuid/start", controllers.CoolifyServiceStart)
h.Get("/coolify/services/:uuid/stop", controllers.CoolifyServiceStop)
h.Get("/coolify/services/:uuid/restart", controllers.CoolifyServiceRestart)
h.Get("/coolify/services/:uuid/envs", controllers.CoolifyServiceEnvs)
h.Patch("/coolify/services/:uuid/envs", controllers.CoolifyServiceEnvUpdateBulk)
h.Get("/coolify/databases", controllers.CoolifyListDatabases)
h.Get("/coolify/databases/:uuid", controllers.CoolifyGetDatabase)
h.Get("/coolify/databases/:uuid/start", controllers.CoolifyDatabaseStart)
h.Get("/coolify/databases/:uuid/stop", controllers.CoolifyDatabaseStop)
h.Get("/coolify/databases/:uuid/restart", controllers.CoolifyDatabaseRestart)
h.Get("/coolify/projects", controllers.CoolifyListProjects)
h.Get("/coolify/projects/:uuid", controllers.CoolifyGetProject)
h.Get("/coolify/projects/:uuid/environments", controllers.CoolifyProjectEnvironments)
h.Get("/coolify/deployments", controllers.CoolifyListDeployments)
h.Get("/coolify/deployments/:uuid", controllers.CoolifyGetDeployment)
h.Get("/coolify/teams", controllers.CoolifyListTeams)
h.Get("/coolify/teams/current", controllers.CoolifyCurrentTeam)
h.Get("/coolify/teams/current/members", controllers.CoolifyTeamMembers)
// ─── Agente Telegram: chats autorizados ──────────────────────────────────
h.Get("/agent/auth", controllers.AgentAuthList)
h.Post("/agent/auth", controllers.AgentAuthCreate)
h.Delete("/agent/auth/:id", controllers.AgentAuthDelete)
h.Delete("/agent/history/:chat_id", controllers.AgentHistoryClear)
// ─── VCard API ───────────────────────────────────────────────────────────
h.Get("/vcard-api/config", controllers.VcardApiGetConfig)
+4
View File
@@ -37,6 +37,10 @@ func RutasPublicas(web fiber.Router) {
// Configurar en el bot: POST https://api.telegram.org/bot{TOKEN}/setWebhook?url={HOST}/webhooks/telegram-portal
web.Post("/webhooks/telegram-portal", controllers.TelegramPortalWebhook)
// ─── Webhook del agente administrador (bot con IA) ──────────────────────
// Configurar en el bot: POST https://api.telegram.org/bot{TOKEN}/setWebhook?url={HOST}/webhooks/telegram-agent/{BOT_TOKEN}
web.Post("/webhooks/telegram-agent/:bot_token", controllers.TelegramAgentWebhook)
// ─── WebSMS (LabsMobile) — API de envío + webhooks ──────────────────
web.Post("/api/sms/send", controllers.ApiSendSms)
web.Post("/webhooks/websms/delivery", controllers.WebSmsDeliveryWebhook)