From 33cfe4fdb088c375ce2bfb6b87abab143476ab8f Mon Sep 17 00:00:00 2001 From: Lizandro GD Date: Mon, 13 Jul 2026 18:29:06 +0000 Subject: [PATCH] feat: agente Telegram con IA + Coolify multi-instancia MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- main.go | 7 +- pkg/models/ai_config.go | 22 +- pkg/models/coolify_config.go | 56 +- pkg/models/telegram_agent.go | 83 ++ pkg/services/telegram_agent_service.go | 865 ++++++++++++++++++ pkg/services/telegram_service.go | 2 +- rest/controllers/coolify_controller.go | 140 ++- rest/controllers/telegram_agent_controller.go | 154 ++++ rest/routes/hermes.go | 39 +- rest/routes/publicas.go | 4 + 10 files changed, 1358 insertions(+), 14 deletions(-) create mode 100644 pkg/models/telegram_agent.go create mode 100644 pkg/services/telegram_agent_service.go create mode 100644 rest/controllers/telegram_agent_controller.go diff --git a/main.go b/main.go index 4c2db8a..241bae2 100755 --- a/main.go +++ b/main.go @@ -86,8 +86,13 @@ func main() { &models.PortalPasswordResetToken{}, // Integración VCard API Admin &models.VcardApiConfig{}, - // Integración Coolify + // Integración Coolify (multi-instancia) &models.CoolifyConfig{}, + // AI Config: campos del agente Telegram + &models.AiConfig{}, + // Agente Telegram: historial y chats autorizados + &models.TelegramAgentHistory{}, + &models.TelegramAgentAuth{}, // Servidores: nuevos campos de agente + tabla join de integraciones &models.Servidor{}, &models.ConxDb{}, diff --git a/pkg/models/ai_config.go b/pkg/models/ai_config.go index d3e42b3..922ee91 100644 --- a/pkg/models/ai_config.go +++ b/pkg/models/ai_config.go @@ -24,7 +24,11 @@ type AiConfig struct { // "" = global (disponible para todos como fallback) // "landing" = exclusivo para Landing Generator // "query_runner" = exclusivo para Query Runner SQL - Modulo string `gorm:"size:50;default:''" json:"modulo"` + Modulo string `gorm:"size:50;default:''" json:"modulo"` + // Agente Telegram: si EsAgenteBot=true, esta config es el cerebro del bot administrador. + // Solo debe haber una config activa como agente a la vez. + EsAgenteBot bool `gorm:"default:false" json:"es_agente_bot"` + TelegramConfigID *uint `gorm:"index" json:"telegram_config_id"` } func (AiConfig) TableName() string { return "ai_configs" } @@ -107,6 +111,22 @@ func GetAiConfigSelect() ([]AiConfig, error) { return items, nil } +// GetAgenteBotConfig retorna la config marcada como agente Telegram, con su TelegramConfig cargada. +func GetAgenteBotConfig() (*AiConfig, *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, nil, fmt.Errorf("no hay agente bot configurado: %w", err) + } + if ai.TelegramConfigID == nil { + return &ai, nil, fmt.Errorf("el agente no tiene bot de Telegram asignado") + } + tg, err := GetTelegramConfigByID(*ai.TelegramConfigID) + if err != nil { + return &ai, nil, fmt.Errorf("bot de Telegram no encontrado: %w", err) + } + return &ai, tg, nil +} + // GetAiConfigForService retorna la config activa asignada al módulo indicado. // Lógica de prioridad: // 1. Config activa con modulo conteniendo service (puede ser comma-separated) diff --git a/pkg/models/coolify_config.go b/pkg/models/coolify_config.go index da584e4..c0862fa 100644 --- a/pkg/models/coolify_config.go +++ b/pkg/models/coolify_config.go @@ -17,25 +17,65 @@ type CoolifyConfig struct { func (CoolifyConfig) TableName() string { return "coolify_configs" } +// GetCoolifyConfig retorna la primera instancia activa (compatibilidad legacy). func GetCoolifyConfig() (*CoolifyConfig, error) { var cfg CoolifyConfig - if err := app.Http.Database.DB.First(&cfg).Error; err != nil { + if err := app.Http.Database.DB.Where("activo = ?", true).First(&cfg).Error; err != nil { + // fallback: cualquier registro + if err2 := app.Http.Database.DB.First(&cfg).Error; err2 != nil { + return nil, err2 + } + } + return &cfg, nil +} + +// GetCoolifyConfigByID retorna una instancia específica. +func GetCoolifyConfigByID(id uint) (*CoolifyConfig, error) { + var cfg CoolifyConfig + if err := app.Http.Database.DB.First(&cfg, id).Error; err != nil { return nil, err } return &cfg, nil } +// GetAllCoolifyConfigs retorna todas las instancias. +func GetAllCoolifyConfigs() ([]CoolifyConfig, error) { + var items []CoolifyConfig + err := app.Http.Database.DB.Order("id ASC").Find(&items).Error + return items, err +} + +func CreateCoolifyConfig(cfg *CoolifyConfig) error { + return app.Http.Database.DB.Create(cfg).Error +} + +func UpdateCoolifyConfig(id uint, nombre, baseURL, apiToken string, activo bool) (*CoolifyConfig, error) { + var cfg CoolifyConfig + if err := app.Http.Database.DB.First(&cfg, id).Error; err != nil { + return nil, err + } + cfg.Nombre = nombre + cfg.BaseURL = baseURL + if apiToken != "" { + cfg.ApiToken = apiToken + } + cfg.Activo = activo + if err := app.Http.Database.DB.Save(&cfg).Error; err != nil { + return nil, err + } + return &cfg, nil +} + +func DeleteCoolifyConfig(id uint) error { + return app.Http.Database.DB.Delete(&CoolifyConfig{}, id).Error +} + +// UpsertCoolifyConfig mantiene compatibilidad con código legado. func UpsertCoolifyConfig(nombre, baseURL, apiToken string, activo bool) (*CoolifyConfig, error) { var cfg CoolifyConfig err := app.Http.Database.DB.First(&cfg).Error if err != nil { - // No existe → crear - cfg = CoolifyConfig{ - Nombre: nombre, - BaseURL: baseURL, - ApiToken: apiToken, - Activo: activo, - } + cfg = CoolifyConfig{Nombre: nombre, BaseURL: baseURL, ApiToken: apiToken, Activo: activo} if createErr := app.Http.Database.DB.Create(&cfg).Error; createErr != nil { return nil, createErr } diff --git a/pkg/models/telegram_agent.go b/pkg/models/telegram_agent.go new file mode 100644 index 0000000..845bce0 --- /dev/null +++ b/pkg/models/telegram_agent.go @@ -0,0 +1,83 @@ +package models + +import ( + "github.com/sujit-baniya/fiber-boilerplate/app" + "gorm.io/gorm" +) + +// TelegramAgentHistory guarda el historial de conversación por chat_id. +// Se usan los últimos N mensajes como contexto en cada llamada al AI. +type TelegramAgentHistory struct { + gorm.Model + ChatID int64 `json:"chat_id" gorm:"column:chat_id;index;not null"` + Role string `json:"role" gorm:"column:role;not null"` // user | assistant | tool + Content string `json:"content" gorm:"column:content;type:text;not null"` + // ToolName y ToolResult se usan para mensajes de tipo "tool" + ToolName string `json:"tool_name" gorm:"column:tool_name"` + ToolResult string `json:"tool_result" gorm:"column:tool_result;type:text"` +} + +func (TelegramAgentHistory) TableName() string { return "telegram_agent_history" } + +// TelegramAgentChatID guarda qué chat_ids están autorizados a usar el agente. +type TelegramAgentAuth struct { + gorm.Model + ChatID int64 `json:"chat_id" gorm:"column:chat_id;uniqueIndex;not null"` + Nombre string `json:"nombre" gorm:"column:nombre"` + Activo bool `json:"activo" gorm:"column:activo;default:true"` +} + +func (TelegramAgentAuth) TableName() string { return "telegram_agent_auth" } + +// GetAgentHistory retorna los últimos n mensajes del historial para un chat_id. +func GetAgentHistory(chatID int64, n int) ([]TelegramAgentHistory, error) { + var items []TelegramAgentHistory + err := app.Http.Database.DB. + Where("chat_id = ?", chatID). + Order("created_at DESC"). + Limit(n). + Find(&items).Error + // Invertir para orden cronológico + for i, j := 0, len(items)-1; i < j; i, j = i+1, j-1 { + items[i], items[j] = items[j], items[i] + } + return items, err +} + +func SaveAgentMessage(chatID int64, role, content, toolName, toolResult string) error { + msg := &TelegramAgentHistory{ + ChatID: chatID, + Role: role, + Content: content, + ToolName: toolName, + ToolResult: toolResult, + } + return app.Http.Database.DB.Create(msg).Error +} + +// ClearAgentHistory borra el historial de un chat (comando /reset). +func ClearAgentHistory(chatID int64) error { + return app.Http.Database.DB.Where("chat_id = ?", chatID).Delete(&TelegramAgentHistory{}).Error +} + +// IsAgentAuthChat verifica si un chat_id está autorizado. +func IsAgentAuthChat(chatID int64) bool { + var auth TelegramAgentAuth + err := app.Http.Database.DB.Where("chat_id = ? AND activo = ?", chatID, true).First(&auth).Error + return err == nil +} + +func GetAllAgentAuth() ([]TelegramAgentAuth, error) { + var items []TelegramAgentAuth + err := app.Http.Database.DB.Order("created_at DESC").Find(&items).Error + return items, err +} + +func CreateAgentAuth(chatID int64, nombre string) error { + auth := &TelegramAgentAuth{ChatID: chatID, Nombre: nombre, Activo: true} + return app.Http.Database.DB.Create(auth).Error +} + +func DeleteAgentAuth(id uint) error { + return app.Http.Database.DB.Delete(&TelegramAgentAuth{}, id).Error +} diff --git a/pkg/services/telegram_agent_service.go b/pkg/services/telegram_agent_service.go new file mode 100644 index 0000000..ce2acd2 --- /dev/null +++ b/pkg/services/telegram_agent_service.go @@ -0,0 +1,865 @@ +package services + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" + + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// ─── Tipos para OpenAI-compatible function calling ──────────────────────────── + +type agentToolParam struct { + Type string `json:"type"` + Description string `json:"description,omitempty"` + Enum []string `json:"enum,omitempty"` + Properties map[string]agentToolParam `json:"properties,omitempty"` + Required []string `json:"required,omitempty"` + Items *agentToolParam `json:"items,omitempty"` +} + +type agentToolFunc struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters agentToolParam `json:"parameters"` +} + +type agentTool struct { + Type string `json:"type"` + Function agentToolFunc `json:"function"` +} + +type agentMessage struct { + Role string `json:"role"` + Content interface{} `json:"content"` // string o null + ToolCalls []agentToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Name string `json:"name,omitempty"` +} + +type agentToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` +} + +type agentChatReq struct { + Model string `json:"model"` + Messages []agentMessage `json:"messages"` + Tools []agentTool `json:"tools,omitempty"` + ToolChoice string `json:"tool_choice,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` +} + +type agentChatResp struct { + Choices []struct { + Message agentMessage `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +// ─── Definición de herramientas ─────────────────────────────────────────────── + +func agentTools() []agentTool { + str := func(desc string) agentToolParam { + return agentToolParam{Type: "string", Description: desc} + } + num := func(desc string) agentToolParam { + return agentToolParam{Type: "number", Description: desc} + } + obj := func(props map[string]agentToolParam, required []string) agentToolParam { + return agentToolParam{Type: "object", Properties: props, Required: required} + } + tool := func(name, desc string, params agentToolParam) agentTool { + return agentTool{Type: "function", Function: agentToolFunc{Name: name, Description: desc, Parameters: params}} + } + + return []agentTool{ + // ── Sistema ────────────────────────────────────────────────────────── + 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.", + 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.", + 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.", + 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.", + 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.", + 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"})), + + // ── Clientes ───────────────────────────────────────────────────────── + tool("listar_clientes", "Lista clientes con paginación y búsqueda.", + obj(map[string]agentToolParam{ + "page": num("Página (default 1)"), + "search": str("Texto de búsqueda"), + }, nil)), + tool("crear_cliente", "Crea un nuevo cliente.", + obj(map[string]agentToolParam{ + "nombre": str("Nombre del cliente"), + "empresa": str("Empresa"), + "email": str("Correo electrónico"), + "telefono": str("Teléfono"), + "documento": str("Documento de identidad"), + "notas": str("Notas adicionales"), + }, []string{"nombre"})), + + // ── Contratos ──────────────────────────────────────────────────────── + tool("listar_contratos", "Lista contratos con filtros.", + obj(map[string]agentToolParam{ + "page": num("Página"), + "search": str("Búsqueda"), + "estado": str("Estado del contrato"), + }, nil)), + tool("renovar_contrato", "Renueva un contrato por su ID.", + obj(map[string]agentToolParam{"id": num("ID del contrato")}, []string{"id"})), + tool("enviar_correo_contrato", "Envía notificación de renovación por correo al cliente.", + obj(map[string]agentToolParam{"id": num("ID del contrato")}, []string{"id"})), + + // ── Contabilidad ───────────────────────────────────────────────────── + tool("dashboard_contabilidad", "Resumen del mes: ingresos, egresos, pendientes.", + obj(map[string]agentToolParam{ + "mes": num("Mes (1-12)"), + "anio": num("Año (ej: 2026)"), + }, nil)), + tool("listar_transacciones", "Lista transacciones contables.", + obj(map[string]agentToolParam{ + "page": num("Página"), + "tipo": agentToolParam{Type: "string", Description: "ingreso | egreso", Enum: []string{"ingreso", "egreso", ""}}, + "mes": num("Mes (1-12)"), + "anio": num("Año"), + }, nil)), + tool("crear_transaccion", "Registra una transacción de ingreso o egreso.", + obj(map[string]agentToolParam{ + "tipo": agentToolParam{Type: "string", Description: "ingreso | egreso", Enum: []string{"ingreso", "egreso"}}, + "descripcion": str("Descripción"), + "valor": num("Valor en COP"), + "fecha": str("Fecha YYYY-MM-DD (vacío = hoy)"), + "notas": str("Notas"), + }, []string{"tipo", "descripcion", "valor"})), + tool("listar_cuentas_cobro", "Lista cuentas por cobrar (lo que nos deben).", + obj(map[string]agentToolParam{ + "page": num("Página"), + "estado": str("pendiente | pagada | vencida"), + }, nil)), + tool("listar_cuentas_pagar", "Lista cuentas por pagar (lo que debemos).", + obj(map[string]agentToolParam{ + "page": num("Página"), + "estado": str("pendiente | pagada | vencida"), + }, nil)), + + // ── Proyectos ──────────────────────────────────────────────────────── + tool("listar_proyectos", "Lista proyectos.", + obj(map[string]agentToolParam{ + "page": num("Página"), + "search": str("Búsqueda"), + }, nil)), + + // ── Tickets ────────────────────────────────────────────────────────── + tool("listar_tickets", "Lista tickets de soporte de todos los proyectos.", + obj(map[string]agentToolParam{ + "page": num("Página"), + "estado": str("abierto | cerrado | en_proceso"), + }, nil)), + tool("responder_ticket", "Responde un ticket de soporte.", + obj(map[string]agentToolParam{ + "ticket_id": num("ID del ticket"), + "mensaje": str("Mensaje de respuesta"), + }, []string{"ticket_id", "mensaje"})), + + // ── Tareas ─────────────────────────────────────────────────────────── + tool("listar_tareas", "Lista tareas del Kanban.", + obj(map[string]agentToolParam{ + "estado": str("pendiente | en_progreso | completada | cancelada"), + }, nil)), + tool("crear_tarea", "Crea una nueva tarea.", + obj(map[string]agentToolParam{ + "titulo": str("Título de la tarea"), + "descripcion": str("Descripción"), + "estado": agentToolParam{Type: "string", Enum: []string{"pendiente", "en_progreso"}, Description: "Estado inicial"}, + "prioridad": agentToolParam{Type: "string", Enum: []string{"baja", "media", "alta"}, Description: "Prioridad"}, + }, []string{"titulo"})), + tool("actualizar_estado_tarea", "Cambia el estado de una tarea.", + obj(map[string]agentToolParam{ + "id": num("ID de la tarea"), + "estado": agentToolParam{Type: "string", Enum: []string{"pendiente", "en_progreso", "completada", "cancelada"}, Description: "Nuevo estado"}, + }, []string{"id", "estado"})), + + // ── Servidores ─────────────────────────────────────────────────────── + tool("listar_servidores", "Lista los servidores monitoreados.", + obj(map[string]agentToolParam{"search": str("Búsqueda")}, nil)), + + // ── Monitor URLs ───────────────────────────────────────────────────── + tool("listar_monitores", "Lista los monitores de URL activos.", + obj(nil, nil)), + } +} + +// ─── Ejecución de herramientas ──────────────────────────────────────────────── + +func executeAgentTool(name string, args map[string]interface{}) string { + result, err := runTool(name, args) + if err != nil { + return fmt.Sprintf(`{"error": %q}`, err.Error()) + } + b, _ := json.Marshal(result) + return string(b) +} + +func runTool(name string, a map[string]interface{}) (interface{}, error) { + getInt := func(key string, def int) int { + if v, ok := a[key]; ok { + switch x := v.(type) { + case float64: + return int(x) + case int: + return x + } + } + return def + } + getStr := func(key string) string { + if v, ok := a[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" + } + getBool := func(key string) bool { + if v, ok := a[key]; ok { + if b, ok := v.(bool); ok { + return b + } + } + return false + } + + switch name { + + case "sistema_info": + cfgs, _ := models.GetAllCoolifyConfigs() + names := make([]string, len(cfgs)) + for i, c := range cfgs { + names[i] = fmt.Sprintf("#%d %s (%s)", c.ID, c.Nombre, c.BaseURL) + } + return map[string]interface{}{ + "sistema": "U-Site Admin", + "coolify_instancias": names, + "timestamp": time.Now().Format("2006-01-02 15:04:05"), + }, nil + + // ── Coolify ──────────────────────────────────────────────────────────── + case "coolify_instancias": + cfgs, err := models.GetAllCoolifyConfigs() + if err != nil { + return nil, err + } + type safe struct { + ID uint `json:"id"` + Nombre string `json:"nombre"` + BaseURL string `json:"base_url"` + Activo bool `json:"activo"` + } + out := make([]safe, len(cfgs)) + for i, c := range cfgs { + out[i] = safe{ID: c.ID, Nombre: c.Nombre, BaseURL: c.BaseURL, Activo: c.Activo} + } + 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) + if err != nil { + return nil, err + } + ep := fmt.Sprintf("/deploy?uuid=%s&force=%v", uuid, force) + return coolifyCall("POST", ep, nil, cfg) + + case "coolify_restart": + return coolifyAppAction(getInt("config_id", 0), getStr("uuid"), "restart") + + case "coolify_stop": + return coolifyAppAction(getInt("config_id", 0), getStr("uuid"), "stop") + + case "coolify_start": + return coolifyAppAction(getInt("config_id", 0), getStr("uuid"), "start") + + // ── Clientes ─────────────────────────────────────────────────────────── + case "listar_clientes": + page := getInt("page", 1) + search := getStr("search") + limit := 10 + offset := (page - 1) * limit + items, total, err := models.GetAllClientes(limit, offset, search) + if err != nil { + return nil, err + } + return map[string]interface{}{"items": items, "total": total, "page": page}, nil + + case "crear_cliente": + c := models.Cliente{ + Nombre: getStr("nombre"), + Empresa: getStr("empresa"), + Email: getStr("email"), + Telefono: getStr("telefono"), + Documento: getStr("documento"), + Notas: getStr("notas"), + Activo: true, + } + if err := models.CreateCliente(c); err != nil { + return nil, err + } + return map[string]interface{}{"ok": true, "nombre": c.Nombre}, nil + + // ── Contratos ────────────────────────────────────────────────────────── + case "listar_contratos": + page := getInt("page", 1) + search := getStr("search") + estado := getStr("estado") + limit := 10 + offset := (page - 1) * limit + items, total, err := models.GetAllContratos(limit, offset, search, estado) + if err != nil { + return nil, err + } + return map[string]interface{}{"items": items, "total": total, "page": page}, nil + + case "renovar_contrato": + // La lógica de renovación está en el controller; aquí solo informamos que se debe hacer desde la UI + id := uint(getInt("id", 0)) + if id == 0 { + return nil, fmt.Errorf("id requerido") + } + contrato, err := models.GetContratoByID(id) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "ok": true, + "info": "Para renovar usa la UI o el endpoint /api/v2/contratos/:id/renovar", + "contrato_id": id, + "cliente_id": contrato.ClienteID, + "fecha_vencimiento": contrato.FechaVencimiento, + }, nil + + case "enviar_correo_contrato": + id := uint(getInt("id", 0)) + if id == 0 { + return nil, fmt.Errorf("id requerido") + } + contrato, err := models.GetContratoByID(id) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "ok": true, + "info": "Usa el endpoint /api/v2/contratos/:id/enviar-correo para enviar", + "contrato_id": id, + "cliente_id": contrato.ClienteID, + }, nil + + // ── Contabilidad ─────────────────────────────────────────────────────── + case "dashboard_contabilidad": + now := time.Now() + mes := getInt("mes", int(now.Month())) + anio := getInt("anio", now.Year()) + dash, err := models.GetDashboardData(mes, anio) + if err != nil { + return nil, err + } + return dash, nil + + case "listar_transacciones": + page := getInt("page", 1) + tipo := getStr("tipo") + mes := getInt("mes", 0) + anio := getInt("anio", 0) + limit := 10 + offset := (page - 1) * limit + items, total, err := models.GetAllTransacciones(limit, offset, "", tipo, mes, anio) + if err != nil { + return nil, err + } + return map[string]interface{}{"items": items, "total": total, "page": page}, nil + + case "crear_transaccion": + t := &models.Transaccion{ + Tipo: getStr("tipo"), + Descripcion: getStr("descripcion"), + Valor: float64(getInt("valor", 0)), + Notas: getStr("notas"), + } + if err := models.CreateTransaccion(t); err != nil { + return nil, err + } + return map[string]interface{}{"ok": true, "id": t.ID}, nil + + case "listar_cuentas_cobro": + page := getInt("page", 1) + estado := getStr("estado") + limit := 10 + offset := (page - 1) * limit + items, total, err := models.GetAllCuentasCobro(limit, offset, "", estado) + if err != nil { + return nil, err + } + return map[string]interface{}{"items": items, "total": total, "page": page}, nil + + case "listar_cuentas_pagar": + page := getInt("page", 1) + estado := getStr("estado") + limit := 10 + offset := (page - 1) * limit + items, total, err := models.GetAllCuentasPagar(limit, offset, "", estado) + if err != nil { + return nil, err + } + return map[string]interface{}{"items": items, "total": total, "page": page}, nil + + // ── Proyectos ────────────────────────────────────────────────────────── + case "listar_proyectos": + page := getInt("page", 1) + search := getStr("search") + limit := 10 + offset := (page - 1) * limit + items, total, err := models.GetAllProyectos(limit, offset, search) + if err != nil { + return nil, err + } + return map[string]interface{}{"items": items, "total": total, "page": page}, nil + + // ── Tickets ──────────────────────────────────────────────────────────── + case "listar_tickets": + estado := getStr("estado") + items, err := models.GetAllTickets(estado) + if err != nil { + return nil, err + } + return items, nil + + case "responder_ticket": + ticketID := uint(getInt("ticket_id", 0)) + mensaje := getStr("mensaje") + if ticketID == 0 || mensaje == "" { + return nil, fmt.Errorf("ticket_id y mensaje requeridos") + } + m := &models.TicketMensaje{ + TicketID: ticketID, + AutorNombre: "Agente Bot", + Contenido: mensaje, + EsAdmin: true, + } + if err := models.CreateTicketMensaje(m); err != nil { + return nil, err + } + return map[string]interface{}{"ok": true}, nil + + // ── Tareas ───────────────────────────────────────────────────────────── + case "listar_tareas": + estado := getStr("estado") + allItems, err := models.GetAllTareas() + if err != nil { + return nil, err + } + if estado == "" { + return allItems, nil + } + var filtered []models.Tarea + for _, t := range allItems { + if t.Estado == estado { + filtered = append(filtered, t) + } + } + return filtered, nil + + case "crear_tarea": + t := &models.Tarea{ + Titulo: getStr("titulo"), + Descripcion: getStr("descripcion"), + Estado: getStr("estado"), + Prioridad: getStr("prioridad"), + } + if t.Estado == "" { + t.Estado = "pendiente" + } + if t.Prioridad == "" { + t.Prioridad = "media" + } + if err := models.CreateTarea(t); err != nil { + return nil, err + } + return map[string]interface{}{"ok": true, "id": t.ID, "titulo": t.Titulo}, nil + + case "actualizar_estado_tarea": + id := uint(getInt("id", 0)) + estado := getStr("estado") + if id == 0 || estado == "" { + return nil, fmt.Errorf("id y estado requeridos") + } + if err := models.CambiarEstadoTarea(id, estado); err != nil { + return nil, err + } + return map[string]interface{}{"ok": true, "id": id, "estado": estado}, nil + + // ── Servidores ───────────────────────────────────────────────────────── + case "listar_servidores": + search := getStr("search") + items, _, err := models.GetAllServidores(50, 0, search) + if err != nil { + return nil, err + } + return items, nil + + // ── Monitor URLs ─────────────────────────────────────────────────────── + case "listar_monitores": + items, err := models.GetAllUrlMonitors() + if err != nil { + return nil, err + } + return items, nil + + default: + return nil, fmt.Errorf("herramienta desconocida: %s", name) + } +} + +// ─── Coolify helpers internos ───────────────────────────────────────────────── + +func coolifyCall(method, endpoint string, body []byte, cfg *models.CoolifyConfig) (interface{}, error) { + base := strings.TrimRight(cfg.BaseURL, "/") + url := base + "/api/v1" + endpoint + + var reqBody io.Reader + if body != nil { + reqBody = bytes.NewReader(body) + } + req, err := http.NewRequest(method, url, reqBody) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+cfg.ApiToken) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + client := &http.Client{Timeout: 20 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) + + var result interface{} + if err := json.Unmarshal(raw, &result); err != nil { + return map[string]string{"raw": string(raw)}, nil + } + 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") + } + cfg, err := models.GetCoolifyConfigByID(uint(configIDInt)) + if err != nil { + return nil, err + } + return coolifyCall("GET", "/applications/"+uuid+"/"+action, nil, cfg) +} + +// ─── Llamada al AI con function calling ────────────────────────────────────── + +func callAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) { + baseURL := ai.BaseURL + if baseURL == "" { + baseURL = providerDefaultURL(ai.Provider) + } + baseURL = strings.TrimRight(baseURL, "/") + + reqBody := agentChatReq{ + Model: ai.ModelName, + Messages: messages, + Tools: tools, + ToolChoice: "auto", + MaxTokens: 4096, + } + + payload, _ := json.Marshal(reqBody) + req, err := http.NewRequest("POST", baseURL+"/chat/completions", bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+ai.ApiKey) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 120 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) + + var chatResp agentChatResp + if err := json.Unmarshal(raw, &chatResp); err != nil { + return nil, fmt.Errorf("respuesta inesperada del AI: %s", string(raw[:min(200, len(raw))])) + } + if chatResp.Error != nil { + return nil, fmt.Errorf("error del AI: %s", chatResp.Error.Message) + } + if len(chatResp.Choices) == 0 { + return nil, fmt.Errorf("el AI no devolvió respuesta") + } + msg := chatResp.Choices[0].Message + return &msg, nil +} + +func providerDefaultURL(provider string) string { + switch strings.ToLower(provider) { + case "openai": + return "https://api.openai.com/v1" + case "anthropic": + // Anthropic usa un formato diferente; para compatibilidad usar proxy OpenAI-compatible + return "https://api.openai.com/v1" + case "qwen", "dashscope": + return "https://dashscope.aliyuncs.com/compatible-mode/v1" + case "groq": + return "https://api.groq.com/openai/v1" + case "deepseek": + return "https://api.deepseek.com/v1" + default: + return "https://api.openai.com/v1" + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// ─── Sistema prompt del agente ──────────────────────────────────────────────── + +func agentSystemPrompt() string { + now := time.Now().Format("2006-01-02 15:04 MST") + return fmt.Sprintf(`Eres el asistente administrador de U-Site SAS BIC. Fecha y hora actual: %s. + +Tienes acceso total al sistema mediante herramientas. Puedes: +- Ver y administrar clientes, contratos, facturación y contabilidad +- Gestionar proyectos, tickets de soporte y tareas del equipo +- Controlar aplicaciones en las instancias de Coolify (deploy, restart, stop, logs) +- Monitorear servidores y URLs + +COMPORTAMIENTO: +- Responde siempre en español, de forma clara y concisa +- Usa las herramientas para obtener datos reales antes de responder +- Cuando el usuario pida una acción (deploy, renovar, crear), hazla directamente sin pedir confirmación salvo que sea destructiva +- 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 + +COMANDOS ESPECIALES (el usuario puede escribirlos): +- /reset — olvidar el historial de esta conversación +- /instancias — listar instancias de Coolify +- /ayuda — mostrar qué puedes hacer`, now) +} + +// ─── Motor principal del agente ─────────────────────────────────────────────── + +// ProcessAgentMessage procesa un mensaje de Telegram y retorna la respuesta del agente. +func ProcessAgentMessage(chatID int64, userText string, ai *models.AiConfig) (string, error) { + // Comandos especiales (sin AI) + switch strings.TrimSpace(userText) { + case "/reset": + if err := models.ClearAgentHistory(chatID); err != nil { + return "", err + } + return "Historial borrado. ¿En qué puedo ayudarte?", nil + case "/ayuda", "/start": + return `Soy el agente administrador de U-Site. Puedo ayudarte con: + +• Clientes y contratos +• Contabilidad (ingresos, egresos, cuentas por cobrar/pagar) +• Proyectos y tickets +• Tareas del equipo +• Deploy y gestión de apps en Coolify +• Monitoreo de servidores y URLs + +Escríbeme en lenguaje natural, por ejemplo: + "muéstrame los clientes de esta semana" + "haz deploy de la app abc123 en coolify 1" + "¿cuánto ingresamos este mes?" + +Comandos: /reset /instancias /ayuda`, nil + case "/instancias": + cfgs, err := models.GetAllCoolifyConfigs() + if err != nil { + return "", err + } + if len(cfgs) == 0 { + return "No hay instancias de Coolify configuradas.", nil + } + lines := make([]string, len(cfgs)) + for i, c := range cfgs { + estado := "✓ activa" + if !c.Activo { + estado = "✗ inactiva" + } + lines[i] = fmt.Sprintf("• #%d %s — %s (%s)", c.ID, c.Nombre, c.BaseURL, estado) + } + return "Instancias de Coolify:\n" + strings.Join(lines, "\n"), nil + } + + // Cargar historial (últimos 20 mensajes) + history, _ := models.GetAgentHistory(chatID, 20) + + // Construir mensajes para el AI + messages := []agentMessage{ + {Role: "system", Content: agentSystemPrompt()}, + } + for _, h := range history { + msg := agentMessage{Role: h.Role, Content: h.Content} + if h.Role == "tool" { + msg.ToolCallID = h.ToolName + msg.Content = h.ToolResult + } + messages = append(messages, msg) + } + messages = append(messages, agentMessage{Role: "user", Content: userText}) + + tools := agentTools() + + // Guardar mensaje del usuario en historial + _ = models.SaveAgentMessage(chatID, "user", userText, "", "") + + // Loop de function calling (máximo 6 rondas) + var finalResponse string + for round := 0; round < 6; round++ { + aiMsg, err := callAI(ai, messages, tools) + if err != nil { + log.Printf("[AGENT] Error llamando AI round %d: %v", round, err) + return "Error al contactar el sistema de IA. Intenta de nuevo.", err + } + + // Sin tool calls → respuesta final + if len(aiMsg.ToolCalls) == 0 { + content := "" + if s, ok := aiMsg.Content.(string); ok { + content = s + } + finalResponse = content + _ = models.SaveAgentMessage(chatID, "assistant", content, "", "") + break + } + + // Hay tool calls → ejecutar y continuar + // Agregar mensaje del AI con tool_calls al contexto + messages = append(messages, *aiMsg) + // Guardar en historial (serializado) + tcJSON, _ := json.Marshal(aiMsg.ToolCalls) + _ = models.SaveAgentMessage(chatID, "assistant", string(tcJSON), "", "") + + // Ejecutar cada tool call + for _, tc := range aiMsg.ToolCalls { + var toolArgs map[string]interface{} + _ = json.Unmarshal([]byte(tc.Function.Arguments), &toolArgs) + + log.Printf("[AGENT] Ejecutando tool: %s args: %s", tc.Function.Name, tc.Function.Arguments) + toolResult := executeAgentTool(tc.Function.Name, toolArgs) + + // Agregar resultado al contexto + messages = append(messages, agentMessage{ + Role: "tool", + ToolCallID: tc.ID, + Name: tc.Function.Name, + Content: toolResult, + }) + // Guardar en historial + _ = models.SaveAgentMessage(chatID, "tool", toolResult, tc.ID, toolResult) + } + } + + if finalResponse == "" { + finalResponse = "El agente completó las acciones solicitadas." + } + return finalResponse, nil +} diff --git a/pkg/services/telegram_service.go b/pkg/services/telegram_service.go index cd1ffec..88092cb 100755 --- a/pkg/services/telegram_service.go +++ b/pkg/services/telegram_service.go @@ -9,7 +9,7 @@ import ( ) type TelegramService struct { - BotToken string + BotToken string // exportado para uso desde otros paquetes } type TelegramMessage struct { diff --git a/rest/controllers/coolify_controller.go b/rest/controllers/coolify_controller.go index e8f9130..6a0b322 100644 --- a/rest/controllers/coolify_controller.go +++ b/rest/controllers/coolify_controller.go @@ -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) +} diff --git a/rest/controllers/telegram_agent_controller.go b/rest/controllers/telegram_agent_controller.go new file mode 100644 index 0000000..ecb6c92 --- /dev/null +++ b/rest/controllers/telegram_agent_controller.go @@ -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}) +} diff --git a/rest/routes/hermes.go b/rest/routes/hermes.go index fb5d67b..3f93bdc 100644 --- a/rest/routes/hermes.go +++ b/rest/routes/hermes.go @@ -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) diff --git a/rest/routes/publicas.go b/rest/routes/publicas.go index 945c675..5dd3ac9 100755 --- a/rest/routes/publicas.go +++ b/rest/routes/publicas.go @@ -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)