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 }