Files
soft_usite/pkg/services/telegram_agent_service.go
T
Lizandro GDandClaude Sonnet 5 516220a8a1 feat: automatización de cotizaciones, contratos, actas y cuentas de cobro con IA
Implementa las 4 fases de la especificación de automatización: módulo de
plantillas/tarifas editable por el equipo, generación de PDF (HTML+JS vía
Chrome headless) para cotizaciones/contratos/arquitecturas/cuentas de cobro,
chat propio en el dashboard reutilizando el mismo motor y tools del bot de
Telegram, y nuevas tools del agente para crear estos documentos end-to-end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 00:45:58 +00:00

1316 lines
47 KiB
Go

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"})),
// ── Facturas ─────────────────────────────────────────────────────────
tool("listar_facturas", "Lista facturas. Puede filtrar por estado: pendiente, pagada, vencida, cancelada.",
obj(map[string]agentToolParam{
"page": num("Página (default 1)"),
"search": str("Búsqueda por número, cliente o descripción"),
"estado": agentToolParam{Type: "string", Description: "pendiente | pagada | vencida | cancelada", Enum: []string{"pendiente", "pagada", "vencida", "cancelada", ""}},
}, nil)),
// ── 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"})),
tool("crear_contrato", "Crea un contrato para un cliente con uno o más servicios y genera de una vez su PDF con cláusulas estándar.",
obj(map[string]agentToolParam{
"cliente_id": num("ID del cliente (usa listar_clientes si no lo sabes)"),
"servicio_ids": agentToolParam{Type: "array", Description: "IDs de los servicios contratados", Items: &agentToolParam{Type: "number"}},
"duracion_meses": num("Duración del contrato en meses (default 12)"),
"precio_acordado": num("Valor total acordado"),
"moneda": str("Moneda, ej: COP, USD (default COP)"),
"notas": str("Notas adicionales del contrato"),
}, []string{"cliente_id", "servicio_ids", "precio_acordado"})),
tool("generar_documento_contrato", "Genera (o regenera) el PDF de un contrato ya existente a partir de la plantilla activa.",
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("crear_cuenta_cobro", "Crea una cuenta de cobro para un cliente/proyecto y genera de una vez su PDF de solicitud.",
obj(map[string]agentToolParam{
"cliente_id": num("ID del cliente"),
"descripcion": str("Descripción del cobro, ej: proyecto y periodo"),
"valor": num("Valor a cobrar"),
"notas": str("Notas adicionales"),
}, []string{"cliente_id", "descripcion", "valor"})),
tool("generar_documento_cuenta_cobro", "Genera (o regenera) el PDF de una cuenta de cobro ya existente.",
obj(map[string]agentToolParam{"id": num("ID de la cuenta de cobro")}, []string{"id"})),
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)),
// ── Automatización IA: cotizaciones y tarifas ───────────────────────────
tool("listar_tarifas", "Lista las tarifas activas (valor por hora, licencias, VMs, márgenes) para armar cotizaciones.",
obj(map[string]agentToolParam{
"categoria": str("Filtrar por categoría: hora_servicio | licencia | vm_azure | margen | otro"),
}, nil)),
tool("crear_cotizacion", "Genera el PDF de una cotización para un cliente, a partir de la plantilla activa y los items con precio ya calculado (consulta listar_tarifas antes para saber los valores).",
obj(map[string]agentToolParam{
"cliente_id": num("ID del cliente (usa listar_clientes si no lo sabes)"),
"alcance": str("Descripción del alcance del proyecto o servicio a cotizar"),
"tipo_proyecto": str("Tipo de proyecto, ej: migracion_m365, vm_azure, soporte"),
"items": agentToolParam{
Type: "array",
Description: "Ítems de la cotización con precio ya calculado",
Items: &agentToolParam{
Type: "object",
Properties: map[string]agentToolParam{
"descripcion": str("Descripción del ítem"),
"cantidad": num("Cantidad"),
"valor_unitario": num("Valor unitario en COP"),
"unidad": str("hora | mes | unico"),
},
Required: []string{"descripcion", "cantidad", "valor_unitario"},
},
},
}, []string{"cliente_id", "alcance", "items"})),
// ── Automatización IA: arquitecturas ────────────────────────────────────
tool("listar_arquitecturas_referencia", "Lista los patrones de arquitectura técnica ya resueltos (ej: AD redundante en Azure con VPN Gateway) para reutilizarlos en vez de improvisar.",
obj(map[string]agentToolParam{"search": str("Búsqueda por nombre o tag")}, nil)),
tool("generar_arquitectura", "Genera el PDF de una propuesta técnica ya redactada (consulta antes listar_arquitecturas_referencia para reutilizar patrones existentes).",
obj(map[string]agentToolParam{
"requerimiento": str("Requerimiento del cliente en texto libre"),
"propuesta": str("Propuesta técnica ya redactada (HTML o texto) que se insertará en el documento"),
"nombre": str("Título de la propuesta"),
"cliente_id": num("ID del cliente (opcional)"),
"guardar_como_referencia": agentToolParam{Type: "boolean", Description: "Si true, guarda esta propuesta como patrón reutilizable para el futuro"},
}, []string{"requerimiento", "propuesta"})),
// ── Proyectos ────────────────────────────────────────────────────────
tool("listar_proyectos", "Lista proyectos.",
obj(map[string]agentToolParam{
"page": num("Página"),
"search": str("Búsqueda"),
}, nil)),
tool("crear_proyecto", "Crea un nuevo proyecto para un cliente.",
obj(map[string]agentToolParam{
"cliente_id": num("ID del cliente (usa listar_clientes si no lo sabes)"),
"nombre": str("Nombre del proyecto"),
"stack": str("Stack tecnológico, ej: Go + React + PostgreSQL"),
"descripcion": str("Descripción adicional del proyecto"),
}, []string{"cliente_id", "nombre"})),
// ── 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_facturas":
page := getInt("page", 1)
search := getStr("search")
estado := getStr("estado")
limit := 10
offset := (page - 1) * limit
all, total, err := models.GetAllFacturas(limit, offset, search)
if err != nil {
return nil, err
}
// Filtrar por estado si se especificó
items := all
if estado != "" {
items = nil
for _, f := range all {
if f.Estado == estado {
items = append(items, f)
}
}
total = int64(len(items))
}
return map[string]interface{}{"items": items, "total": total, "page": page}, nil
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
case "crear_contrato":
idsRaw, _ := a["servicio_ids"].([]interface{})
servicioIDs := make([]uint, 0, len(idsRaw))
for _, v := range idsRaw {
switch x := v.(type) {
case float64:
servicioIDs = append(servicioIDs, uint(x))
case int:
servicioIDs = append(servicioIDs, uint(x))
}
}
contrato, doc, err := CrearContratoConDocumento(
uint(getInt("cliente_id", 0)),
servicioIDs,
getInt("duracion_meses", 12),
float64(getInt("precio_acordado", 0)),
getStr("moneda"),
getStr("notas"),
"telegram",
)
if err != nil {
return nil, err
}
result := map[string]interface{}{"ok": true, "contrato_id": contrato.ID}
if doc != nil {
result["documento_id"] = doc.ID
result["descargar"] = fmt.Sprintf("/api/v2/documentos-generados/%d/download", doc.ID)
}
return result, nil
case "generar_documento_contrato":
id := uint(getInt("id", 0))
if id == 0 {
return nil, fmt.Errorf("id requerido")
}
doc, err := GenerarDocumentoContrato(id, "telegram")
if err != nil {
return nil, err
}
return map[string]interface{}{
"ok": true,
"documento_id": doc.ID,
"descargar": fmt.Sprintf("/api/v2/documentos-generados/%d/download", doc.ID),
}, 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 "crear_cuenta_cobro":
cc, doc, err := CrearCuentaCobroConDocumento(
uint(getInt("cliente_id", 0)),
getStr("descripcion"),
float64(getInt("valor", 0)),
nil,
getStr("notas"),
"telegram",
)
if err != nil {
return nil, err
}
result := map[string]interface{}{"ok": true, "cuenta_cobro_id": cc.ID}
if doc != nil {
result["documento_id"] = doc.ID
result["descargar"] = fmt.Sprintf("/api/v2/documentos-generados/%d/download", doc.ID)
}
return result, nil
case "generar_documento_cuenta_cobro":
id := uint(getInt("id", 0))
if id == 0 {
return nil, fmt.Errorf("id requerido")
}
doc, err := GenerarDocumentoCuentaCobro(id, "telegram")
if err != nil {
return nil, err
}
return map[string]interface{}{
"ok": true,
"documento_id": doc.ID,
"descargar": fmt.Sprintf("/api/v2/documentos-generados/%d/download", doc.ID),
}, 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
// ── Automatización IA: cotizaciones y tarifas ───────────────────────────
case "listar_tarifas":
items, err := models.GetTarifasActivas(getStr("categoria"))
if err != nil {
return nil, err
}
return items, nil
case "crear_cotizacion":
itemsRaw, _ := a["items"].([]interface{})
items := make([]ItemCotizacion, 0, len(itemsRaw))
for _, raw := range itemsRaw {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
toFloat := func(v interface{}) float64 {
switch x := v.(type) {
case float64:
return x
case int:
return float64(x)
}
return 0
}
toStr := func(v interface{}) string {
s, _ := v.(string)
return s
}
items = append(items, ItemCotizacion{
Descripcion: toStr(m["descripcion"]),
Cantidad: toFloat(m["cantidad"]),
ValorUnitario: toFloat(m["valor_unitario"]),
Unidad: toStr(m["unidad"]),
})
}
doc, total, err := CrearCotizacion(uint(getInt("cliente_id", 0)), getStr("alcance"), getStr("tipo_proyecto"), items, "telegram")
if err != nil {
return nil, err
}
return map[string]interface{}{
"ok": true,
"documento_id": doc.ID,
"total": total,
"descargar": fmt.Sprintf("/api/v2/documentos-generados/%d/download", doc.ID),
}, nil
// ── Automatización IA: arquitecturas ────────────────────────────────────
case "listar_arquitecturas_referencia":
items, _, err := models.GetAllArquitecturas(50, 0, getStr("search"), true)
if err != nil {
return nil, err
}
return items, nil
case "generar_arquitectura":
var clienteID *uint
if id := getInt("cliente_id", 0); id > 0 {
u := uint(id)
clienteID = &u
}
doc, err := GenerarArquitectura(getStr("requerimiento"), getStr("propuesta"), getStr("nombre"), clienteID, getBool("guardar_como_referencia"), "telegram")
if err != nil {
return nil, err
}
return map[string]interface{}{
"ok": true,
"documento_id": doc.ID,
"descargar": fmt.Sprintf("/api/v2/documentos-generados/%d/download", doc.ID),
}, 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
case "crear_proyecto":
p, err := CrearProyectoSimple(uint(getInt("cliente_id", 0)), getStr("nombre"), getStr("stack"), getStr("descripcion"))
if err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "id": p.ID, "slug": p.Slug}, 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)
}
// ─── Tipos Anthropic ─────────────────────────────────────────────────────────
type anthropicTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema agentToolParam `json:"input_schema"`
}
type anthropicContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Content string `json:"content,omitempty"`
}
type anthropicMsg struct {
Role string `json:"role"`
Content interface{} `json:"content"` // string o []anthropicContentBlock
}
type anthropicReq struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System string `json:"system,omitempty"`
Messages []anthropicMsg `json:"messages"`
Tools []anthropicTool `json:"tools,omitempty"`
}
type anthropicResp struct {
Content []anthropicContentBlock `json:"content"`
StopReason string `json:"stop_reason"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error"`
}
// ─── Llamada al AI con function calling ──────────────────────────────────────
// callAI despacha al provider correcto (Anthropic o OpenAI-compatible).
func callAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) {
if strings.ToLower(ai.Provider) == "anthropic" {
return callAnthropicAI(ai, messages, tools)
}
return callOpenAICompatibleAI(ai, messages, tools)
}
// callOpenAICompatibleAI usa el formato de OpenAI (también vale para qwen, groq, deepseek, etc.)
func callOpenAICompatibleAI(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
}
// callAnthropicAI llama a la API nativa de Anthropic con tool use.
func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) {
// Convertir herramientas al formato Anthropic
anthropicTools := make([]anthropicTool, len(tools))
for i, t := range tools {
anthropicTools[i] = anthropicTool{
Name: t.Function.Name,
Description: t.Function.Description,
InputSchema: t.Function.Parameters,
}
}
// Extraer system prompt y convertir mensajes
var systemPrompt string
var anthropicMsgs []anthropicMsg
for _, m := range messages {
switch m.Role {
case "system":
if s, ok := m.Content.(string); ok {
systemPrompt = s
}
case "user":
content := ""
if s, ok := m.Content.(string); ok {
content = s
}
// Si el último mensaje ya es user, agregar tool_result como bloque adicional
if len(anthropicMsgs) > 0 && anthropicMsgs[len(anthropicMsgs)-1].Role == "user" {
last := anthropicMsgs[len(anthropicMsgs)-1]
if blocks, ok := last.Content.([]anthropicContentBlock); ok {
anthropicMsgs[len(anthropicMsgs)-1].Content = append(blocks, anthropicContentBlock{
Type: "text",
Text: content,
})
continue
}
}
anthropicMsgs = append(anthropicMsgs, anthropicMsg{Role: "user", Content: content})
case "assistant":
if len(m.ToolCalls) > 0 {
// Convertir tool_calls a bloques tool_use
var blocks []anthropicContentBlock
if s, ok := m.Content.(string); ok && s != "" {
blocks = append(blocks, anthropicContentBlock{Type: "text", Text: s})
}
for _, tc := range m.ToolCalls {
blocks = append(blocks, anthropicContentBlock{
Type: "tool_use",
ID: tc.ID,
Name: tc.Function.Name,
Input: json.RawMessage(tc.Function.Arguments),
})
}
anthropicMsgs = append(anthropicMsgs, anthropicMsg{Role: "assistant", Content: blocks})
} else {
content := ""
if s, ok := m.Content.(string); ok {
content = s
}
anthropicMsgs = append(anthropicMsgs, anthropicMsg{Role: "assistant", Content: content})
}
case "tool":
// Los resultados de tool deben ir en un mensaje de usuario con tipo tool_result
block := anthropicContentBlock{
Type: "tool_result",
ToolUseID: m.ToolCallID,
Content: fmt.Sprintf("%v", m.Content),
}
// Agrupar en el último mensaje user si existe, o crear uno nuevo
if len(anthropicMsgs) > 0 && anthropicMsgs[len(anthropicMsgs)-1].Role == "user" {
last := anthropicMsgs[len(anthropicMsgs)-1]
switch c := last.Content.(type) {
case []anthropicContentBlock:
anthropicMsgs[len(anthropicMsgs)-1].Content = append(c, block)
default:
anthropicMsgs[len(anthropicMsgs)-1].Content = []anthropicContentBlock{block}
}
} else {
anthropicMsgs = append(anthropicMsgs, anthropicMsg{
Role: "user",
Content: []anthropicContentBlock{block},
})
}
}
}
reqBody := anthropicReq{
Model: ai.ModelName,
MaxTokens: 4096,
System: systemPrompt,
Messages: anthropicMsgs,
Tools: anthropicTools,
}
payload, _ := json.Marshal(reqBody)
req, err := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("x-api-key", ai.ApiKey)
req.Header.Set("anthropic-version", "2023-06-01")
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 anthropicRsp anthropicResp
if err := json.Unmarshal(raw, &anthropicRsp); err != nil {
return nil, fmt.Errorf("respuesta inesperada de Anthropic: %s", string(raw[:min(200, len(raw))]))
}
if anthropicRsp.Error != nil {
return nil, fmt.Errorf("error de Anthropic: %s", anthropicRsp.Error.Message)
}
// Convertir respuesta Anthropic → agentMessage (formato interno OpenAI)
result := &agentMessage{Role: "assistant"}
var textParts []string
var toolCalls []agentToolCall
for _, block := range anthropicRsp.Content {
switch block.Type {
case "text":
if block.Text != "" {
textParts = append(textParts, block.Text)
}
case "tool_use":
inputJSON := "{}"
if block.Input != nil {
inputJSON = string(block.Input)
}
toolCalls = append(toolCalls, agentToolCall{
ID: block.ID,
Type: "function",
Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{Name: block.Name, Arguments: inputJSON},
})
}
}
if len(textParts) > 0 {
result.Content = strings.Join(textParts, "\n")
}
result.ToolCalls = toolCalls
return result, nil
}
func providerDefaultURL(provider string) string {
switch strings.ToLower(provider) {
case "openai":
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)
// Solo cargar user + assistant (texto), sin tool_calls ni tool_results intermedios.
// Esto mantiene el hilo conversacional sin reenviar JSON de datos que ya fueron procesados.
history, _ := models.GetAgentHistory(chatID, 10)
messages := []agentMessage{
{Role: "system", Content: agentSystemPrompt()},
}
for _, h := range history {
if h.Role != "user" && h.Role != "assistant" {
continue
}
messages = append(messages, agentMessage{Role: h.Role, Content: h.Content})
}
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 (solo en memoria, no en BD)
messages = append(messages, *aiMsg)
// 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 (solo en memoria, no en BD)
messages = append(messages, agentMessage{
Role: "tool",
ToolCallID: tc.ID,
Name: tc.Function.Name,
Content: toolResult,
})
}
}
if finalResponse == "" {
finalResponse = "El agente completó las acciones solicitadas."
}
return finalResponse, nil
}