package services import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "strings" "time" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" ) // ─── Tipos para OpenAI-compatible function calling ──────────────────────────── type agentToolParam struct { Type string `json:"type"` Description string `json:"description,omitempty"` Enum []string `json:"enum,omitempty"` Properties map[string]agentToolParam `json:"properties,omitempty"` Required []string `json:"required,omitempty"` Items *agentToolParam `json:"items,omitempty"` } type agentToolFunc struct { Name string `json:"name"` Description string `json:"description"` Parameters agentToolParam `json:"parameters"` } type agentTool struct { Type string `json:"type"` Function agentToolFunc `json:"function"` } type agentMessage struct { Role string `json:"role"` Content interface{} `json:"content"` // string o null ToolCalls []agentToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` Name string `json:"name,omitempty"` } type agentToolCall struct { ID string `json:"id"` Type string `json:"type"` Function struct { Name string `json:"name"` Arguments string `json:"arguments"` } `json:"function"` } type agentChatReq struct { Model string `json:"model"` Messages []agentMessage `json:"messages"` Tools []agentTool `json:"tools,omitempty"` ToolChoice string `json:"tool_choice,omitempty"` MaxTokens int `json:"max_tokens,omitempty"` } type agentChatResp struct { Choices []struct { Message agentMessage `json:"message"` FinishReason string `json:"finish_reason"` } `json:"choices"` Error *struct { Message string `json:"message"` } `json:"error"` } // ─── Definición de herramientas ─────────────────────────────────────────────── func agentTools() []agentTool { str := func(desc string) agentToolParam { return agentToolParam{Type: "string", Description: desc} } num := func(desc string) agentToolParam { return agentToolParam{Type: "number", Description: desc} } obj := func(props map[string]agentToolParam, required []string) agentToolParam { return agentToolParam{Type: "object", Properties: props, Required: required} } tool := func(name, desc string, params agentToolParam) agentTool { return agentTool{Type: "function", Function: agentToolFunc{Name: name, Description: desc, Parameters: params}} } return []agentTool{ // ── Sistema ────────────────────────────────────────────────────────── tool("sistema_info", "Información general del sistema y recursos disponibles.", obj(nil, nil)), // ── Coolify ────────────────────────────────────────────────────────── tool("coolify_instancias", "Lista las instancias de Coolify configuradas.", obj(nil, nil)), tool("coolify_apps", "Lista las aplicaciones de una instancia Coolify.", obj(map[string]agentToolParam{"config_id": num("ID de la instancia Coolify")}, []string{"config_id"})), tool("coolify_servicios", "Lista los servicios (stacks) de una instancia Coolify.", obj(map[string]agentToolParam{"config_id": num("ID de la instancia Coolify")}, []string{"config_id"})), tool("coolify_servidores", "Lista los servidores de una instancia Coolify.", obj(map[string]agentToolParam{"config_id": num("ID de la instancia Coolify")}, []string{"config_id"})), tool("coolify_deploy", "Despliega una aplicación en Coolify.", obj(map[string]agentToolParam{ "config_id": num("ID de la instancia Coolify"), "uuid": str("UUID de la aplicación"), "force": agentToolParam{Type: "boolean", Description: "Forzar rebuild desde cero"}, }, []string{"config_id", "uuid"})), tool("coolify_restart", "Reinicia una aplicación en Coolify.", obj(map[string]agentToolParam{ "config_id": num("ID de la instancia Coolify"), "uuid": str("UUID de la aplicación"), }, []string{"config_id", "uuid"})), tool("coolify_stop", "Detiene una aplicación en Coolify.", obj(map[string]agentToolParam{ "config_id": num("ID de la instancia Coolify"), "uuid": str("UUID de la aplicación"), }, []string{"config_id", "uuid"})), tool("coolify_start", "Inicia una aplicación detenida en Coolify.", obj(map[string]agentToolParam{ "config_id": num("ID de la instancia Coolify"), "uuid": str("UUID de la aplicación"), }, []string{"config_id", "uuid"})), tool("coolify_logs", "Obtiene los logs de una aplicación en Coolify.", obj(map[string]agentToolParam{ "config_id": num("ID de la instancia Coolify"), "uuid": str("UUID de la aplicación"), }, []string{"config_id", "uuid"})), tool("coolify_deployments", "Lista los deployments recientes de una aplicación.", obj(map[string]agentToolParam{ "config_id": num("ID de la instancia Coolify"), "uuid": str("UUID de la aplicación"), }, []string{"config_id", "uuid"})), // ── Facturas ───────────────────────────────────────────────────────── tool("listar_facturas", "Lista facturas. Puede filtrar por estado: pendiente, pagada, vencida, cancelada.", obj(map[string]agentToolParam{ "page": num("Página (default 1)"), "search": str("Búsqueda por número, cliente o descripción"), "estado": agentToolParam{Type: "string", Description: "pendiente | pagada | vencida | cancelada", Enum: []string{"pendiente", "pagada", "vencida", "cancelada", ""}}, }, nil)), // ── Clientes ───────────────────────────────────────────────────────── tool("listar_clientes", "Lista clientes con paginación y búsqueda.", obj(map[string]agentToolParam{ "page": num("Página (default 1)"), "search": str("Texto de búsqueda"), }, nil)), tool("crear_cliente", "Crea un nuevo cliente.", obj(map[string]agentToolParam{ "nombre": str("Nombre del cliente"), "empresa": str("Empresa"), "email": str("Correo electrónico"), "telefono": str("Teléfono"), "documento": str("Documento de identidad"), "notas": str("Notas adicionales"), }, []string{"nombre"})), // ── Contratos ──────────────────────────────────────────────────────── tool("listar_contratos", "Lista contratos con filtros.", obj(map[string]agentToolParam{ "page": num("Página"), "search": str("Búsqueda"), "estado": str("Estado del contrato"), }, nil)), tool("renovar_contrato", "Renueva un contrato por su ID.", obj(map[string]agentToolParam{"id": num("ID del contrato")}, []string{"id"})), tool("enviar_correo_contrato", "Envía notificación de renovación por correo al cliente.", obj(map[string]agentToolParam{"id": num("ID del contrato")}, []string{"id"})), // ── Contabilidad ───────────────────────────────────────────────────── tool("dashboard_contabilidad", "Resumen del mes: ingresos, egresos, pendientes.", obj(map[string]agentToolParam{ "mes": num("Mes (1-12)"), "anio": num("Año (ej: 2026)"), }, nil)), tool("listar_transacciones", "Lista transacciones contables.", obj(map[string]agentToolParam{ "page": num("Página"), "tipo": agentToolParam{Type: "string", Description: "ingreso | egreso", Enum: []string{"ingreso", "egreso", ""}}, "mes": num("Mes (1-12)"), "anio": num("Año"), }, nil)), tool("crear_transaccion", "Registra una transacción de ingreso o egreso.", obj(map[string]agentToolParam{ "tipo": agentToolParam{Type: "string", Description: "ingreso | egreso", Enum: []string{"ingreso", "egreso"}}, "descripcion": str("Descripción"), "valor": num("Valor en COP"), "fecha": str("Fecha YYYY-MM-DD (vacío = hoy)"), "notas": str("Notas"), }, []string{"tipo", "descripcion", "valor"})), tool("listar_cuentas_cobro", "Lista cuentas por cobrar (lo que nos deben).", obj(map[string]agentToolParam{ "page": num("Página"), "estado": str("pendiente | pagada | vencida"), }, nil)), tool("listar_cuentas_pagar", "Lista cuentas por pagar (lo que debemos).", obj(map[string]agentToolParam{ "page": num("Página"), "estado": str("pendiente | pagada | vencida"), }, nil)), // ── Proyectos ──────────────────────────────────────────────────────── tool("listar_proyectos", "Lista proyectos.", obj(map[string]agentToolParam{ "page": num("Página"), "search": str("Búsqueda"), }, nil)), // ── Tickets ────────────────────────────────────────────────────────── tool("listar_tickets", "Lista tickets de soporte de todos los proyectos.", obj(map[string]agentToolParam{ "page": num("Página"), "estado": str("abierto | cerrado | en_proceso"), }, nil)), tool("responder_ticket", "Responde un ticket de soporte.", obj(map[string]agentToolParam{ "ticket_id": num("ID del ticket"), "mensaje": str("Mensaje de respuesta"), }, []string{"ticket_id", "mensaje"})), // ── Tareas ─────────────────────────────────────────────────────────── tool("listar_tareas", "Lista tareas del Kanban.", obj(map[string]agentToolParam{ "estado": str("pendiente | en_progreso | completada | cancelada"), }, nil)), tool("crear_tarea", "Crea una nueva tarea.", obj(map[string]agentToolParam{ "titulo": str("Título de la tarea"), "descripcion": str("Descripción"), "estado": agentToolParam{Type: "string", Enum: []string{"pendiente", "en_progreso"}, Description: "Estado inicial"}, "prioridad": agentToolParam{Type: "string", Enum: []string{"baja", "media", "alta"}, Description: "Prioridad"}, }, []string{"titulo"})), tool("actualizar_estado_tarea", "Cambia el estado de una tarea.", obj(map[string]agentToolParam{ "id": num("ID de la tarea"), "estado": agentToolParam{Type: "string", Enum: []string{"pendiente", "en_progreso", "completada", "cancelada"}, Description: "Nuevo estado"}, }, []string{"id", "estado"})), // ── Servidores ─────────────────────────────────────────────────────── tool("listar_servidores", "Lista los servidores monitoreados.", obj(map[string]agentToolParam{"search": str("Búsqueda")}, nil)), // ── Monitor URLs ───────────────────────────────────────────────────── tool("listar_monitores", "Lista los monitores de URL activos.", obj(nil, nil)), } } // ─── Ejecución de herramientas ──────────────────────────────────────────────── func executeAgentTool(name string, args map[string]interface{}) string { result, err := runTool(name, args) if err != nil { return fmt.Sprintf(`{"error": %q}`, err.Error()) } b, _ := json.Marshal(result) return string(b) } func runTool(name string, a map[string]interface{}) (interface{}, error) { getInt := func(key string, def int) int { if v, ok := a[key]; ok { switch x := v.(type) { case float64: return int(x) case int: return x } } return def } getStr := func(key string) string { if v, ok := a[key]; ok { if s, ok := v.(string); ok { return s } } return "" } getBool := func(key string) bool { if v, ok := a[key]; ok { if b, ok := v.(bool); ok { return b } } return false } switch name { case "sistema_info": cfgs, _ := models.GetAllCoolifyConfigs() names := make([]string, len(cfgs)) for i, c := range cfgs { names[i] = fmt.Sprintf("#%d %s (%s)", c.ID, c.Nombre, c.BaseURL) } return map[string]interface{}{ "sistema": "U-Site Admin", "coolify_instancias": names, "timestamp": time.Now().Format("2006-01-02 15:04:05"), }, nil // ── Coolify ──────────────────────────────────────────────────────────── case "coolify_instancias": cfgs, err := models.GetAllCoolifyConfigs() if err != nil { return nil, err } type safe struct { ID uint `json:"id"` Nombre string `json:"nombre"` BaseURL string `json:"base_url"` Activo bool `json:"activo"` } out := make([]safe, len(cfgs)) for i, c := range cfgs { out[i] = safe{ID: c.ID, Nombre: c.Nombre, BaseURL: c.BaseURL, Activo: c.Activo} } return out, nil case "coolify_apps", "coolify_servicios", "coolify_servidores", "coolify_logs", "coolify_deployments": configID := uint(getInt("config_id", 0)) if configID == 0 { return nil, fmt.Errorf("config_id requerido") } cfg, err := models.GetCoolifyConfigByID(configID) if err != nil { return nil, fmt.Errorf("instancia Coolify #%d no encontrada", configID) } endpoint := map[string]string{ "coolify_apps": "/applications", "coolify_servicios": "/services", "coolify_servidores": "/servers", }[name] if endpoint == "" { uuid := getStr("uuid") if uuid == "" { return nil, fmt.Errorf("uuid requerido") } endpoint = map[string]string{ "coolify_logs": "/applications/" + uuid + "/logs", "coolify_deployments": "/applications/" + uuid + "/deployments", }[name] } return coolifyCall("GET", endpoint, nil, cfg) case "coolify_deploy": configID := uint(getInt("config_id", 0)) uuid := getStr("uuid") force := getBool("force") if configID == 0 || uuid == "" { return nil, fmt.Errorf("config_id y uuid requeridos") } cfg, err := models.GetCoolifyConfigByID(configID) if err != nil { return nil, err } ep := fmt.Sprintf("/deploy?uuid=%s&force=%v", uuid, force) return coolifyCall("POST", ep, nil, cfg) case "coolify_restart": return coolifyAppAction(getInt("config_id", 0), getStr("uuid"), "restart") case "coolify_stop": return coolifyAppAction(getInt("config_id", 0), getStr("uuid"), "stop") case "coolify_start": return coolifyAppAction(getInt("config_id", 0), getStr("uuid"), "start") // ── Clientes ─────────────────────────────────────────────────────────── case "listar_facturas": page := getInt("page", 1) search := getStr("search") estado := getStr("estado") limit := 10 offset := (page - 1) * limit all, total, err := models.GetAllFacturas(limit, offset, search) if err != nil { return nil, err } // Filtrar por estado si se especificó items := all if estado != "" { items = nil for _, f := range all { if f.Estado == estado { items = append(items, f) } } total = int64(len(items)) } return map[string]interface{}{"items": items, "total": total, "page": page}, nil case "listar_clientes": page := getInt("page", 1) search := getStr("search") limit := 10 offset := (page - 1) * limit items, total, err := models.GetAllClientes(limit, offset, search) if err != nil { return nil, err } return map[string]interface{}{"items": items, "total": total, "page": page}, nil case "crear_cliente": c := models.Cliente{ Nombre: getStr("nombre"), Empresa: getStr("empresa"), Email: getStr("email"), Telefono: getStr("telefono"), Documento: getStr("documento"), Notas: getStr("notas"), Activo: true, } if err := models.CreateCliente(c); err != nil { return nil, err } return map[string]interface{}{"ok": true, "nombre": c.Nombre}, nil // ── Contratos ────────────────────────────────────────────────────────── case "listar_contratos": page := getInt("page", 1) search := getStr("search") estado := getStr("estado") limit := 10 offset := (page - 1) * limit items, total, err := models.GetAllContratos(limit, offset, search, estado) if err != nil { return nil, err } return map[string]interface{}{"items": items, "total": total, "page": page}, nil case "renovar_contrato": // La lógica de renovación está en el controller; aquí solo informamos que se debe hacer desde la UI id := uint(getInt("id", 0)) if id == 0 { return nil, fmt.Errorf("id requerido") } contrato, err := models.GetContratoByID(id) if err != nil { return nil, err } return map[string]interface{}{ "ok": true, "info": "Para renovar usa la UI o el endpoint /api/v2/contratos/:id/renovar", "contrato_id": id, "cliente_id": contrato.ClienteID, "fecha_vencimiento": contrato.FechaVencimiento, }, nil case "enviar_correo_contrato": id := uint(getInt("id", 0)) if id == 0 { return nil, fmt.Errorf("id requerido") } contrato, err := models.GetContratoByID(id) if err != nil { return nil, err } return map[string]interface{}{ "ok": true, "info": "Usa el endpoint /api/v2/contratos/:id/enviar-correo para enviar", "contrato_id": id, "cliente_id": contrato.ClienteID, }, nil // ── Contabilidad ─────────────────────────────────────────────────────── case "dashboard_contabilidad": now := time.Now() mes := getInt("mes", int(now.Month())) anio := getInt("anio", now.Year()) dash, err := models.GetDashboardData(mes, anio) if err != nil { return nil, err } return dash, nil case "listar_transacciones": page := getInt("page", 1) tipo := getStr("tipo") mes := getInt("mes", 0) anio := getInt("anio", 0) limit := 10 offset := (page - 1) * limit items, total, err := models.GetAllTransacciones(limit, offset, "", tipo, mes, anio) if err != nil { return nil, err } return map[string]interface{}{"items": items, "total": total, "page": page}, nil case "crear_transaccion": t := &models.Transaccion{ Tipo: getStr("tipo"), Descripcion: getStr("descripcion"), Valor: float64(getInt("valor", 0)), Notas: getStr("notas"), } if err := models.CreateTransaccion(t); err != nil { return nil, err } return map[string]interface{}{"ok": true, "id": t.ID}, nil case "listar_cuentas_cobro": page := getInt("page", 1) estado := getStr("estado") limit := 10 offset := (page - 1) * limit items, total, err := models.GetAllCuentasCobro(limit, offset, "", estado) if err != nil { return nil, err } return map[string]interface{}{"items": items, "total": total, "page": page}, nil case "listar_cuentas_pagar": page := getInt("page", 1) estado := getStr("estado") limit := 10 offset := (page - 1) * limit items, total, err := models.GetAllCuentasPagar(limit, offset, "", estado) if err != nil { return nil, err } return map[string]interface{}{"items": items, "total": total, "page": page}, nil // ── Proyectos ────────────────────────────────────────────────────────── case "listar_proyectos": page := getInt("page", 1) search := getStr("search") limit := 10 offset := (page - 1) * limit items, total, err := models.GetAllProyectos(limit, offset, search) if err != nil { return nil, err } return map[string]interface{}{"items": items, "total": total, "page": page}, nil // ── Tickets ──────────────────────────────────────────────────────────── case "listar_tickets": estado := getStr("estado") items, err := models.GetAllTickets(estado) if err != nil { return nil, err } return items, nil case "responder_ticket": ticketID := uint(getInt("ticket_id", 0)) mensaje := getStr("mensaje") if ticketID == 0 || mensaje == "" { return nil, fmt.Errorf("ticket_id y mensaje requeridos") } m := &models.TicketMensaje{ TicketID: ticketID, AutorNombre: "Agente Bot", Contenido: mensaje, EsAdmin: true, } if err := models.CreateTicketMensaje(m); err != nil { return nil, err } return map[string]interface{}{"ok": true}, nil // ── Tareas ───────────────────────────────────────────────────────────── case "listar_tareas": estado := getStr("estado") allItems, err := models.GetAllTareas() if err != nil { return nil, err } if estado == "" { return allItems, nil } var filtered []models.Tarea for _, t := range allItems { if t.Estado == estado { filtered = append(filtered, t) } } return filtered, nil case "crear_tarea": t := &models.Tarea{ Titulo: getStr("titulo"), Descripcion: getStr("descripcion"), Estado: getStr("estado"), Prioridad: getStr("prioridad"), } if t.Estado == "" { t.Estado = "pendiente" } if t.Prioridad == "" { t.Prioridad = "media" } if err := models.CreateTarea(t); err != nil { return nil, err } return map[string]interface{}{"ok": true, "id": t.ID, "titulo": t.Titulo}, nil case "actualizar_estado_tarea": id := uint(getInt("id", 0)) estado := getStr("estado") if id == 0 || estado == "" { return nil, fmt.Errorf("id y estado requeridos") } if err := models.CambiarEstadoTarea(id, estado); err != nil { return nil, err } return map[string]interface{}{"ok": true, "id": id, "estado": estado}, nil // ── Servidores ───────────────────────────────────────────────────────── case "listar_servidores": search := getStr("search") items, _, err := models.GetAllServidores(50, 0, search) if err != nil { return nil, err } return items, nil // ── Monitor URLs ─────────────────────────────────────────────────────── case "listar_monitores": items, err := models.GetAllUrlMonitors() if err != nil { return nil, err } return items, nil default: return nil, fmt.Errorf("herramienta desconocida: %s", name) } } // ─── Coolify helpers internos ───────────────────────────────────────────────── func coolifyCall(method, endpoint string, body []byte, cfg *models.CoolifyConfig) (interface{}, error) { base := strings.TrimRight(cfg.BaseURL, "/") url := base + "/api/v1" + endpoint var reqBody io.Reader if body != nil { reqBody = bytes.NewReader(body) } req, err := http.NewRequest(method, url, reqBody) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+cfg.ApiToken) req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") } client := &http.Client{Timeout: 20 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) var result interface{} if err := json.Unmarshal(raw, &result); err != nil { return map[string]string{"raw": string(raw)}, nil } return result, nil } func coolifyAppAction(configIDInt int, uuid, action string) (interface{}, error) { if configIDInt == 0 || uuid == "" { return nil, fmt.Errorf("config_id y uuid requeridos") } cfg, err := models.GetCoolifyConfigByID(uint(configIDInt)) if err != nil { return nil, err } return coolifyCall("GET", "/applications/"+uuid+"/"+action, nil, cfg) } // ─── Tipos Anthropic ───────────────────────────────────────────────────────── type anthropicTool struct { Name string `json:"name"` Description string `json:"description"` InputSchema agentToolParam `json:"input_schema"` } type anthropicContentBlock struct { Type string `json:"type"` Text string `json:"text,omitempty"` ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Input json.RawMessage `json:"input,omitempty"` ToolUseID string `json:"tool_use_id,omitempty"` Content string `json:"content,omitempty"` } type anthropicMsg struct { Role string `json:"role"` Content interface{} `json:"content"` // string o []anthropicContentBlock } type anthropicReq struct { Model string `json:"model"` MaxTokens int `json:"max_tokens"` System string `json:"system,omitempty"` Messages []anthropicMsg `json:"messages"` Tools []anthropicTool `json:"tools,omitempty"` } type anthropicResp struct { Content []anthropicContentBlock `json:"content"` StopReason string `json:"stop_reason"` Error *struct { Message string `json:"message"` Type string `json:"type"` } `json:"error"` } // ─── Llamada al AI con function calling ────────────────────────────────────── // callAI despacha al provider correcto (Anthropic o OpenAI-compatible). func callAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) { if strings.ToLower(ai.Provider) == "anthropic" { return callAnthropicAI(ai, messages, tools) } return callOpenAICompatibleAI(ai, messages, tools) } // callOpenAICompatibleAI usa el formato de OpenAI (también vale para qwen, groq, deepseek, etc.) func callOpenAICompatibleAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) { baseURL := ai.BaseURL if baseURL == "" { baseURL = providerDefaultURL(ai.Provider) } baseURL = strings.TrimRight(baseURL, "/") reqBody := agentChatReq{ Model: ai.ModelName, Messages: messages, Tools: tools, ToolChoice: "auto", MaxTokens: 4096, } payload, _ := json.Marshal(reqBody) req, err := http.NewRequest("POST", baseURL+"/chat/completions", bytes.NewReader(payload)) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+ai.ApiKey) req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 120 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) var chatResp agentChatResp if err := json.Unmarshal(raw, &chatResp); err != nil { return nil, fmt.Errorf("respuesta inesperada del AI: %s", string(raw[:min(200, len(raw))])) } if chatResp.Error != nil { return nil, fmt.Errorf("error del AI: %s", chatResp.Error.Message) } if len(chatResp.Choices) == 0 { return nil, fmt.Errorf("el AI no devolvió respuesta") } msg := chatResp.Choices[0].Message return &msg, nil } // callAnthropicAI llama a la API nativa de Anthropic con tool use. func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) { // Convertir herramientas al formato Anthropic anthropicTools := make([]anthropicTool, len(tools)) for i, t := range tools { anthropicTools[i] = anthropicTool{ Name: t.Function.Name, Description: t.Function.Description, InputSchema: t.Function.Parameters, } } // Extraer system prompt y convertir mensajes var systemPrompt string var anthropicMsgs []anthropicMsg for _, m := range messages { switch m.Role { case "system": if s, ok := m.Content.(string); ok { systemPrompt = s } case "user": content := "" if s, ok := m.Content.(string); ok { content = s } // Si el último mensaje ya es user, agregar tool_result como bloque adicional if len(anthropicMsgs) > 0 && anthropicMsgs[len(anthropicMsgs)-1].Role == "user" { last := anthropicMsgs[len(anthropicMsgs)-1] if blocks, ok := last.Content.([]anthropicContentBlock); ok { anthropicMsgs[len(anthropicMsgs)-1].Content = append(blocks, anthropicContentBlock{ Type: "text", Text: content, }) continue } } anthropicMsgs = append(anthropicMsgs, anthropicMsg{Role: "user", Content: content}) case "assistant": if len(m.ToolCalls) > 0 { // Convertir tool_calls a bloques tool_use var blocks []anthropicContentBlock if s, ok := m.Content.(string); ok && s != "" { blocks = append(blocks, anthropicContentBlock{Type: "text", Text: s}) } for _, tc := range m.ToolCalls { blocks = append(blocks, anthropicContentBlock{ Type: "tool_use", ID: tc.ID, Name: tc.Function.Name, Input: json.RawMessage(tc.Function.Arguments), }) } anthropicMsgs = append(anthropicMsgs, anthropicMsg{Role: "assistant", Content: blocks}) } else { content := "" if s, ok := m.Content.(string); ok { content = s } anthropicMsgs = append(anthropicMsgs, anthropicMsg{Role: "assistant", Content: content}) } case "tool": // Los resultados de tool deben ir en un mensaje de usuario con tipo tool_result block := anthropicContentBlock{ Type: "tool_result", ToolUseID: m.ToolCallID, Content: fmt.Sprintf("%v", m.Content), } // Agrupar en el último mensaje user si existe, o crear uno nuevo if len(anthropicMsgs) > 0 && anthropicMsgs[len(anthropicMsgs)-1].Role == "user" { last := anthropicMsgs[len(anthropicMsgs)-1] switch c := last.Content.(type) { case []anthropicContentBlock: anthropicMsgs[len(anthropicMsgs)-1].Content = append(c, block) default: anthropicMsgs[len(anthropicMsgs)-1].Content = []anthropicContentBlock{block} } } else { anthropicMsgs = append(anthropicMsgs, anthropicMsg{ Role: "user", Content: []anthropicContentBlock{block}, }) } } } reqBody := anthropicReq{ Model: ai.ModelName, MaxTokens: 4096, System: systemPrompt, Messages: anthropicMsgs, Tools: anthropicTools, } payload, _ := json.Marshal(reqBody) req, err := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", bytes.NewReader(payload)) if err != nil { return nil, err } req.Header.Set("x-api-key", ai.ApiKey) req.Header.Set("anthropic-version", "2023-06-01") req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 120 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) var anthropicRsp anthropicResp if err := json.Unmarshal(raw, &anthropicRsp); err != nil { return nil, fmt.Errorf("respuesta inesperada de Anthropic: %s", string(raw[:min(200, len(raw))])) } if anthropicRsp.Error != nil { return nil, fmt.Errorf("error de Anthropic: %s", anthropicRsp.Error.Message) } // Convertir respuesta Anthropic → agentMessage (formato interno OpenAI) result := &agentMessage{Role: "assistant"} var textParts []string var toolCalls []agentToolCall for _, block := range anthropicRsp.Content { switch block.Type { case "text": if block.Text != "" { textParts = append(textParts, block.Text) } case "tool_use": inputJSON := "{}" if block.Input != nil { inputJSON = string(block.Input) } toolCalls = append(toolCalls, agentToolCall{ ID: block.ID, Type: "function", Function: struct { Name string `json:"name"` Arguments string `json:"arguments"` }{Name: block.Name, Arguments: inputJSON}, }) } } if len(textParts) > 0 { result.Content = strings.Join(textParts, "\n") } result.ToolCalls = toolCalls return result, nil } func providerDefaultURL(provider string) string { switch strings.ToLower(provider) { case "openai": return "https://api.openai.com/v1" case "qwen", "dashscope": return "https://dashscope.aliyuncs.com/compatible-mode/v1" case "groq": return "https://api.groq.com/openai/v1" case "deepseek": return "https://api.deepseek.com/v1" default: return "https://api.openai.com/v1" } } func min(a, b int) int { if a < b { return a } return b } // ─── Sistema prompt del agente ──────────────────────────────────────────────── func agentSystemPrompt() string { now := time.Now().Format("2006-01-02 15:04 MST") return fmt.Sprintf(`Eres el asistente administrador de U-Site SAS BIC. Fecha y hora actual: %s. Tienes acceso total al sistema mediante herramientas. Puedes: - Ver y administrar clientes, contratos, facturación y contabilidad - Gestionar proyectos, tickets de soporte y tareas del equipo - Controlar aplicaciones en las instancias de Coolify (deploy, restart, stop, logs) - Monitorear servidores y URLs COMPORTAMIENTO: - Responde siempre en español, de forma clara y concisa - Usa las herramientas para obtener datos reales antes de responder - Cuando el usuario pida una acción (deploy, renovar, crear), hazla directamente sin pedir confirmación salvo que sea destructiva - Para listar datos, muestra los más relevantes en formato legible - Los valores monetarios son en COP (pesos colombianos) - Si una herramienta falla, explica el error y sugiere alternativas - Para acciones en Coolify, primero usa coolify_instancias para saber qué IDs hay disponibles si el usuario no lo especifica COMANDOS ESPECIALES (el usuario puede escribirlos): - /reset — olvidar el historial de esta conversación - /instancias — listar instancias de Coolify - /ayuda — mostrar qué puedes hacer`, now) } // ─── Motor principal del agente ─────────────────────────────────────────────── // ProcessAgentMessage procesa un mensaje de Telegram y retorna la respuesta del agente. func ProcessAgentMessage(chatID int64, userText string, ai *models.AiConfig) (string, error) { // Comandos especiales (sin AI) switch strings.TrimSpace(userText) { case "/reset": if err := models.ClearAgentHistory(chatID); err != nil { return "", err } return "Historial borrado. ¿En qué puedo ayudarte?", nil case "/ayuda", "/start": return `Soy el agente administrador de U-Site. Puedo ayudarte con: • Clientes y contratos • Contabilidad (ingresos, egresos, cuentas por cobrar/pagar) • Proyectos y tickets • Tareas del equipo • Deploy y gestión de apps en Coolify • Monitoreo de servidores y URLs Escríbeme en lenguaje natural, por ejemplo: "muéstrame los clientes de esta semana" "haz deploy de la app abc123 en coolify 1" "¿cuánto ingresamos este mes?" Comandos: /reset /instancias /ayuda`, nil case "/instancias": cfgs, err := models.GetAllCoolifyConfigs() if err != nil { return "", err } if len(cfgs) == 0 { return "No hay instancias de Coolify configuradas.", nil } lines := make([]string, len(cfgs)) for i, c := range cfgs { estado := "✓ activa" if !c.Activo { estado = "✗ inactiva" } lines[i] = fmt.Sprintf("• #%d %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}) } messages = append(messages, agentMessage{Role: "user", Content: userText}) tools := agentTools() // Guardar mensaje del usuario en historial _ = models.SaveAgentMessage(chatID, "user", userText, "", "") // Loop de function calling (máximo 6 rondas) var finalResponse string for round := 0; round < 6; round++ { aiMsg, err := callAI(ai, messages, tools) if err != nil { log.Printf("[AGENT] Error llamando AI round %d: %v", round, err) return "Error al contactar el sistema de IA. Intenta de nuevo.", err } // Sin tool calls → respuesta final if len(aiMsg.ToolCalls) == 0 { content := "" if s, ok := aiMsg.Content.(string); ok { content = s } finalResponse = content _ = models.SaveAgentMessage(chatID, "assistant", content, "", "") break } // Hay tool calls → ejecutar y continuar // Agregar mensaje del AI con tool_calls al contexto (solo en memoria, no en BD) messages = append(messages, *aiMsg) // Ejecutar cada tool call for _, tc := range aiMsg.ToolCalls { var toolArgs map[string]interface{} _ = json.Unmarshal([]byte(tc.Function.Arguments), &toolArgs) log.Printf("[AGENT] Ejecutando tool: %s args: %s", tc.Function.Name, tc.Function.Arguments) toolResult := executeAgentTool(tc.Function.Name, toolArgs) // Agregar resultado al contexto (solo en memoria, no en BD) messages = append(messages, agentMessage{ Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name, Content: toolResult, }) } } if finalResponse == "" { finalResponse = "El agente completó las acciones solicitadas." } return finalResponse, nil }