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:
co-authored by
Claude Sonnet 4.6
parent
50812749ea
commit
33cfe4fdb0
@@ -86,8 +86,13 @@ func main() {
|
|||||||
&models.PortalPasswordResetToken{},
|
&models.PortalPasswordResetToken{},
|
||||||
// Integración VCard API Admin
|
// Integración VCard API Admin
|
||||||
&models.VcardApiConfig{},
|
&models.VcardApiConfig{},
|
||||||
// Integración Coolify
|
// Integración Coolify (multi-instancia)
|
||||||
&models.CoolifyConfig{},
|
&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
|
// Servidores: nuevos campos de agente + tabla join de integraciones
|
||||||
&models.Servidor{},
|
&models.Servidor{},
|
||||||
&models.ConxDb{},
|
&models.ConxDb{},
|
||||||
|
|||||||
+21
-1
@@ -24,7 +24,11 @@ type AiConfig struct {
|
|||||||
// "" = global (disponible para todos como fallback)
|
// "" = global (disponible para todos como fallback)
|
||||||
// "landing" = exclusivo para Landing Generator
|
// "landing" = exclusivo para Landing Generator
|
||||||
// "query_runner" = exclusivo para Query Runner SQL
|
// "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" }
|
func (AiConfig) TableName() string { return "ai_configs" }
|
||||||
@@ -107,6 +111,22 @@ func GetAiConfigSelect() ([]AiConfig, error) {
|
|||||||
return items, nil
|
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.
|
// GetAiConfigForService retorna la config activa asignada al módulo indicado.
|
||||||
// Lógica de prioridad:
|
// Lógica de prioridad:
|
||||||
// 1. Config activa con modulo conteniendo service (puede ser comma-separated)
|
// 1. Config activa con modulo conteniendo service (puede ser comma-separated)
|
||||||
|
|||||||
@@ -17,25 +17,65 @@ type CoolifyConfig struct {
|
|||||||
|
|
||||||
func (CoolifyConfig) TableName() string { return "coolify_configs" }
|
func (CoolifyConfig) TableName() string { return "coolify_configs" }
|
||||||
|
|
||||||
|
// GetCoolifyConfig retorna la primera instancia activa (compatibilidad legacy).
|
||||||
func GetCoolifyConfig() (*CoolifyConfig, error) {
|
func GetCoolifyConfig() (*CoolifyConfig, error) {
|
||||||
var cfg CoolifyConfig
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &cfg, nil
|
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) {
|
func UpsertCoolifyConfig(nombre, baseURL, apiToken string, activo bool) (*CoolifyConfig, error) {
|
||||||
var cfg CoolifyConfig
|
var cfg CoolifyConfig
|
||||||
err := app.Http.Database.DB.First(&cfg).Error
|
err := app.Http.Database.DB.First(&cfg).Error
|
||||||
if err != nil {
|
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 {
|
if createErr := app.Http.Database.DB.Create(&cfg).Error; createErr != nil {
|
||||||
return nil, createErr
|
return nil, createErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 <b>%s</b> — %s (%s)", c.ID, c.Nombre, c.BaseURL, estado)
|
||||||
|
}
|
||||||
|
return "<b>Instancias de Coolify:</b>\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
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type TelegramService struct {
|
type TelegramService struct {
|
||||||
BotToken string
|
BotToken string // exportado para uso desde otros paquetes
|
||||||
}
|
}
|
||||||
|
|
||||||
type TelegramMessage struct {
|
type TelegramMessage struct {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -12,6 +14,16 @@ import (
|
|||||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
"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 ────────────────────────────────────────────────────────
|
// ─── helpers internos ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// coolifyDo ejecuta una petición a la API de Coolify y devuelve el body crudo.
|
// 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
|
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.
|
// coolifyProxy resuelve config, aplica qs de la request y responde al frontend.
|
||||||
func coolifyProxy(c *fiber.Ctx, method, endpoint string) error {
|
func coolifyProxy(c *fiber.Ctx, method, endpoint string) error {
|
||||||
cfg, err := models.GetCoolifyConfig()
|
cfg, err := coolifyResolveConfig(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Configura primero la integración Coolify"})
|
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"})
|
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
|
ep := endpoint
|
||||||
if qs := string(c.Request().URI().QueryString()); qs != "" {
|
if qs != "" {
|
||||||
ep = endpoint + "?" + qs
|
ep = endpoint + "?" + qs
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,3 +351,105 @@ func CoolifyTeamMembers(c *fiber.Ctx) error {
|
|||||||
func CoolifyWebhook(c *fiber.Ctx) error {
|
func CoolifyWebhook(c *fiber.Ctx) error {
|
||||||
return c.SendStatus(fiber.StatusOK)
|
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
@@ -349,20 +349,57 @@ func AdminApiRoutes(api fiber.Router) {
|
|||||||
h.Put("/cloudflare/zones/:zone_id/dns/:record_id", controllers.UpdateCloudflareDNS)
|
h.Put("/cloudflare/zones/:zone_id/dns/:record_id", controllers.UpdateCloudflareDNS)
|
||||||
h.Delete("/cloudflare/zones/:zone_id/dns/:record_id", controllers.DeleteCloudflareDNS)
|
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", controllers.CoolifyListApplications)
|
||||||
h.Get("/coolify/apps/:uuid", controllers.CoolifyGetApplication)
|
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/start", controllers.CoolifyApplicationStart)
|
||||||
h.Get("/coolify/apps/:uuid/stop", controllers.CoolifyApplicationStop)
|
h.Get("/coolify/apps/:uuid/stop", controllers.CoolifyApplicationStop)
|
||||||
h.Get("/coolify/apps/:uuid/restart", controllers.CoolifyApplicationRestart)
|
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.Post("/coolify/apps/:uuid/deploy", controllers.CoolifyApplicationDeploy)
|
||||||
h.Get("/coolify/servers", controllers.CoolifyListServers)
|
h.Get("/coolify/servers", controllers.CoolifyListServers)
|
||||||
h.Get("/coolify/servers/:uuid", controllers.CoolifyGetServer)
|
h.Get("/coolify/servers/:uuid", controllers.CoolifyGetServer)
|
||||||
h.Get("/coolify/servers/:uuid/resources", controllers.CoolifyServerResources)
|
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", 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", 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", 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", 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 ───────────────────────────────────────────────────────────
|
// ─── VCard API ───────────────────────────────────────────────────────────
|
||||||
h.Get("/vcard-api/config", controllers.VcardApiGetConfig)
|
h.Get("/vcard-api/config", controllers.VcardApiGetConfig)
|
||||||
|
|||||||
@@ -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
|
// Configurar en el bot: POST https://api.telegram.org/bot{TOKEN}/setWebhook?url={HOST}/webhooks/telegram-portal
|
||||||
web.Post("/webhooks/telegram-portal", controllers.TelegramPortalWebhook)
|
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 ──────────────────
|
// ─── WebSMS (LabsMobile) — API de envío + webhooks ──────────────────
|
||||||
web.Post("/api/sms/send", controllers.ApiSendSms)
|
web.Post("/api/sms/send", controllers.ApiSendSms)
|
||||||
web.Post("/webhooks/websms/delivery", controllers.WebSmsDeliveryWebhook)
|
web.Post("/webhooks/websms/delivery", controllers.WebSmsDeliveryWebhook)
|
||||||
|
|||||||
Reference in New Issue
Block a user