Seguridad (crítico): - Los webhooks de Bold y dLocal solo validaban la firma si el atacante la enviaba: sin cabecera se aceptaba cualquier payload. Ahora es obligatoria. - GET /pago-exitoso marcaba contratos como pagados leyendo un query param del navegador. Ahora solo muestra estado; la confirmación la hace la verificación contra la API de la pasarela o el webhook firmado. - /uploads se servía como estático público: se descargaban RUTs, facturas y entregables sabiendo la ruta. Ahora exige sesión. - Los secretos JWT no se podían sobreescribir por entorno (faltaba el tag env:) y su valor estaba en el repo, permitiendo firmarse una sesión de admin. Ahora son configurables y el arranque se detiene si siguen con el valor publicado. - .env y session.db salen del control de versiones. - Query Runner, gestión de usuarios/roles/módulos y seeds quedan restringidos a administradores; antes bastaba con tener sesión. Pasarelas de pago: - dLocal generaba enlaces que nunca se reconciliaban: mandaba el ID numérico en vez de "contrato-N", la URL de retorno apuntaba a la API de dLocal y nunca se enviaba notification_url, así que su webhook jamás se disparaba. - PayPal solo tenía pantalla de configuración. Se implementa el servicio completo (OAuth, orden, captura, verificación de webhook) y queda seleccionable como pasarela. - La moneda estaba fija en COP: un contrato en USD generaba un cobro por esa cifra en pesos. Contratos: - pago_confirmado nunca volvía a false, así que el segundo ciclo de renovación no se cobraba aunque el cliente pagara. Se reinicia al generar enlace nuevo. - Los contratos vencidos nunca cambiaban de estado y recibían correo a diario de forma indefinida; ahora se cierran tras 30 días de gracia. Otros: - Coolify: coolifyCall ignoraba el status HTTP y reportaba errores como éxito. El agente pasa de 10 a cobertura completa (servicios, bases de datos, variables de entorno, proyectos, equipos y recursos de servidor). - SeedBalanceData ya no corre en cada arranque (recreaba transacciones borradas); ahora se invoca con SEED_BALANCE=1. - Los seeds dejan de devolver permisos revocados en cada despliegue. - Timeouts en las llamadas HTTP a Telegram y dLocal que podían colgarse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1621 lines
61 KiB
Go
1621 lines
61 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 el documento (PDF/foto) que el usuario acaba de enviar por Telegram en este chat. Solo funciona si hay un archivo adjunto pendiente; si no lo hay, pide al usuario que lo reenvíe.",
|
|
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"})),
|
|
|
|
// ── 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) 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{"pendiente", "en_progreso"}, Description: "Estado inicial"},
|
|
"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("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(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 "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 "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
|
|
- 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 PDF o foto por Telegram y puedes leerlo directamente (está adjunto a este mismo mensaje, no es solo un nombre de archivo). Decide primero de qué se trata el archivo antes de guardarlo:
|
|
· Si es una factura/cuenta de cobro → identifica cliente, monto y número, resuelve el cliente_id con listar_clientes, y llama a adjuntar_factura
|
|
· 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 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 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
|
|
|
|
COMANDOS ESPECIALES (el usuario puede escribirlos):
|
|
- /reset — olvidar el historial de esta conversación
|
|
- /instancias — listar instancias de Coolify
|
|
- /ayuda — mostrar qué puedes hacer`, now)
|
|
}
|
|
|
|
// ─── Motor principal del agente ───────────────────────────────────────────────
|
|
|
|
// ProcessAgentMessage procesa un mensaje de Telegram y retorna la respuesta del agente.
|
|
func ProcessAgentMessage(chatID int64, userText string, ai *models.AiConfig) (string, error) {
|
|
// Comandos especiales (sin AI)
|
|
switch strings.TrimSpace(userText) {
|
|
case "/reset":
|
|
if err := models.ClearAgentHistory(chatID); err != nil {
|
|
return "", err
|
|
}
|
|
return "Historial borrado. ¿En qué puedo ayudarte?", nil
|
|
case "/ayuda", "/start":
|
|
return `Soy el agente administrador de U-Site. Puedo ayudarte con:
|
|
|
|
• Clientes y contratos
|
|
• Contabilidad (ingresos, egresos, cuentas por cobrar/pagar)
|
|
• Proyectos y tickets
|
|
• Tareas del equipo
|
|
• Deploy y gestión de apps en Coolify
|
|
• Monitoreo de servidores y URLs
|
|
|
|
Escríbeme en lenguaje natural, por ejemplo:
|
|
"muéstrame los clientes de esta semana"
|
|
"haz deploy de la app abc123 en coolify 1"
|
|
"¿cuánto ingresamos este mes?"
|
|
|
|
Comandos: /reset /instancias /ayuda`, nil
|
|
case "/instancias":
|
|
cfgs, err := models.GetAllCoolifyConfigs()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(cfgs) == 0 {
|
|
return "No hay instancias de Coolify configuradas.", nil
|
|
}
|
|
lines := make([]string, len(cfgs))
|
|
for i, c := range cfgs {
|
|
estado := "✓ activa"
|
|
if !c.Activo {
|
|
estado = "✗ inactiva"
|
|
}
|
|
lines[i] = fmt.Sprintf("• #%d **%s** — %s (%s)", c.ID, c.Nombre, c.BaseURL, estado)
|
|
}
|
|
return "**Instancias de Coolify:**\n" + strings.Join(lines, "\n"), nil
|
|
}
|
|
|
|
// Cargar historial (últimos 20 mensajes)
|
|
// 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.
|
|
if strings.ToLower(ai.Provider) == "anthropic" {
|
|
if att, ok := PeekTelegramAttachment(chatID); ok {
|
|
if data, err := os.ReadFile(att.Path); err == nil {
|
|
userMsg.AttachmentBase64 = base64.StdEncoding.EncodeToString(data)
|
|
userMsg.AttachmentMime = ResolverMimeType(att.MimeType, att.OriginalName)
|
|
} 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
|
|
}
|