feat: registrar proyectos con fases, adjuntar documentos y asignar tareas por Telegram

- crear_fase_proyecto: agrega fases/etapas a un proyecto (se puede llamar
  varias veces seguidas para cargar todas las fases de un proyecto nuevo).
- adjuntar_documento_proyecto: guarda el archivo que el usuario acaba de
  enviar por Telegram como documento del proyecto (mismo mecanismo de
  adjuntos ya usado para facturas — se generalizó TelegramAttachment para
  servir a ambos casos).
- listar_usuarios + asignado_id en crear_tarea + asignar_tarea: permite
  asignar tareas a alguien del equipo por Telegram, disparando la misma
  notificación (Telegram/email/sistema) que ya dispara el dashboard.
- El prompt del sistema ahora indica cómo decidir entre adjuntar_factura y
  adjuntar_documento_proyecto según el contexto del archivo recibido.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro GD
2026-08-03 02:11:02 +00:00
co-authored by Claude Sonnet 5
parent cd0ca6d232
commit a0b2e9ae0a
5 changed files with 278 additions and 45 deletions
+97 -19
View File
@@ -282,6 +282,22 @@ func agentTools() []agentTool {
"stack": str("Stack tecnológico, ej: Go + React + PostgreSQL"),
"descripcion": str("Descripción adicional del proyecto"),
}, []string{"cliente_id", "nombre"})),
tool("crear_fase_proyecto", "Agrega una fase/etapa a un proyecto existente (ej: Diseño, Desarrollo, QA, Entrega). Se puede llamar varias veces seguidas para registrar todas las fases de un proyecto de una vez.",
obj(map[string]agentToolParam{
"proyecto_id": num("ID del proyecto (usa listar_proyectos si no lo sabes)"),
"nombre": str("Nombre de la fase"),
"descripcion": str("Descripción de la fase"),
"estado": agentToolParam{Type: "string", Enum: []string{"pendiente", "en_progreso", "completado"}, Description: "Estado inicial (default pendiente)"},
"orden": num("Orden de la fase dentro del proyecto (0, 1, 2...)"),
"fecha_estimada": str("Fecha estimada de entrega, formato YYYY-MM-DD"),
}, []string{"proyecto_id", "nombre"})),
tool("adjuntar_documento_proyecto", "Guarda como documento del proyecto el archivo (PDF/foto) que el usuario acaba de enviar por Telegram en este chat. Solo funciona si hay un archivo adjunto pendiente.",
obj(map[string]agentToolParam{
"proyecto_id": num("ID del proyecto (usa listar_proyectos si no lo sabes)"),
"tipo": agentToolParam{Type: "string", Enum: []string{"contrato", "orden_servicio", "otro"}, Description: "Tipo de documento (default otro)"},
"nombre": str("Nombre del documento"),
"descripcion": str("Descripción breve"),
}, []string{"proyecto_id"})),
// ── Tickets ──────────────────────────────────────────────────────────
tool("listar_tickets", "Lista tickets de soporte de todos los proyectos.",
@@ -300,13 +316,22 @@ func agentTools() []agentTool {
obj(map[string]agentToolParam{
"estado": str("pendiente | en_progreso | completada | cancelada"),
}, nil)),
tool("crear_tarea", "Crea una nueva tarea.",
tool("listar_usuarios", "Lista los usuarios internos del sistema (staff), para poder asignarles tareas.",
obj(map[string]agentToolParam{"search": str("Búsqueda por nombre")}, nil)),
tool("crear_tarea", "Crea una nueva tarea, opcionalmente asignada a alguien desde el inicio.",
obj(map[string]agentToolParam{
"titulo": str("Título de la tarea"),
"descripcion": str("Descripción"),
"estado": agentToolParam{Type: "string", Enum: []string{"pendiente", "en_progreso"}, Description: "Estado inicial"},
"prioridad": agentToolParam{Type: "string", Enum: []string{"baja", "media", "alta"}, Description: "Prioridad"},
"titulo": str("Título de la tarea"),
"descripcion": str("Descripción"),
"estado": agentToolParam{Type: "string", Enum: []string{"pendiente", "en_progreso"}, Description: "Estado inicial"},
"prioridad": agentToolParam{Type: "string", Enum: []string{"baja", "media", "alta"}, Description: "Prioridad"},
"asignado_id": num("ID del usuario responsable (usa listar_usuarios si no lo sabes)"),
"fecha_limite": str("Fecha límite, formato YYYY-MM-DD"),
}, []string{"titulo"})),
tool("asignar_tarea", "Asigna (o reasigna) una tarea existente a un usuario. Notifica al usuario asignado.",
obj(map[string]agentToolParam{
"id": num("ID de la tarea"),
"asignado_id": num("ID del usuario responsable (usa listar_usuarios si no lo sabes)"),
}, []string{"id", "asignado_id"})),
tool("actualizar_estado_tarea", "Cambia el estado de una tarea.",
obj(map[string]agentToolParam{
"id": num("ID de la tarea"),
@@ -470,7 +495,7 @@ func runTool(chatID int64, name string, a map[string]interface{}) (interface{},
return map[string]interface{}{"items": items, "total": total, "page": page}, nil
case "adjuntar_factura":
att, ok := PopFacturaAttachment(chatID)
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")
}
@@ -805,6 +830,26 @@ func runTool(chatID int64, name string, a map[string]interface{}) (interface{},
}
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")
@@ -849,24 +894,52 @@ func runTool(chatID int64, name string, a map[string]interface{}) (interface{},
}
return filtered, nil
case "listar_usuarios":
users, _, err := models.AllUsersSistema(50, 0, getStr("search"))
if err != nil {
return nil, err
}
type usuarioItem struct {
ID uint `json:"id"`
Name string `json:"name"`
}
out := make([]usuarioItem, len(users))
for i, u := range users {
out[i] = usuarioItem{ID: u.ID, Name: u.Name}
}
return out, nil
case "crear_tarea":
t := &models.Tarea{
Titulo: getStr("titulo"),
Descripcion: getStr("descripcion"),
Estado: getStr("estado"),
Prioridad: getStr("prioridad"),
var asignadoID *uint
if v := getInt("asignado_id", 0); v > 0 {
u := uint(v)
asignadoID = &u
}
if t.Estado == "" {
t.Estado = "pendiente"
var fechaLimite *time.Time
if s := getStr("fecha_limite"); s != "" {
if t, errParse := time.Parse("2006-01-02", s); errParse == nil {
fechaLimite = &t
}
}
if t.Prioridad == "" {
t.Prioridad = "media"
}
if err := models.CreateTarea(t); err != nil {
t, err := CrearTareaAsignada(getStr("titulo"), getStr("descripcion"), getStr("estado"), getStr("prioridad"), asignadoID, fechaLimite)
if err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "id": t.ID, "titulo": t.Titulo}, nil
case "asignar_tarea":
id := uint(getInt("id", 0))
asignadoIDVal := getInt("asignado_id", 0)
if id == 0 || asignadoIDVal == 0 {
return nil, fmt.Errorf("id y asignado_id requeridos")
}
asignadoID := uint(asignadoIDVal)
t, err := AsignarTarea(id, &asignadoID)
if err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "id": t.ID, "asignado_id": asignadoID}, nil
case "actualizar_estado_tarea":
id := uint(getInt("id", 0))
estado := getStr("estado")
@@ -1269,7 +1342,12 @@ COMPORTAMIENTO:
- 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
- Cuando el mensaje empiece con "[Documento adjunto recibido: ...]", el usuario acaba de enviar un PDF o foto por Telegram y puedes leerlo directamente (está adjunto a este mismo mensaje, no es solo un nombre de archivo). Léelo para identificar cliente, monto y número de factura. Usa listar_clientes para encontrar el cliente_id que mejor coincida con el nombre/empresa que aparece en el documento o en el texto del usuario. Si logras identificar cliente y monto (del documento o de lo que escribió el usuario), llama a adjuntar_factura directamente sin pedir confirmación — solo pregunta si de verdad no hay forma de determinar el cliente o el monto. No uses esta tool si el mensaje no menciona ningún documento adjunto
- Cuando el mensaje empiece con "[Documento adjunto recibido: ...]", el usuario acaba de enviar un PDF o foto por Telegram y puedes leerlo directamente (está adjunto a este mismo mensaje, no es solo un nombre de archivo). Decide primero de qué se trata el archivo antes de guardarlo:
· Si es una factura/cuenta de cobro → identifica cliente, monto y número, resuelve el cliente_id con listar_clientes, y llama a adjuntar_factura
· Si es un documento de un proyecto (contrato, orden de servicio, entregable, etc.) → identifica a qué proyecto corresponde (usa listar_proyectos si hace falta) y llama a adjuntar_documento_proyecto
Si logras identificar los datos necesarios (del documento o de lo que escribió el usuario), guarda directamente sin pedir confirmación — solo pregunta si de verdad no hay forma de determinar el cliente/proyecto o falta un dato imprescindible. No uses estas tools si el mensaje no menciona ningún documento adjunto
- Para registrar un proyecto nuevo con sus fases (el usuario puede describirlo en texto o mandarlo en un documento), primero usa crear_proyecto y luego llama a crear_fase_proyecto una vez por cada fase que corresponda — no hace falta preguntar confirmación entre cada fase
- Para asignar una tarea a alguien, resuelve el nombre con listar_usuarios y usa asignado_id en crear_tarea (si es nueva) o asignar_tarea (si ya existe) — la persona asignada recibe una notificación
COMANDOS ESPECIALES (el usuario puede escribirlos):
- /reset — olvidar el historial de esta conversación
@@ -1342,7 +1420,7 @@ Comandos: /reset /instancias /ayuda`, nil
// como archivo real (solo Anthropic lo lee de verdad) en vez de que la IA
// tenga que adivinar cliente/monto a partir del nombre del archivo.
if strings.ToLower(ai.Provider) == "anthropic" {
if att, ok := PeekFacturaAttachment(chatID); ok {
if att, ok := PeekTelegramAttachment(chatID); ok {
if data, err := os.ReadFile(att.Path); err == nil {
userMsg.AttachmentBase64 = base64.StdEncoding.EncodeToString(data)
userMsg.AttachmentMime = ResolverMimeType(att.MimeType, att.OriginalName)