Files
soft_usite/pkg/services/telegram_agent_service.go
Lizandro GuarnizoandClaude Sonnet 5 e2440b04f1 feat(telegram): el bot resuelve clientes por nombre y mide sus propios fallos
Dos de las cuatro mejoras de la auditoría del agente.

1. Las herramientas aceptan cliente_nombre además de cliente_id.

   De 27 parámetros que eran IDs, 10 eran de cliente. Para cada acción el
   modelo tenía que encadenar: listar_clientes → leer la respuesta →
   encontrar la fila → extraer el número → recién ahí llamar a la
   herramienta. Cuatro pasos, y basta que falle uno para que no se guarde
   nada — es la explicación más probable de la factura que nunca se creó,
   porque ese cliente podía no venir en la primera página del listado.

   Ahora el modelo pasa el nombre y el servidor lo resuelve, con búsqueda
   completa y no paginada. Si hay varios candidatos devuelve la lista para
   que el modelo pregunte cuál, en vez de elegir uno: cargarle una factura
   a la empresa equivocada es peor que preguntar. Una coincidencia exacta
   gana sobre las parciales, así "Metropolitana" no queda ambiguo solo
   porque existe "Metropolitana Norte".

2. Contadores de uso y fallo por herramienta, expuestos como
   estado_herramientas.

   Cada problema se venía diagnosticando de a un caso, reconstruyendo qué
   pasó después de que el usuario avisara. Ahora se puede preguntar
   directamente qué viene fallando y ver si es una herramienta puntual o el
   modelo eligiendo mal. En memoria a propósito: alcanza para responder eso
   y no agrega una tabla.

Quedan las otras dos: adelgazar el prompt de 5,4 KB moviendo los manuales
por dominio a las descripciones de las herramientas, y comparar modelos con
un mismo caso. La primera conviene hacerla con el sistema estable, porque
toca cómo decide el modelo en todos los flujos a la vez.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 21:24:06 -05:00

1984 lines
79 KiB
Go

