Files
soft_usite/pkg/services/telegram_agent_service.go
T
Lizandro GD 0eef3f80a3 Corrige tablas faltantes en producción y bug de listado en Entidades
- AutoMigrate migraba ~50 modelos en un solo llamado que se detiene en
  el primer error, dejando todo lo posterior (plantillas_documento,
  tarifas, documentos_generados, arquitecturas, telegram_staff_tokens)
  sin tabla, en silencio. Ahora se migra modelo por modelo: uno roto
  ya no bloquea al resto.
- /app/contabilidad/entidades pedía datos a la misma URL de la página
  (devuelve HTML) en vez de /list (JSON) — el listado nunca traía nada.
- Mensaje de bienvenida del asistente: estaba generado por la IA cada
  vez con calidad variable; ahora es determinístico para /ayuda y
  /start, con redacción revisada, y el system prompt pide el mismo
  formato cuando el usuario solo saluda.
2026-08-07 03:00:02 +00:00

1674 lines
66 KiB
Go

package services
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"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"`
// Adjunto opcional para que el modelo lea el archivo real (solo Anthropic).
AttachmentBase64 string `json:"-"`
AttachmentMime string `json:"-"`
}
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. Úsala primero para saber qué config_id existe si el usuario no lo especifica.", obj(nil, nil)),
tool("coolify_listar", "Lista recursos de una instancia Coolify: aplicaciones, servicios, bases de datos, servidores, proyectos, deployments recientes o miembros del equipo.",
obj(map[string]agentToolParam{
"config_id": num("ID de la instancia Coolify"),
"tipo": agentToolParam{
Type: "string",
Description: "Qué listar",
Enum: []string{"apps", "servicios", "bases_datos", "servidores", "proyectos", "deployments", "equipo"},
},
}, []string{"config_id", "tipo"})),
tool("coolify_detalle", "Muestra el detalle de un recurso concreto de Coolify por su UUID.",
obj(map[string]agentToolParam{
"config_id": num("ID de la instancia Coolify"),
"tipo": agentToolParam{
Type: "string",
Description: "Tipo de recurso",
Enum: []string{"app", "servicio", "base_datos", "servidor", "proyecto", "deployment"},
},
"uuid": str("UUID del recurso"),
}, []string{"config_id", "tipo", "uuid"})),
tool("coolify_accion", "Ejecuta una acción sobre una aplicación, servicio o base de datos en Coolify: iniciar, detener, reiniciar o desplegar. 'deploy' solo aplica a aplicaciones.",
obj(map[string]agentToolParam{
"config_id": num("ID de la instancia Coolify"),
"tipo": agentToolParam{
Type: "string",
Description: "Sobre qué se ejecuta la acción",
Enum: []string{"app", "servicio", "base_datos"},
},
"uuid": str("UUID del recurso"),
"accion": agentToolParam{
Type: "string",
Description: "Acción a ejecutar",
Enum: []string{"start", "stop", "restart", "deploy"},
},
"force": agentToolParam{Type: "boolean", Description: "Solo para deploy: forzar rebuild desde cero sin usar caché"},
}, []string{"config_id", "tipo", "uuid", "accion"})),
tool("coolify_logs", "Obtiene los logs recientes de una aplicación en Coolify. Útil para diagnosticar por qué algo falla.",
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, con su estado (para saber si un despliegue terminó bien).",
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_envs", "Lista las variables de entorno de una aplicación o servicio en Coolify. Los valores pueden contener secretos: nunca los repitas completos en la respuesta salvo que el usuario lo pida explícitamente.",
obj(map[string]agentToolParam{
"config_id": num("ID de la instancia Coolify"),
"tipo": agentToolParam{Type: "string", Description: "Tipo de recurso", Enum: []string{"app", "servicio"}},
"uuid": str("UUID del recurso"),
}, []string{"config_id", "tipo", "uuid"})),
tool("coolify_servidor_info", "Consulta información de un servidor de Coolify: recursos desplegados, dominios configurados, o valida su conexión.",
obj(map[string]agentToolParam{
"config_id": num("ID de la instancia Coolify"),
"uuid": str("UUID del servidor"),
"info": agentToolParam{
Type: "string",
Description: "Qué consultar",
Enum: []string{"recursos", "dominios", "validar"},
},
}, []string{"config_id", "uuid", "info"})),
// ── 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)),
tool("adjuntar_factura", "Guarda como factura de VENTA el documento (PDF/foto) que el usuario acaba de enviar por Telegram: U-SITE es quien factura/cobra al cliente. Solo funciona si hay un archivo adjunto pendiente. Si el documento es al revés (un proveedor le factura a U-SITE), usa adjuntar_factura_compra en su lugar, no esta.",
obj(map[string]agentToolParam{
"cliente_id": num("ID del cliente al que pertenece la factura (usa listar_clientes si no lo sabes)"),
"numero": str("Número de factura, si el usuario lo indicó"),
"monto": num("Monto de la factura, si el usuario lo indicó"),
"descripcion": str("Descripción breve, ej: 'Factura mensual octubre'"),
}, []string{"cliente_id"})),
tool("adjuntar_factura_compra", "Guarda como factura de COMPRA (cuenta por pagar) el documento que el usuario acaba de enviar por Telegram: un proveedor le está facturando a U-SITE, no al revés. Busca o crea el proveedor por nombre. Solo funciona si hay un archivo adjunto pendiente.",
obj(map[string]agentToolParam{
"proveedor": str("Nombre del proveedor que emitió la factura (quien la firma/factura, no U-SITE)"),
"monto": num("Monto de la factura"),
"descripcion": str("Descripción breve de la compra, ej: 'Hosting AWS octubre'"),
}, []string{"proveedor", "monto"})),
// ── 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"})),
tool("crear_fase_proyecto", "Agrega una fase/etapa a un proyecto existente (ej: Diseño, Desarrollo, QA, Entrega). Se puede llamar varias veces seguidas para registrar todas las fases de un proyecto de una vez.",
obj(map[string]agentToolParam{
"proyecto_id": num("ID del proyecto (usa listar_proyectos si no lo sabes)"),
"nombre": str("Nombre de la fase"),
"descripcion": str("Descripción de la fase"),
"estado": agentToolParam{Type: "string", Enum: []string{"pendiente", "en_progreso", "completado"}, Description: "Estado inicial (default pendiente)"},
"orden": num("Orden de la fase dentro del proyecto (0, 1, 2...)"),
"fecha_estimada": str("Fecha estimada de entrega, formato YYYY-MM-DD"),
}, []string{"proyecto_id", "nombre"})),
tool("adjuntar_documento_proyecto", "Guarda como documento del proyecto el archivo (PDF, foto, Word, Excel o cualquier otro tipo) que el usuario acaba de enviar por Telegram en este chat. Solo funciona si hay un archivo adjunto pendiente.",
obj(map[string]agentToolParam{
"proyecto_id": num("ID del proyecto (usa listar_proyectos si no lo sabes)"),
"tipo": agentToolParam{Type: "string", Enum: []string{"contrato", "orden_servicio", "otro"}, Description: "Tipo de documento (default otro)"},
"nombre": str("Nombre del documento"),
"descripcion": str("Descripción breve"),
}, []string{"proyecto_id"})),
// ── 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("listar_usuarios", "Lista los usuarios internos del sistema (staff), para poder asignarles tareas.",
obj(map[string]agentToolParam{"search": str("Búsqueda por nombre")}, nil)),
tool("crear_tarea", "Crea una nueva tarea, opcionalmente asignada a alguien desde el inicio.",
obj(map[string]agentToolParam{
"titulo": str("Título de la tarea"),
"descripcion": str("Descripción"),
"estado": agentToolParam{Type: "string", Enum: []string{"por_hacer", "en_progreso"}, Description: "Estado inicial (default por_hacer). Debe coincidir con las columnas del tablero Kanban."},
"prioridad": agentToolParam{Type: "string", Enum: []string{"baja", "media", "alta"}, Description: "Prioridad"},
"asignado_id": num("ID del usuario responsable (usa listar_usuarios si no lo sabes)"),
"fecha_limite": str("Fecha límite, formato YYYY-MM-DD"),
}, []string{"titulo"})),
tool("asignar_tarea", "Asigna (o reasigna) una tarea existente a un usuario. Notifica al usuario asignado.",
obj(map[string]agentToolParam{
"id": num("ID de la tarea"),
"asignado_id": num("ID del usuario responsable (usa listar_usuarios si no lo sabes)"),
}, []string{"id", "asignado_id"})),
tool("adjuntar_documento_tarea", "Guarda como comentario de una tarea el archivo (PDF, foto, Word, Excel o cualquier otro tipo) que el usuario acaba de enviar por Telegram en este chat. Úsala cuando el usuario mande un archivo junto con instrucciones sobre una tarea (nueva o existente), sin importar el formato. Solo funciona si hay un archivo adjunto pendiente. Si la tarea es nueva, primero créala con crear_tarea y usa el id que devuelve.",
obj(map[string]agentToolParam{
"id": num("ID de la tarea a la que se adjunta el archivo"),
"comentario": str("Comentario que acompaña al archivo (opcional)"),
}, []string{"id"})),
tool("actualizar_estado_tarea", "Cambia el estado de una tarea. Los valores deben coincidir con las columnas del tablero Kanban del dashboard.",
obj(map[string]agentToolParam{
"id": num("ID de la tarea"),
"estado": agentToolParam{
Type: "string",
Enum: []string{"por_hacer", "en_progreso", "revision", "hecho"},
Description: "por_hacer=nueva, en_progreso=en curso, revision=terminada pendiente de revisar, hecho=cerrada",
},
}, []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(chatID int64, name string, args map[string]interface{}) string {
result, err := runTool(chatID, name, args)
if err != nil {
return fmt.Sprintf(`{"error": %q}`, err.Error())
}
b, _ := json.Marshal(result)
return string(b)
}
func runTool(chatID int64, 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_listar":
cfg, err := coolifyConfigDe(getInt("config_id", 0))
if err != nil {
return nil, err
}
endpoint, ok := map[string]string{
"apps": "/applications",
"servicios": "/services",
"bases_datos": "/databases",
"servidores": "/servers",
"proyectos": "/projects",
"deployments": "/deployments",
"equipo": "/teams/current/members",
}[getStr("tipo")]
if !ok {
return nil, fmt.Errorf("tipo inválido: usa apps, servicios, bases_datos, servidores, proyectos, deployments o equipo")
}
return coolifyCall("GET", endpoint, nil, cfg)
case "coolify_detalle":
cfg, err := coolifyConfigDe(getInt("config_id", 0))
if err != nil {
return nil, err
}
uuid := getStr("uuid")
if uuid == "" {
return nil, fmt.Errorf("uuid requerido")
}
base, ok := coolifyBasePorTipo(getStr("tipo"))
if !ok {
if getStr("tipo") == "deployment" {
return coolifyCall("GET", "/deployments/"+uuid, nil, cfg)
}
return nil, fmt.Errorf("tipo inválido: usa app, servicio, base_datos, servidor, proyecto o deployment")
}
return coolifyCall("GET", base+"/"+uuid, nil, cfg)
case "coolify_accion":
cfg, err := coolifyConfigDe(getInt("config_id", 0))
if err != nil {
return nil, err
}
uuid := getStr("uuid")
if uuid == "" {
return nil, fmt.Errorf("uuid requerido")
}
tipo := getStr("tipo")
accion := getStr("accion")
if accion == "deploy" {
if tipo != "app" {
return nil, fmt.Errorf("'deploy' solo aplica a aplicaciones; para %s usa start, stop o restart", tipo)
}
return coolifyCall("POST", fmt.Sprintf("/deploy?uuid=%s&force=%v", uuid, getBool("force")), nil, cfg)
}
if accion != "start" && accion != "stop" && accion != "restart" {
return nil, fmt.Errorf("acción inválida: usa start, stop, restart o deploy")
}
base, ok := coolifyBasePorTipo(tipo)
if !ok || tipo == "servidor" || tipo == "proyecto" {
return nil, fmt.Errorf("tipo inválido para una acción: usa app, servicio o base_datos")
}
return coolifyCall("GET", fmt.Sprintf("%s/%s/%s", base, uuid, accion), nil, cfg)
case "coolify_logs", "coolify_deployments":
cfg, err := coolifyConfigDe(getInt("config_id", 0))
if err != nil {
return nil, err
}
uuid := getStr("uuid")
if uuid == "" {
return nil, fmt.Errorf("uuid requerido")
}
sufijo := "/logs"
if name == "coolify_deployments" {
sufijo = "/deployments"
}
return coolifyCall("GET", "/applications/"+uuid+sufijo, nil, cfg)
case "coolify_envs":
cfg, err := coolifyConfigDe(getInt("config_id", 0))
if err != nil {
return nil, err
}
uuid := getStr("uuid")
if uuid == "" {
return nil, fmt.Errorf("uuid requerido")
}
base, ok := coolifyBasePorTipo(getStr("tipo"))
if !ok || (getStr("tipo") != "app" && getStr("tipo") != "servicio") {
return nil, fmt.Errorf("tipo inválido: las variables de entorno solo se consultan de app o servicio")
}
return coolifyCall("GET", base+"/"+uuid+"/envs", nil, cfg)
case "coolify_servidor_info":
cfg, err := coolifyConfigDe(getInt("config_id", 0))
if err != nil {
return nil, err
}
uuid := getStr("uuid")
if uuid == "" {
return nil, fmt.Errorf("uuid requerido")
}
sufijo, ok := map[string]string{
"recursos": "/resources",
"dominios": "/domains",
"validar": "/validate",
}[getStr("info")]
if !ok {
return nil, fmt.Errorf("info inválida: usa recursos, dominios o validar")
}
return coolifyCall("GET", "/servers/"+uuid+sufijo, nil, cfg)
// ── 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 "adjuntar_factura":
att, ok := PopTelegramAttachment(chatID)
if !ok {
return nil, fmt.Errorf("no hay ningún documento pendiente en este chat; pide al usuario que lo envíe de nuevo")
}
clienteID := uint(getInt("cliente_id", 0))
if clienteID == 0 {
return nil, fmt.Errorf("cliente_id requerido")
}
f := &models.Factura{
ClienteID: clienteID,
Numero: getStr("numero"),
Descripcion: getStr("descripcion"),
Monto: float64(getInt("monto", 0)),
Estado: "pendiente",
FechaEmision: time.Now(),
Visible: true,
}
if err := models.CreateFactura(f); err != nil {
_ = os.Remove(att.Path)
return nil, err
}
finalDir := "uploads/facturas"
if err := os.MkdirAll(finalDir, 0755); err != nil {
return nil, err
}
ext := filepath.Ext(att.OriginalName)
finalPath := filepath.Join(finalDir, fmt.Sprintf("%d_telegram%s", f.ID, ext))
if err := os.Rename(att.Path, finalPath); err != nil {
return nil, fmt.Errorf("no se pudo guardar el archivo: %w", err)
}
if err := models.UpdateFacturaArchivo(f.ID, finalPath, att.OriginalName); err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "factura_id": f.ID}, nil
case "adjuntar_factura_compra":
cp, err := AdjuntarFacturaCompra(chatID, getStr("proveedor"), getStr("descripcion"), float64(getInt("monto", 0)))
if err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "cuenta_pagar_id": cp.ID, "proveedor_id": cp.EntidadID}, 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
case "crear_fase_proyecto":
var fecha *time.Time
if s := getStr("fecha_estimada"); s != "" {
if t, errParse := time.Parse("2006-01-02", s); errParse == nil {
fecha = &t
}
}
f, err := CrearFaseProyecto(uint(getInt("proyecto_id", 0)), getStr("nombre"), getStr("descripcion"), getStr("estado"), getInt("orden", 0), fecha)
if err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "fase_id": f.ID}, nil
case "adjuntar_documento_proyecto":
d, err := AdjuntarDocumentoProyecto(chatID, uint(getInt("proyecto_id", 0)), getStr("tipo"), getStr("nombre"), getStr("descripcion"))
if err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "documento_id": d.ID}, 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 "listar_usuarios":
users, _, err := models.AllUsersSistema(50, 0, getStr("search"))
if err != nil {
return nil, err
}
type usuarioItem struct {
ID uint `json:"id"`
Name string `json:"name"`
}
out := make([]usuarioItem, len(users))
for i, u := range users {
out[i] = usuarioItem{ID: u.ID, Name: u.Name}
}
return out, nil
case "crear_tarea":
var asignadoID *uint
if v := getInt("asignado_id", 0); v > 0 {
u := uint(v)
asignadoID = &u
}
var fechaLimite *time.Time
if s := getStr("fecha_limite"); s != "" {
if t, errParse := time.Parse("2006-01-02", s); errParse == nil {
fechaLimite = &t
}
}
t, err := CrearTareaAsignada(getStr("titulo"), getStr("descripcion"), getStr("estado"), getStr("prioridad"), asignadoID, fechaLimite)
if err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "id": t.ID, "titulo": t.Titulo}, nil
case "asignar_tarea":
id := uint(getInt("id", 0))
asignadoIDVal := getInt("asignado_id", 0)
if id == 0 || asignadoIDVal == 0 {
return nil, fmt.Errorf("id y asignado_id requeridos")
}
asignadoID := uint(asignadoIDVal)
t, err := AsignarTarea(id, &asignadoID)
if err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "id": t.ID, "asignado_id": asignadoID}, nil
case "adjuntar_documento_tarea":
c, err := AdjuntarDocumentoTarea(chatID, uint(getInt("id", 0)), getStr("comentario"))
if err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "comentario_id": c.ID, "tarea_id": c.TareaID}, 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))
// Coolify devuelve el detalle del error en el body; sin revisar el status,
// un 401/404/500 se reportaría al usuario como si la acción hubiera salido bien.
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
detalle := strings.TrimSpace(string(raw))
if len(detalle) > 300 {
detalle = detalle[:300]
}
switch resp.StatusCode {
case http.StatusUnauthorized, http.StatusForbidden:
return nil, fmt.Errorf("Coolify rechazó la petición (%d): el API token de la instancia es inválido o no tiene permisos", resp.StatusCode)
case http.StatusNotFound:
return nil, fmt.Errorf("Coolify no encontró el recurso (404) en %s — revisa el uuid", endpoint)
default:
return nil, fmt.Errorf("Coolify respondió %d: %s", resp.StatusCode, detalle)
}
}
var result interface{}
if err := json.Unmarshal(raw, &result); err != nil {
return map[string]string{"raw": string(raw)}, nil
}
return result, nil
}
// coolifyConfigDe resuelve la instancia de Coolify a usar. Si no se indicó
// config_id y solo hay una instancia activa configurada, la toma por defecto
// para que el usuario no tenga que saberse los IDs de memoria.
func coolifyConfigDe(configIDInt int) (*models.CoolifyConfig, error) {
if configIDInt > 0 {
cfg, err := models.GetCoolifyConfigByID(uint(configIDInt))
if err != nil {
return nil, fmt.Errorf("instancia Coolify #%d no encontrada", configIDInt)
}
return cfg, nil
}
cfgs, err := models.GetAllCoolifyConfigs()
if err != nil {
return nil, err
}
var activas []models.CoolifyConfig
for _, c := range cfgs {
if c.Activo {
activas = append(activas, c)
}
}
if len(activas) == 1 {
return &activas[0], nil
}
if len(activas) == 0 {
return nil, fmt.Errorf("no hay instancias de Coolify activas configuradas")
}
return nil, fmt.Errorf("hay varias instancias de Coolify: indica cuál usar con config_id (consulta coolify_instancias)")
}
// coolifyBasePorTipo mapea el tipo de recurso al prefijo de la API de Coolify.
func coolifyBasePorTipo(tipo string) (string, bool) {
base, ok := map[string]string{
"app": "/applications",
"servicio": "/services",
"base_datos": "/databases",
"servidor": "/servers",
"proyecto": "/projects",
}[tipo]
return base, ok
}
// ─── 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"`
Source *anthropicBlockSource `json:"source,omitempty"`
}
// anthropicBlockSource es el contenido en base64 de una imagen o PDF adjunto,
// para que Claude lea el archivo real en vez de solo un aviso en texto.
type anthropicBlockSource struct {
Type string `json:"type"` // "base64"
MediaType string `json:"media_type"`
Data string `json:"data"`
}
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
}
}
if m.AttachmentBase64 != "" {
blockType := "image"
if m.AttachmentMime == "application/pdf" {
blockType = "document"
}
blocks := []anthropicContentBlock{{
Type: blockType,
Source: &anthropicBlockSource{
Type: "base64",
MediaType: m.AttachmentMime,
Data: m.AttachmentBase64,
},
}}
if content != "" {
blocks = append(blocks, anthropicContentBlock{Type: "text", Text: content})
}
anthropicMsgs = append(anthropicMsgs, anthropicMsg{Role: "user", Content: blocks})
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
- Si el usuario solo saluda o pregunta qué puedes hacer (sin pedir una acción concreta), responde igual que el comando /ayuda: breve, organizado por categoría, sin inventar capacidades que no tienes
- En Coolify: si solo hay una instancia activa se usa automáticamente, no hace falta preguntar por config_id; si hay varias, consulta coolify_instancias y pregunta cuál. Para actuar sobre algo necesitas su UUID: búscalo con coolify_listar por nombre antes de llamar a coolify_accion. Reiniciar/desplegar es rutinario, hazlo directo; detener algo en producción sí confírmalo antes. Si un despliegue falla, revisa coolify_logs y coolify_deployments para explicar la causa concreta
- Cuando el mensaje empiece con "[Documento adjunto recibido: ...]", el usuario acaba de enviar un archivo por Telegram (puede ser cualquier formato: PDF, foto, Word, Excel, etc.). Si es PDF o foto, viene adjunto a este mismo mensaje y puedes leerlo directamente. Si es otro formato (Word, Excel, zip, etc.) no puedes ver su contenido, solo el nombre — de todas formas guárdalo con la herramienta que corresponda usando lo que el usuario haya escrito como contexto, no rechaces el archivo por el formato. Decide primero de qué se trata el archivo antes de guardarlo:
· Si es una factura → primero determina la dirección leyendo quién emite y quién recibe: si el emisor/vendedor es "U-Site S.A.S BIC" (o similar) y el destinatario es un cliente, es una factura de VENTA → identifica cliente, monto y número, resuelve el cliente_id con listar_clientes, y llama a adjuntar_factura. Si es al revés — un proveedor (AWS, Coolify, un hosting, cualquier tercero) le está facturando A U-Site — es una factura de COMPRA → llama a adjuntar_factura_compra con el nombre de ese proveedor, no busques cliente_id. Ante la duda, mira a nombre de quién sale la plata: si U-Site cobra, es venta; si U-Site paga, es compra
· Si es un documento de un proyecto (contrato, orden de servicio, entregable, etc.) → identifica a qué proyecto corresponde (usa listar_proyectos si hace falta) y llama a adjuntar_documento_proyecto
· Si el mensaje habla de una tarea (ej: "asigna esto a Natalia", "esta es la tarea para el diseño") → usa adjuntar_documento_tarea. Si la tarea todavía no existe, créala primero con crear_tarea (con asignado_id si corresponde) y luego llama a adjuntar_documento_tarea con el id que te devolvió; si ya existe, resuélvela con listar_tareas
Si logras identificar los datos necesarios (del documento o de lo que escribió el usuario), guarda directamente sin pedir confirmación — solo pregunta si de verdad no hay forma de determinar el cliente/proyecto/tarea o falta un dato imprescindible. No uses estas tools si el mensaje no menciona ningún documento adjunto
- Para registrar un proyecto nuevo con sus fases (el usuario puede describirlo en texto o mandarlo en un documento), primero usa crear_proyecto y luego llama a crear_fase_proyecto una vez por cada fase que corresponda — no hace falta preguntar confirmación entre cada fase
- Para asignar una tarea a alguien, resuelve el nombre con listar_usuarios y usa asignado_id en crear_tarea (si es nueva) o asignar_tarea (si ya existe) — la persona asignada recibe una notificación. Si además el usuario mandó un archivo junto con la instrucción de asignar, después de crear/asignar la tarea usa también adjuntar_documento_tarea para guardar ese archivo en la tarea
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 `¡Hola! 👋 Soy el asistente de administración de U-Site S.A.S BIC. Esto es lo que puedo hacer por ti:
✅ Clientes y facturación
Listar y crear clientes · facturas de venta y compra · cuentas por cobrar y pagar
✅ Contratos y cotizaciones
Crear, renovar y gestionar contratos · generar cotizaciones y propuestas técnicas
✅ Proyectos y soporte
Crear proyectos y fases · gestionar tickets · adjuntar documentos (PDF, fotos, Word, Excel, etc.)
✅ Tareas del equipo
Crear y asignar tareas · ver el tablero Kanban
✅ Coolify (deploy y control)
Listar apps, servicios y bases de datos · deploy, reinicio y detención · logs y despliegues
✅ Contabilidad
Dashboard de ingresos/egresos · transacciones · tarifas
✅ Monitoreo
Servidores y URLs vigiladas
¿Qué necesitas? Escríbeme en lenguaje natural, por ejemplo:
"muéstrame los clientes de esta semana"
"haz deploy de la app abc123 en coolify 1"
"¿cuánto ingresamos este mes?"
Comandos: /reset · /instancias · /ayuda`, nil
case "/instancias":
cfgs, err := models.GetAllCoolifyConfigs()
if err != nil {
return "", err
}
if len(cfgs) == 0 {
return "No hay instancias de Coolify configuradas.", nil
}
lines := make([]string, len(cfgs))
for i, c := range cfgs {
estado := "✓ activa"
if !c.Activo {
estado = "✗ inactiva"
}
lines[i] = fmt.Sprintf("• #%d **%s** — %s (%s)", c.ID, c.Nombre, c.BaseURL, estado)
}
return "**Instancias de Coolify:**\n" + strings.Join(lines, "\n"), nil
}
// Cargar historial (últimos 20 mensajes)
// 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})
}
userMsg := agentMessage{Role: "user", Content: userText}
// Si hay un documento/foto pendiente en este chat, se lo pasamos al modelo
// como archivo real (solo Anthropic lo lee de verdad) en vez de que la IA
// tenga que adivinar cliente/monto a partir del nombre del archivo. Solo se
// hace esto para tipos que la API de Claude sabe previsualizar (imagen o
// PDF): un .docx/.xlsx/etc mandado como bloque "image" lo rechaza la API
// (400) y tumba toda la respuesta. Para esos, el archivo se queda igual
// adjunto/guardado (adjuntar_documento_tarea, etc.), solo que la IA no
// puede "leer" su contenido, solo el nombre.
if strings.ToLower(ai.Provider) == "anthropic" {
if att, ok := PeekTelegramAttachment(chatID); ok {
mime := ResolverMimeType(att.MimeType, att.OriginalName)
if esMimePrevisualizablePorClaude(mime) {
if data, err := os.ReadFile(att.Path); err == nil {
userMsg.AttachmentBase64 = base64.StdEncoding.EncodeToString(data)
userMsg.AttachmentMime = mime
} else {
log.Printf("[AGENT] No se pudo leer el adjunto pendiente (%s): %v", att.Path, err)
}
}
}
}
messages = append(messages, userMsg)
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(chatID, 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
}