package services
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"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:"-"`
// Raw guarda el JSON tal cual vino del proveedor cuando este mensaje se
// parseó de una respuesta (no cuando lo armamos nosotros). Algunos
// proveedores mandan campos propios fuera de nuestra struct — ej. Gemini
// exige que el tool_call de un modelo "thinking" se reenvíe con su
// thought_signature intacto, si no rechaza la siguiente ronda con 400.
// Reenviar los bytes originales en vez de reserializar desde los campos
// tipados preserva cualquier campo así sin que tengamos que conocerlo.
Raw json.RawMessage `json:"-"`
}
// UnmarshalJSON guarda una copia de los bytes originales en Raw además de
// poblar los campos normales — así un mensaje que vino de la respuesta del
// proveedor se puede reenviar exactamente como llegó.
func (m *agentMessage) UnmarshalJSON(data []byte) error {
type alias agentMessage
var a alias
if err := json.Unmarshal(data, &a); err != nil {
return err
}
*m = agentMessage(a)
m.Raw = append(json.RawMessage(nil), data...)
return nil
}
// MarshalJSON reenvía Raw tal cual si el mensaje vino de un unmarshal
// previo; si lo armamos nosotros (Raw vacío), serializa los campos normales.
func (m agentMessage) MarshalJSON() ([]byte, error) {
if len(m.Raw) > 0 {
return m.Raw, nil
}
type alias agentMessage
return json.Marshal(alias(m))
}
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"`
// Usage lo devuelven todos los proveedores OpenAI-compatibles con el mismo
// nombre de campo, así que no hace falta un caso por proveedor.
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
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("estado_herramientas", "Muestra qué herramientas se usaron y cuáles fallaron desde que arrancó el sistema. Útil cuando el usuario pregunta por qué algo no se guardó o si algo viene fallando.",
obj(map[string]agentToolParam{}, nil)),
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("crear_factura", "Registra una factura de VENTA a partir de datos escritos, SIN archivo adjunto: U-SITE le factura/cobra al cliente. Usa esta cuando el usuario dicta los datos de la factura. Si en cambio acaba de enviar un PDF o una foto de la factura, usa adjuntar_factura.",
obj(map[string]agentToolParam{
"cliente_id": num("ID del cliente, si ya lo conocés"),
"cliente_nombre": str("Nombre o empresa del cliente, tal como lo dijo el usuario. Alcanza con esto: el servidor lo resuelve. Usalo en vez de buscar el id con listar_clientes."),
"monto": num("Monto total de la factura"),
"numero": str("Número de factura, ej: FEV 92"),
"descripcion": str("Concepto, ej: 'Hosting por 1 año'"),
"fecha_emision": str("Fecha de emisión YYYY-MM-DD; si no se indica, hoy"),
"fecha_vencimiento": str("Fecha de vencimiento YYYY-MM-DD, opcional"),
}, []string{"cliente_id", "monto"})),
tool("actualizar_factura", "Corrige los datos de una factura de venta ya registrada: monto, número, concepto o estado. Útil cuando quedó mal cargada.",
obj(map[string]agentToolParam{
"factura_id": num("ID de la factura (usa listar_facturas si no lo sabes)"),
"monto": num("Nuevo monto, si hay que corregirlo"),
"numero": str("Nuevo número de factura"),
"descripcion": str("Nuevo concepto"),
"estado": agentToolParam{Type: "string", Description: "pendiente | pagada | vencida | cancelada", Enum: []string{"pendiente", "pagada", "vencida", "cancelada", ""}},
}, []string{"factura_id"})),
tool("marcar_cuenta_cobro_pagada", "Marca como pagada una cuenta por cobrar y registra el ingreso en contabilidad.",
obj(map[string]agentToolParam{
"cuenta_id": num("ID de la cuenta por cobrar (usa listar_cuentas_cobro)"),
"fecha_pago": str("Fecha del pago YYYY-MM-DD; si no se indica, hoy"),
}, []string{"cuenta_id"})),
tool("marcar_cuenta_pagar_pagada", "Marca como pagada una cuenta por pagar y registra el egreso en contabilidad.",
obj(map[string]agentToolParam{
"cuenta_id": num("ID de la cuenta por pagar (usa listar_cuentas_pagar)"),
"fecha_pago": str("Fecha del pago YYYY-MM-DD; si no se indica, hoy"),
}, []string{"cuenta_id"})),
tool("adjuntar_factura", "Guarda como factura de VENTA el documento (PDF/foto) que el usuario acaba de enviar por Telegram: U-SITE es quien factura/cobra al cliente. Solo funciona si hay un archivo adjunto pendiente. Si el documento es al revés (un proveedor le factura a U-SITE), usa adjuntar_factura_compra en su lugar, no esta.",
obj(map[string]agentToolParam{
"cliente_id": num("ID del cliente, si ya lo conocés"),
"cliente_nombre": str("Nombre o empresa del cliente, tal como lo dijo el usuario. Alcanza con esto: el servidor lo resuelve. Usalo en vez de buscar el id con listar_clientes."),
"numero": str("Número de factura, si el usuario lo indicó"),
"monto": num("Monto de la factura, si el usuario lo indicó"),
"descripcion": str("Descripción breve, ej: 'Factura mensual octubre'"),
}, []string{"cliente_id"})),
tool("adjuntar_factura_compra", "Guarda como factura de COMPRA (cuenta por pagar) el documento que el usuario acaba de enviar por Telegram: un proveedor le está facturando a U-SITE, no al revés. Busca o crea el proveedor por nombre. Solo funciona si hay un archivo adjunto pendiente.",
obj(map[string]agentToolParam{
"proveedor": str("Nombre del proveedor que emitió la factura (quien la firma/factura, no U-SITE)"),
"monto": num("Monto de la factura"),
"descripcion": str("Descripción breve de la compra, ej: 'Hosting AWS octubre'"),
}, []string{"proveedor", "monto"})),
// ── Clientes ─────────────────────────────────────────────────────────
tool("listar_clientes", "Lista clientes con paginación y búsqueda.",
obj(map[string]agentToolParam{
"page": num("Página (default 1)"),
"search": str("Texto de búsqueda"),
}, nil)),
tool("crear_cliente", "Crea un nuevo cliente.",
obj(map[string]agentToolParam{
"nombre": str("Nombre del cliente"),
"empresa": str("Empresa"),
"email": str("Correo electrónico"),
"telefono": str("Teléfono"),
"documento": str("Documento de identidad"),
"notas": str("Notas adicionales"),
}, []string{"nombre"})),
// ── Contratos ────────────────────────────────────────────────────────
tool("listar_contratos", "Lista contratos con filtros.",
obj(map[string]agentToolParam{
"page": num("Página"),
"search": str("Búsqueda"),
"estado": str("Estado del contrato"),
}, nil)),
tool("renovar_contrato", "Renueva un contrato por su ID.",
obj(map[string]agentToolParam{"id": num("ID del contrato")}, []string{"id"})),
tool("enviar_correo_contrato", "Envía notificación de renovación por correo al cliente.",
obj(map[string]agentToolParam{"id": num("ID del contrato")}, []string{"id"})),
tool("crear_contrato", "Crea un contrato para un cliente con uno o más servicios y genera de una vez su PDF con cláusulas estándar.",
obj(map[string]agentToolParam{
"cliente_id": num("ID del cliente, si ya lo conocés"),
"cliente_nombre": str("Nombre o empresa del cliente, tal como lo dijo el usuario. Alcanza con esto: el servidor lo resuelve. Usalo en vez de buscar el id con listar_clientes."),
"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, si ya lo conocés"),
"cliente_nombre": str("Nombre o empresa del cliente, tal como lo dijo el usuario. Alcanza con esto: el servidor lo resuelve. Usalo en vez de buscar el id con listar_clientes."),
"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, si ya lo conocés"),
"cliente_nombre": str("Nombre o empresa del cliente, tal como lo dijo el usuario. Alcanza con esto: el servidor lo resuelve. Usalo en vez de buscar el id con listar_clientes."),
"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, si ya lo conocés"),
"cliente_nombre": str("Nombre o empresa del cliente, tal como lo dijo el usuario. Alcanza con esto: el servidor lo resuelve. Usalo en vez de buscar el id con listar_clientes."),
"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, si ya lo conocés"),
"cliente_nombre": str("Nombre o empresa del cliente, tal como lo dijo el usuario. Alcanza con esto: el servidor lo resuelve. Usalo en vez de buscar el id con listar_clientes."),
"nombre": str("Nombre del proyecto"),
"stack": str("Stack tecnológico, ej: Go + React + PostgreSQL"),
"descripcion": str("Descripción adicional del proyecto"),
}, []string{"cliente_id", "nombre"})),
tool("crear_fase_proyecto", "Agrega una fase/etapa a un proyecto existente (ej: Diseño, Desarrollo, QA, Entrega). Se puede llamar varias veces seguidas para registrar todas las fases de un proyecto de una vez.",
obj(map[string]agentToolParam{
"proyecto_id": num("ID del proyecto (usa listar_proyectos si no lo sabes)"),
"nombre": str("Nombre de la fase"),
"descripcion": str("Descripción de la fase"),
"estado": agentToolParam{Type: "string", Enum: []string{"pendiente", "en_progreso", "completado"}, Description: "Estado inicial (default pendiente)"},
"orden": num("Orden de la fase dentro del proyecto (0, 1, 2...)"),
"fecha_estimada": str("Fecha estimada de entrega, formato YYYY-MM-DD"),
}, []string{"proyecto_id", "nombre"})),
tool("adjuntar_documento_proyecto", "Guarda como documento del proyecto el archivo (PDF, foto, Word, Excel o cualquier otro tipo) que el usuario acaba de enviar por Telegram en este chat. Solo funciona si hay un archivo adjunto pendiente.",
obj(map[string]agentToolParam{
"proyecto_id": num("ID del proyecto (usa listar_proyectos si no lo sabes)"),
"tipo": agentToolParam{Type: "string", Enum: []string{"contrato", "orden_servicio", "otro"}, Description: "Tipo de documento (default otro)"},
"nombre": str("Nombre del documento"),
"descripcion": str("Descripción breve"),
}, []string{"proyecto_id"})),
// ── Tickets ──────────────────────────────────────────────────────────
tool("listar_tickets", "Lista tickets de soporte de todos los proyectos.",
obj(map[string]agentToolParam{
"page": num("Página"),
"estado": str("abierto | cerrado | en_proceso"),
}, nil)),
tool("responder_ticket", "Responde un ticket de soporte.",
obj(map[string]agentToolParam{
"ticket_id": num("ID del ticket"),
"mensaje": str("Mensaje de respuesta"),
}, []string{"ticket_id", "mensaje"})),
// ── Tareas ───────────────────────────────────────────────────────────
tool("listar_tareas", "Lista tareas del Kanban (resumen: título, estado, prioridad, asignado — sin descripción completa ni historial de comentarios). Para ver el detalle completo de una tarea puntual usa detalle_tarea.",
obj(map[string]agentToolParam{
"estado": str("pendiente | en_progreso | completada | cancelada"),
}, nil)),
tool("detalle_tarea", "Trae el detalle completo de una tarea puntual: descripción completa y el historial de comentarios/interacciones (quién comentó, qué dijo, cuándo, y archivos adjuntos si los hay). Úsala cuando el usuario pida el cuerpo, la descripción o el historial de una tarea específica — listar_tareas no trae eso.",
obj(map[string]agentToolParam{
"id": num("ID de la tarea (usa listar_tareas si no lo sabes)"),
}, []string{"id"})),
tool("listar_usuarios", "Lista los usuarios internos del sistema (staff), para poder asignarles tareas.",
obj(map[string]agentToolParam{"search": str("Búsqueda por nombre")}, nil)),
tool("crear_tarea", "Crea una nueva tarea, opcionalmente asignada a alguien desde el inicio.",
obj(map[string]agentToolParam{
"titulo": str("Título de la tarea"),
"descripcion": str("Descripción"),
"estado": agentToolParam{Type: "string", Enum: []string{"por_hacer", "en_progreso"}, Description: "Estado inicial (default por_hacer). Debe coincidir con las columnas del tablero Kanban."},
"prioridad": agentToolParam{Type: "string", Enum: []string{"baja", "media", "alta"}, Description: "Prioridad"},
"asignado_id": num("ID del usuario responsable (usa listar_usuarios si no lo sabes)"),
"fecha_limite": str("Fecha límite, formato YYYY-MM-DD"),
}, []string{"titulo"})),
tool("asignar_tarea", "Asigna (o reasigna) una tarea existente a un usuario. Notifica al usuario asignado.",
obj(map[string]agentToolParam{
"id": num("ID de la tarea"),
"asignado_id": num("ID del usuario responsable (usa listar_usuarios si no lo sabes)"),
}, []string{"id", "asignado_id"})),
tool("adjuntar_documento_tarea", "Guarda como comentario de una tarea el archivo (PDF, foto, Word, Excel o cualquier otro tipo) que el usuario acaba de enviar por Telegram en este chat. Úsala cuando el usuario mande un archivo junto con instrucciones sobre una tarea (nueva o existente), sin importar el formato. Solo funciona si hay un archivo adjunto pendiente. Si la tarea es nueva, primero créala con crear_tarea y usa el id que devuelve.",
obj(map[string]agentToolParam{
"id": num("ID de la tarea a la que se adjunta el archivo"),
"comentario": str("Comentario que acompaña al archivo (opcional)"),
}, []string{"id"})),
tool("actualizar_estado_tarea", "Cambia el estado de una tarea. Los valores deben coincidir con las columnas del tablero Kanban del dashboard.",
obj(map[string]agentToolParam{
"id": num("ID de la tarea"),
"estado": agentToolParam{
Type: "string",
Enum: []string{"por_hacer", "en_progreso", "revision", "hecho"},
Description: "por_hacer=nueva, en_progreso=en curso, revision=terminada pendiente de revisar, hecho=cerrada",
},
}, []string{"id", "estado"})),
// ── Servidores ───────────────────────────────────────────────────────
tool("listar_servidores", "Lista los servidores monitoreados.",
obj(map[string]agentToolParam{"search": str("Búsqueda")}, nil)),
// ── Monitor URLs ─────────────────────────────────────────────────────
tool("listar_monitores", "Lista los monitores de URL activos.",
obj(nil, nil)),
}
}
// ─── Ejecución de herramientas ────────────────────────────────────────────────
func executeAgentTool(chatID int64, name string, args map[string]interface{}) string {
result, err := runTool(chatID, name, args)
if err != nil {
return fmt.Sprintf(`{"error": %q}`, err.Error())
}
b, _ := json.Marshal(result)
return string(b)
}
func runTool(chatID int64, name string, a map[string]interface{}) (interface{}, error) {
getInt := func(key string, def int) int {
if v, ok := a[key]; ok {
switch x := v.(type) {
case float64:
return int(x)
case int:
return x
}
}
return def
}
// Los montos van por acá y no por getInt: getInt trunca los decimales y
// devuelve 0 si el modelo manda el número como texto ("290000"), que es un
// formato que sí usa. El resultado era una factura guardada en 0 mientras
// el bot informaba el monto correcto, porque el bot repite lo que dijo el
// usuario, no lo que se guardó.
getFloat := func(key string, def float64) float64 {
v, ok := a[key]
if !ok {
return def
}
switch x := v.(type) {
case float64:
return x
case int:
return float64(x)
case string:
limpio := strings.NewReplacer("$", "", ",", "", " ", "", "COP", "").Replace(x)
if f, err := strconv.ParseFloat(limpio, 64); err == nil {
return f
}
}
return def
}
getStr := func(key string) string {
if v, ok := a[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// Resuelve el cliente por id o por nombre. Ver agent_resolver.go: evita que
// el modelo tenga que encadenar listar_clientes → leer → extraer id.
resolverCliente := func() (uint, error) {
return ResolverClienteID(uint(getInt("cliente_id", 0)), getStr("cliente_nombre"))
}
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 "crear_factura":
clienteID, err := resolverCliente()
if err != nil {
return nil, err
}
monto := getFloat("monto", 0)
if monto <= 0 {
return nil, fmt.Errorf("el monto debe ser mayor a cero")
}
emision := time.Now()
if v := getStr("fecha_emision"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
emision = t
}
}
var vence *time.Time
if v := getStr("fecha_vencimiento"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
vence = &t
}
}
f := &models.Factura{
ClienteID: clienteID,
Numero: getStr("numero"),
Descripcion: getStr("descripcion"),
Monto: monto,
Moneda: "COP",
Estado: "pendiente",
FechaEmision: emision,
FechaVencimiento: vence,
Visible: true,
}
if err := models.CreateFactura(f); err != nil {
return nil, err
}
// Se devuelven los valores guardados, no los pedidos: es lo que el
// prompt le exige informar al usuario.
return map[string]interface{}{
"ok": true, "factura_id": f.ID, "monto_guardado": f.Monto,
"numero": f.Numero, "cliente_id": f.ClienteID,
}, nil
case "actualizar_factura":
id := uint(getInt("factura_id", 0))
if id == 0 {
return nil, fmt.Errorf("factura_id requerido")
}
f, err := models.GetFacturaByID(id)
if err != nil {
return nil, fmt.Errorf("no se encontró la factura %d", id)
}
// Solo se pisan los campos que vinieron: así corregir el monto no
// borra el número ni el concepto que ya estaban bien.
if v := getFloat("monto", -1); v >= 0 {
f.Monto = v
}
if v := getStr("numero"); v != "" {
f.Numero = v
}
if v := getStr("descripcion"); v != "" {
f.Descripcion = v
}
if v := getStr("estado"); v != "" {
f.Estado = v
}
if err := models.UpdateFactura(f); err != nil {
return nil, err
}
return map[string]interface{}{
"ok": true, "factura_id": f.ID, "monto_guardado": f.Monto,
"numero": f.Numero, "estado": f.Estado,
}, nil
case "marcar_cuenta_cobro_pagada":
id := uint(getInt("cuenta_id", 0))
if id == 0 {
return nil, fmt.Errorf("cuenta_id requerido")
}
fecha := time.Now()
if v := getStr("fecha_pago"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
fecha = t
}
}
if err := models.MarcarCuentaCobroPagada(id, fecha); err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "cuenta_id": id, "fecha_pago": fecha.Format("2006-01-02")}, nil
case "marcar_cuenta_pagar_pagada":
id := uint(getInt("cuenta_id", 0))
if id == 0 {
return nil, fmt.Errorf("cuenta_id requerido")
}
fecha := time.Now()
if v := getStr("fecha_pago"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
fecha = t
}
}
if err := models.MarcarCuentaPagarPagada(id, fecha); err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "cuenta_id": id, "fecha_pago": fecha.Format("2006-01-02")}, 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, err := resolverCliente()
if err != nil {
return nil, err
}
f := &models.Factura{
ClienteID: clienteID,
Numero: getStr("numero"),
Descripcion: getStr("descripcion"),
Monto: getFloat("monto", 0),
Estado: "pendiente",
FechaEmision: time.Now(),
Visible: true,
}
if err := models.CreateFactura(f); err != nil {
_ = os.Remove(att.Path)
return nil, err
}
finalDir := "uploads/facturas"
if err := os.MkdirAll(finalDir, 0755); err != nil {
return nil, err
}
ext := filepath.Ext(att.OriginalName)
finalPath := filepath.Join(finalDir, fmt.Sprintf("%d_telegram%s", f.ID, ext))
if err := os.Rename(att.Path, finalPath); err != nil {
return nil, fmt.Errorf("no se pudo guardar el archivo: %w", err)
}
if err := models.UpdateFacturaArchivo(f.ID, finalPath, att.OriginalName); err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "factura_id": f.ID}, nil
case "adjuntar_factura_compra":
cp, err := AdjuntarFacturaCompra(chatID, getStr("proveedor"), getStr("descripcion"), getFloat("monto", 0))
if err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "cuenta_pagar_id": cp.ID, "proveedor_id": cp.EntidadID}, nil
case "estado_herramientas":
return map[string]interface{}{"items": EstadoHerramientas()}, 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))
}
}
clienteID, err := resolverCliente()
if err != nil {
return nil, err
}
contrato, doc, err := CrearContratoConDocumento(
clienteID,
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: getFloat("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":
clienteID, err := resolverCliente()
if err != nil {
return nil, err
}
cc, doc, err := CrearCuentaCobroConDocumento(
clienteID,
getStr("descripcion"),
getFloat("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"]),
})
}
clienteID, err := resolverCliente()
if err != nil {
return nil, err
}
doc, total, err := CrearCotizacion(clienteID, 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":
clienteID, err := resolverCliente()
if err != nil {
return nil, err
}
p, err := CrearProyectoSimple(clienteID, 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 "detalle_tarea":
id := uint(getInt("id", 0))
if id == 0 {
return nil, fmt.Errorf("id requerido")
}
tarea, err := models.GetTareaByID(id)
if err != nil {
return nil, fmt.Errorf("tarea no encontrada: %w", err)
}
comentarios, err := models.GetComentariosByTarea(id)
if err != nil {
return nil, err
}
return map[string]interface{}{"tarea": tarea, "comentarios": comentarios}, nil
case "listar_usuarios":
users, _, err := models.AllUsersSistema(50, 0, getStr("search"))
if err != nil {
return nil, err
}
type usuarioItem struct {
ID uint `json:"id"`
Name string `json:"name"`
}
out := make([]usuarioItem, len(users))
for i, u := range users {
out[i] = usuarioItem{ID: u.ID, Name: u.Name}
}
return out, nil
case "crear_tarea":
var asignadoID *uint
if v := getInt("asignado_id", 0); v > 0 {
u := uint(v)
asignadoID = &u
}
var fechaLimite *time.Time
if s := getStr("fecha_limite"); s != "" {
if t, errParse := time.Parse("2006-01-02", s); errParse == nil {
fechaLimite = &t
}
}
t, err := CrearTareaAsignada(getStr("titulo"), getStr("descripcion"), getStr("estado"), getStr("prioridad"), asignadoID, fechaLimite)
if err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "id": t.ID, "titulo": t.Titulo}, nil
case "asignar_tarea":
id := uint(getInt("id", 0))
asignadoIDVal := getInt("asignado_id", 0)
if id == 0 || asignadoIDVal == 0 {
return nil, fmt.Errorf("id y asignado_id requeridos")
}
asignadoID := uint(asignadoIDVal)
t, err := AsignarTarea(id, &asignadoID)
if err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "id": t.ID, "asignado_id": asignadoID}, nil
case "adjuntar_documento_tarea":
c, err := AdjuntarDocumentoTarea(chatID, uint(getInt("id", 0)), getStr("comentario"))
if err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "comentario_id": c.ID, "tarea_id": c.TareaID}, nil
case "actualizar_estado_tarea":
id := uint(getInt("id", 0))
estado := getStr("estado")
if id == 0 || estado == "" {
return nil, fmt.Errorf("id y estado requeridos")
}
if err := models.CambiarEstadoTarea(id, estado); err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "id": id, "estado": estado}, nil
// ── Servidores ─────────────────────────────────────────────────────────
case "listar_servidores":
search := getStr("search")
items, _, err := models.GetAllServidores(50, 0, search)
if err != nil {
return nil, err
}
return items, nil
// ── Monitor URLs ───────────────────────────────────────────────────────
case "listar_monitores":
items, err := models.GetAllUrlMonitors()
if err != nil {
return nil, err
}
return items, nil
default:
return nil, fmt.Errorf("herramienta desconocida: %s", name)
}
}
// ─── Coolify helpers internos ─────────────────────────────────────────────────
func coolifyCall(method, endpoint string, body []byte, cfg *models.CoolifyConfig) (interface{}, error) {
base := strings.TrimRight(cfg.BaseURL, "/")
url := base + "/api/v1" + endpoint
var reqBody io.Reader
if body != nil {
reqBody = bytes.NewReader(body)
}
req, err := http.NewRequest(method, url, reqBody)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+cfg.ApiToken)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
// Coolify devuelve el detalle del error en el body; sin revisar el status,
// un 401/404/500 se reportaría al usuario como si la acción hubiera salido bien.
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
detalle := strings.TrimSpace(string(raw))
if len(detalle) > 300 {
detalle = detalle[:300]
}
switch resp.StatusCode {
case http.StatusUnauthorized, http.StatusForbidden:
return nil, fmt.Errorf("Coolify rechazó la petición (%d): el API token de la instancia es inválido o no tiene permisos", resp.StatusCode)
case http.StatusNotFound:
return nil, fmt.Errorf("Coolify no encontró el recurso (404) en %s — revisa el uuid", endpoint)
default:
return nil, fmt.Errorf("Coolify respondió %d: %s", resp.StatusCode, detalle)
}
}
var result interface{}
if err := json.Unmarshal(raw, &result); err != nil {
return map[string]string{"raw": string(raw)}, nil
}
return result, nil
}
// coolifyConfigDe resuelve la instancia de Coolify a usar. Si no se indicó
// config_id y solo hay una instancia activa configurada, la toma por defecto
// para que el usuario no tenga que saberse los IDs de memoria.
func coolifyConfigDe(configIDInt int) (*models.CoolifyConfig, error) {
if configIDInt > 0 {
cfg, err := models.GetCoolifyConfigByID(uint(configIDInt))
if err != nil {
return nil, fmt.Errorf("instancia Coolify #%d no encontrada", configIDInt)
}
return cfg, nil
}
cfgs, err := models.GetAllCoolifyConfigs()
if err != nil {
return nil, err
}
var activas []models.CoolifyConfig
for _, c := range cfgs {
if c.Activo {
activas = append(activas, c)
}
}
if len(activas) == 1 {
return &activas[0], nil
}
if len(activas) == 0 {
return nil, fmt.Errorf("no hay instancias de Coolify activas configuradas")
}
return nil, fmt.Errorf("hay varias instancias de Coolify: indica cuál usar con config_id (consulta coolify_instancias)")
}
// coolifyBasePorTipo mapea el tipo de recurso al prefijo de la API de Coolify.
func coolifyBasePorTipo(tipo string) (string, bool) {
base, ok := map[string]string{
"app": "/applications",
"servicio": "/services",
"base_datos": "/databases",
"servidor": "/servers",
"proyecto": "/projects",
}[tipo]
return base, ok
}
// ─── Tipos Anthropic ─────────────────────────────────────────────────────────
type anthropicTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema agentToolParam `json:"input_schema"`
}
type anthropicContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Content string `json:"content,omitempty"`
Source *anthropicBlockSource `json:"source,omitempty"`
}
// anthropicBlockSource es el contenido en base64 de una imagen o PDF adjunto,
// para que Claude lea el archivo real en vez de solo un aviso en texto.
type anthropicBlockSource struct {
Type string `json:"type"` // "base64"
MediaType string `json:"media_type"`
Data string `json:"data"`
}
type anthropicMsg struct {
Role string `json:"role"`
Content interface{} `json:"content"` // string o []anthropicContentBlock
}
type anthropicReq struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System string `json:"system,omitempty"`
Messages []anthropicMsg `json:"messages"`
Tools []anthropicTool `json:"tools,omitempty"`
}
type anthropicResp struct {
Content []anthropicContentBlock `json:"content"`
StopReason string `json:"stop_reason"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
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).
// El segundo valor son los tokens totales que reportó el proveedor (0 si no
// los informa) — lo usa uMind para medir el consumo facturable.
func callAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, int, 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, int, 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, 0, err
}
req.Header.Set("Authorization", "Bearer "+ai.ClaveEnClaro())
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, 0, 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, 0, fmt.Errorf("respuesta inesperada del AI: %s", string(raw[:min(200, len(raw))]))
}
if chatResp.Error != nil {
return nil, 0, fmt.Errorf("error del AI: %s", chatResp.Error.Message)
}
if len(chatResp.Choices) == 0 {
return nil, 0, fmt.Errorf("el AI no devolvió respuesta")
}
msg := chatResp.Choices[0].Message
return &msg, chatResp.Usage.TotalTokens, nil
}
// callAnthropicAI llama a la API nativa de Anthropic con tool use.
func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, int, 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, 0, err
}
req.Header.Set("x-api-key", ai.ClaveEnClaro())
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, 0, 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, 0, fmt.Errorf("respuesta inesperada de Anthropic: %s", string(raw[:min(200, len(raw))]))
}
if anthropicRsp.Error != nil {
return nil, 0, 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, anthropicRsp.Usage.InputTokens + anthropicRsp.Usage.OutputTokens, 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"
case "gemini", "google":
// Capa de compatibilidad OpenAI de Gemini — sin esto, "gemini" caía al
// default (la URL de OpenAI) y fallaba siempre con la API key de Google.
return "https://generativelanguage.googleapis.com/v1beta/openai"
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:
- NUNCA digas que guardaste, creaste, actualizaste o borraste algo si la
herramienta no te devolvió un resultado exitoso. Si devolvió {"error": ...},
decí exactamente qué falló y qué hace falta para reintentar. Un "guardado
exitosamente" sobre algo que no se guardó es peor que un error: la persona
se entera días después, cuando el registro no aparece.
- Al confirmar que guardaste algo, informá los valores que devolvió la
herramienta (por ejemplo el id), no los que te dijo el usuario: son los que
realmente quedaron en el sistema.
- Responde siempre en español, de forma clara y concisa
- Usa las herramientas para obtener datos reales antes de responder
- Cuando el usuario pida una acción (deploy, renovar, crear), hazla directamente sin pedir confirmación salvo que sea destructiva
- Para listar datos, muestra los más relevantes en formato legible
- Los valores monetarios son en COP (pesos colombianos)
- Si una herramienta falla, explica el error y sugiere alternativas
- Si el usuario solo saluda o pregunta qué puedes hacer (sin pedir una acción concreta), responde igual que el comando /ayuda: breve, organizado por categoría, sin inventar capacidades que no tienes
- En Coolify: si solo hay una instancia activa se usa automáticamente, no hace falta preguntar por config_id; si hay varias, consulta coolify_instancias y pregunta cuál. Para actuar sobre algo necesitas su UUID: búscalo con coolify_listar por nombre antes de llamar a coolify_accion. Reiniciar/desplegar es rutinario, hazlo directo; detener algo en producción sí confírmalo antes. Si un despliegue falla, revisa coolify_logs y coolify_deployments para explicar la causa concreta
- Cuando el mensaje empiece con "[Documento adjunto recibido: ...]", el usuario acaba de enviar un archivo por Telegram (puede ser cualquier formato: PDF, foto, Word, Excel, etc.). Si es PDF o foto, viene adjunto a este mismo mensaje y puedes leerlo directamente. Si es otro formato (Word, Excel, zip, etc.) no puedes ver su contenido, solo el nombre — de todas formas guárdalo con la herramienta que corresponda usando lo que el usuario haya escrito como contexto, no rechaces el archivo por el formato. Decide primero de qué se trata el archivo antes de guardarlo:
· Si es una factura → primero determina la dirección leyendo quién emite y quién recibe: si el emisor/vendedor es "U-Site S.A.S BIC" (o similar) y el destinatario es un cliente, es una factura de VENTA → identifica cliente, monto y número, resuelve el cliente_id con listar_clientes, y llama a adjuntar_factura. Si es al revés — un proveedor (AWS, Coolify, un hosting, cualquier tercero) le está facturando A U-Site — es una factura de COMPRA → llama a adjuntar_factura_compra con el nombre de ese proveedor, no busques cliente_id. Ante la duda, mira a nombre de quién sale la plata: si U-Site cobra, es venta; si U-Site paga, es compra
· Si es un documento de un proyecto (contrato, orden de servicio, entregable, etc.) → identifica a qué proyecto corresponde (usa listar_proyectos si hace falta) y llama a adjuntar_documento_proyecto
· Si el mensaje habla de una tarea (ej: "asigna esto a Natalia", "esta es la tarea para el diseño") → usa adjuntar_documento_tarea. Si la tarea todavía no existe, créala primero con crear_tarea (con asignado_id si corresponde) y luego llama a adjuntar_documento_tarea con el id que te devolvió; si ya existe, resuélvela con listar_tareas
Si logras identificar los datos necesarios (del documento o de lo que escribió el usuario), guarda directamente sin pedir confirmación — solo pregunta si de verdad no hay forma de determinar el cliente/proyecto/tarea o falta un dato imprescindible. No uses estas tools si el mensaje no menciona ningún documento adjunto
- Para registrar un proyecto nuevo con sus fases (el usuario puede describirlo en texto o mandarlo en un documento), primero usa crear_proyecto y luego llama a crear_fase_proyecto una vez por cada fase que corresponda — no hace falta preguntar confirmación entre cada fase
- Para asignar una tarea a alguien, resuelve el nombre con listar_usuarios y usa asignado_id en crear_tarea (si es nueva) o asignar_tarea (si ya existe) — la persona asignada recibe una notificación. Si además el usuario mandó un archivo junto con la instrucción de asignar, después de crear/asignar la tarea usa también adjuntar_documento_tarea para guardar ese archivo en la tarea
- Si preguntan por el detalle, la descripción completa o el historial/comentarios de una tarea específica, usa detalle_tarea (no listar_tareas, que solo da un resumen sin eso) — resuelve el id con listar_tareas primero si hace falta
COMANDOS ESPECIALES (el usuario puede escribirlos):
- /reset — olvidar el historial de esta conversación
- /instancias — listar instancias de Coolify
- /ayuda — mostrar qué puedes hacer`, now)
}
// ─── Motor principal del agente ───────────────────────────────────────────────
// ProcessAgentMessage procesa un mensaje de Telegram y retorna la respuesta del agente.
func ProcessAgentMessage(chatID int64, userText string, ai *models.AiConfig) (string, error) {
// Comandos especiales (sin AI)
switch strings.TrimSpace(userText) {
case "/reset":
if err := models.ClearAgentHistory(chatID); err != nil {
return "", err
}
return "Historial borrado. ¿En qué puedo ayudarte?", nil
case "/ayuda", "/start":
return `¡Hola! 👋 Soy el asistente de administración de U-Site S.A.S BIC. Esto es lo que puedo hacer por ti:
✅ Clientes y facturación
Listar y crear clientes · facturas de venta y compra · cuentas por cobrar y pagar
✅ Contratos y cotizaciones
Crear, renovar y gestionar contratos · generar cotizaciones y propuestas técnicas
✅ Proyectos y soporte
Crear proyectos y fases · gestionar tickets · adjuntar documentos (PDF, fotos, Word, Excel, etc.)
✅ Tareas del equipo
Crear y asignar tareas · ver el tablero Kanban
✅ Coolify (deploy y control)
Listar apps, servicios y bases de datos · deploy, reinicio y detención · logs y despliegues
✅ Contabilidad
Dashboard de ingresos/egresos · transacciones · tarifas
✅ Monitoreo
Servidores y URLs vigiladas
¿Qué necesitas? Escríbeme en lenguaje natural, por ejemplo:
"muéstrame los clientes de esta semana"
"haz deploy de la app abc123 en coolify 1"
"¿cuánto ingresamos este mes?"
Comandos: /reset · /instancias · /ayuda`, nil
case "/instancias":
cfgs, err := models.GetAllCoolifyConfigs()
if err != nil {
return "", err
}
if len(cfgs) == 0 {
return "No hay instancias de Coolify configuradas.", nil
}
lines := make([]string, len(cfgs))
for i, c := range cfgs {
estado := "✓ activa"
if !c.Activo {
estado = "✗ inactiva"
}
lines[i] = fmt.Sprintf("• #%d **%s** — %s (%s)", c.ID, c.Nombre, c.BaseURL, estado)
}
return "**Instancias de Coolify:**\n" + strings.Join(lines, "\n"), nil
}
// Cargar historial (últimos 20 mensajes)
// Solo cargar user + assistant (texto), sin tool_calls ni tool_results intermedios.
// Esto mantiene el hilo conversacional sin reenviar JSON de datos que ya fueron procesados.
history, _ := models.GetAgentHistory(chatID, 10)
messages := []agentMessage{
{Role: "system", Content: agentSystemPrompt()},
}
for _, h := range history {
if h.Role != "user" && h.Role != "assistant" {
continue
}
messages = append(messages, agentMessage{Role: h.Role, Content: h.Content})
}
userMsg := agentMessage{Role: "user", Content: userText}
// Si hay un documento/foto pendiente en este chat, se lo pasamos al modelo
// como archivo real (solo Anthropic lo lee de verdad) en vez de que la IA
// tenga que adivinar cliente/monto a partir del nombre del archivo. Solo se
// hace esto para tipos que la API de Claude sabe previsualizar (imagen o
// PDF): un .docx/.xlsx/etc mandado como bloque "image" lo rechaza la API
// (400) y tumba toda la respuesta. Para esos, el archivo se queda igual
// adjunto/guardado (adjuntar_documento_tarea, etc.), solo que la IA no
// puede "leer" su contenido, solo el nombre.
if strings.ToLower(ai.Provider) == "anthropic" {
if att, ok := PeekTelegramAttachment(chatID); ok {
mime := ResolverMimeType(att.MimeType, att.OriginalName)
if esMimePrevisualizablePorClaude(mime) {
if data, err := os.ReadFile(att.Path); err == nil {
userMsg.AttachmentBase64 = base64.StdEncoding.EncodeToString(data)
userMsg.AttachmentMime = mime
} else {
log.Printf("[AGENT] No se pudo leer el adjunto pendiente (%s): %v", att.Path, err)
}
}
}
}
messages = append(messages, userMsg)
tools := agentTools()
// Guardar mensaje del usuario en historial
_ = models.SaveAgentMessage(chatID, "user", userText, "", "")
// Loop de function calling (máximo 6 rondas)
var finalResponse string
// Herramientas que devolvieron error en esta conversación, para avisarlo al
// final aunque el modelo diga otra cosa.
var fallidas []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)
// El resultado también se registra: el modelo puede decir que
// guardó algo aunque la herramienta haya devuelto un error, así que
// el log es la única fuente confiable de qué pasó de verdad.
log.Printf("[AGENT] Resultado de %s: %s", tc.Function.Name, recortar(toolResult, 500))
msg := errorDeTool(toolResult)
RegistrarUsoHerramienta(tc.Function.Name, msg != "")
if msg != "" {
fallidas = append(fallidas, fmt.Sprintf("%s: %s", tc.Function.Name, msg))
}
// 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."
}
// Aviso determinista, escrito por el código y no por el modelo.
//
// El modelo puede informar "guardado exitosamente" aunque la herramienta
// haya devuelto un error — ya pasó con una factura que nunca se creó. El
// prompt se lo prohíbe, pero un prompt es una sugerencia: esto es la
// garantía. Si algo falló, se ve, diga lo que diga el modelo.
if len(fallidas) > 0 {
finalResponse += "\n\n⚠️ No se completó: " + strings.Join(fallidas, " · ")
}
return finalResponse, nil
}
// errorDeTool devuelve el mensaje si el resultado de una herramienta fue un
// error, o "" si salió bien. executeAgentTool serializa los fallos como
// {"error": "..."}, así que alcanza con mirar ese campo.
func errorDeTool(resultado string) string {
var r struct {
Error string `json:"error"`
}
if err := json.Unmarshal([]byte(resultado), &r); err != nil {
return ""
}
return r.Error
}
// recortar limita lo que se escribe al log: un resultado de tool puede traer
// listados enteros y no vale la pena volcarlos completos.
func recortar(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}