From a0b2e9ae0ade761628fe63efd3d1be697c635515 Mon Sep 17 00:00:00 2001 From: Lizandro GD Date: Mon, 3 Aug 2026 02:11:02 +0000 Subject: [PATCH] feat: registrar proyectos con fases, adjuntar documentos y asignar tareas por Telegram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- pkg/services/factura_attachment_service.go | 52 ++++---- pkg/services/proyecto_service.go | 80 ++++++++++++ pkg/services/tarea_service.go | 73 +++++++++++ pkg/services/telegram_agent_service.go | 116 +++++++++++++++--- rest/controllers/telegram_agent_controller.go | 2 +- 5 files changed, 278 insertions(+), 45 deletions(-) create mode 100644 pkg/services/tarea_service.go diff --git a/pkg/services/factura_attachment_service.go b/pkg/services/factura_attachment_service.go index a47290f..114a0e1 100644 --- a/pkg/services/factura_attachment_service.go +++ b/pkg/services/factura_attachment_service.go @@ -12,20 +12,21 @@ import ( "time" ) -// FacturaAttachment es un archivo que un usuario envió por Telegram y que queda -// "pendiente" hasta que el agente lo asocie a una factura (o expire). -type FacturaAttachment struct { +// TelegramAttachment es un archivo que un usuario envió por Telegram y que queda +// "pendiente" hasta que el agente lo asocie a una factura o a un documento de +// proyecto (o expire). +type TelegramAttachment struct { Path string OriginalName string MimeType string StagedAt time.Time } -const facturaAttachmentTTL = 15 * time.Minute +const telegramAttachmentTTL = 15 * time.Minute var ( - facturaAttachmentsMu sync.Mutex - facturaAttachments = map[int64]*FacturaAttachment{} + telegramAttachmentsMu sync.Mutex + telegramAttachments = map[int64]*TelegramAttachment{} ) // DescargarDocumentoTelegram descarga un archivo de Telegram (por file_id) usando @@ -75,41 +76,42 @@ func DescargarDocumentoTelegram(botToken, fileID, originalName, mimeType string) return localPath, nil } -// StageFacturaAttachment registra un archivo ya descargado como pendiente para el -// chat, a la espera de que el agente lo asocie a una factura con la tool adjuntar_factura. -func StageFacturaAttachment(chatID int64, path, originalName, mimeType string) { - facturaAttachmentsMu.Lock() - defer facturaAttachmentsMu.Unlock() - facturaAttachments[chatID] = &FacturaAttachment{ +// StageTelegramAttachment registra un archivo ya descargado como pendiente para el +// chat, a la espera de que el agente lo asocie a algo (adjuntar_factura o +// adjuntar_documento_proyecto). +func StageTelegramAttachment(chatID int64, path, originalName, mimeType string) { + telegramAttachmentsMu.Lock() + defer telegramAttachmentsMu.Unlock() + telegramAttachments[chatID] = &TelegramAttachment{ Path: path, OriginalName: originalName, MimeType: mimeType, StagedAt: time.Now(), } } -// PopFacturaAttachment retorna y remueve el archivo pendiente de un chat, si +// PopTelegramAttachment retorna y remueve el archivo pendiente de un chat, si // existe y no expiró (si expiró, borra el archivo temporal del disco). -func PopFacturaAttachment(chatID int64) (*FacturaAttachment, bool) { - facturaAttachmentsMu.Lock() - defer facturaAttachmentsMu.Unlock() - a, ok := facturaAttachments[chatID] +func PopTelegramAttachment(chatID int64) (*TelegramAttachment, bool) { + telegramAttachmentsMu.Lock() + defer telegramAttachmentsMu.Unlock() + a, ok := telegramAttachments[chatID] if !ok { return nil, false } - delete(facturaAttachments, chatID) - if time.Since(a.StagedAt) > facturaAttachmentTTL { + delete(telegramAttachments, chatID) + if time.Since(a.StagedAt) > telegramAttachmentTTL { _ = os.Remove(a.Path) return nil, false } return a, true } -// PeekFacturaAttachment consulta el archivo pendiente de un chat sin removerlo +// PeekTelegramAttachment consulta el archivo pendiente de un chat sin removerlo // (se usa para leer su contenido y pasárselo al modelo antes de que la tool // adjuntar_factura lo consuma de verdad). -func PeekFacturaAttachment(chatID int64) (*FacturaAttachment, bool) { - facturaAttachmentsMu.Lock() - defer facturaAttachmentsMu.Unlock() - a, ok := facturaAttachments[chatID] - if !ok || time.Since(a.StagedAt) > facturaAttachmentTTL { +func PeekTelegramAttachment(chatID int64) (*TelegramAttachment, bool) { + telegramAttachmentsMu.Lock() + defer telegramAttachmentsMu.Unlock() + a, ok := telegramAttachments[chatID] + if !ok || time.Since(a.StagedAt) > telegramAttachmentTTL { return nil, false } return a, true diff --git a/pkg/services/proyecto_service.go b/pkg/services/proyecto_service.go index 76edfd2..bf6ea5e 100644 --- a/pkg/services/proyecto_service.go +++ b/pkg/services/proyecto_service.go @@ -2,7 +2,10 @@ package services import ( "fmt" + "os" + "path/filepath" "strings" + "time" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" ) @@ -50,3 +53,80 @@ func CrearProyectoSimple(clienteID uint, nombre, stack, descripcion string) (*mo } return p, nil } + +// CrearFaseProyecto agrega una fase/etapa a un proyecto ya existente, para el +// flujo de automatización con IA ("registra el proyecto y coloca las fases"). +func CrearFaseProyecto(proyectoID uint, nombre, descripcion, estado string, orden int, fechaEstimada *time.Time) (*models.ProyectoFase, error) { + if proyectoID == 0 { + return nil, fmt.Errorf("proyecto_id requerido") + } + if strings.TrimSpace(nombre) == "" { + return nil, fmt.Errorf("nombre requerido") + } + if estado == "" { + estado = "pendiente" + } + f := &models.ProyectoFase{ + ProyectoID: proyectoID, + Nombre: nombre, + Descripcion: descripcion, + Estado: estado, + Orden: orden, + FechaEstimada: fechaEstimada, + } + if err := models.CreateProyectoFase(f); err != nil { + return nil, fmt.Errorf("no se pudo crear la fase: %w", err) + } + _ = models.ActualizarProgresoProyecto(proyectoID) + return f, nil +} + +// AdjuntarDocumentoProyecto toma el archivo pendiente de Telegram para este chat +// (descargado por el webhook) y lo guarda como documento del proyecto, igual que +// si se subiera desde el dashboard. +func AdjuntarDocumentoProyecto(chatID int64, proyectoID uint, tipo, nombre, descripcion string) (*models.ProyectoDocumento, error) { + if proyectoID == 0 { + return nil, fmt.Errorf("proyecto_id requerido") + } + 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") + } + if tipo == "" { + tipo = "otro" + } + if nombre == "" { + nombre = att.OriginalName + } + + dir := fmt.Sprintf("uploads/proyectos/%d/docs", proyectoID) + if err := os.MkdirAll(dir, 0755); err != nil { + _ = os.Remove(att.Path) + return nil, fmt.Errorf("no se pudo crear el directorio: %w", err) + } + ext := filepath.Ext(att.OriginalName) + finalPath := filepath.Join(dir, fmt.Sprintf("telegram_%d%s", time.Now().UnixNano(), ext)) + if err := os.Rename(att.Path, finalPath); err != nil { + return nil, fmt.Errorf("no se pudo guardar el archivo: %w", err) + } + + var tamanio int64 + if info, err := os.Stat(finalPath); err == nil { + tamanio = info.Size() + } + + d := &models.ProyectoDocumento{ + ProyectoID: proyectoID, + Tipo: tipo, + Nombre: nombre, + Descripcion: descripcion, + Archivo: finalPath, + OriginalName: att.OriginalName, + TipoMime: att.MimeType, + Tamanio: tamanio, + } + if err := models.CreateProyectoDocumento(d); err != nil { + return nil, err + } + return d, nil +} diff --git a/pkg/services/tarea_service.go b/pkg/services/tarea_service.go new file mode 100644 index 0000000..774f919 --- /dev/null +++ b/pkg/services/tarea_service.go @@ -0,0 +1,73 @@ +package services + +import ( + "fmt" + "time" + + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// CrearTareaAsignada crea una tarea, opcionalmente ya asignada a alguien, y +// dispara la notificación correspondiente (Telegram/email/sistema, según la +// config de notif_evento_configs) si quedó asignada a un usuario. +func CrearTareaAsignada(titulo, descripcion, estado, prioridad string, asignadoID *uint, fechaLimite *time.Time) (*models.Tarea, error) { + if titulo == "" { + return nil, fmt.Errorf("titulo requerido") + } + if estado == "" { + estado = "pendiente" + } + if prioridad == "" { + prioridad = "media" + } + t := &models.Tarea{ + Titulo: titulo, + Descripcion: descripcion, + Estado: estado, + Prioridad: prioridad, + AsignadoID: asignadoID, + FechaLimite: fechaLimite, + } + if err := models.CreateTarea(t); err != nil { + return nil, fmt.Errorf("no se pudo crear la tarea: %w", err) + } + if asignadoID != nil { + if full, err := models.GetTareaByID(t.ID); err == nil { + go NotificarTareaAsignada(full) + } + } + return t, nil +} + +// AsignarTarea cambia el responsable de una tarea existente y notifica al nuevo +// asignado si el responsable realmente cambió. asignadoID puede ser nil para +// dejar la tarea sin asignar. +func AsignarTarea(id uint, asignadoID *uint) (*models.Tarea, error) { + 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) + } + prevAsignado := tarea.AsignadoID + tarea.AsignadoID = asignadoID + if err := models.SaveTarea(tarea); err != nil { + return nil, fmt.Errorf("no se pudo asignar la tarea: %w", err) + } + full, _ := models.GetTareaByID(id) + if cambioAsignado(prevAsignado, asignadoID) && full != nil { + go NotificarTareaAsignada(full) + } + return full, nil +} + +func cambioAsignado(a, b *uint) bool { + if a == nil && b == nil { + return false + } + if a == nil || b == nil { + return true + } + return *a != *b +} diff --git a/pkg/services/telegram_agent_service.go b/pkg/services/telegram_agent_service.go index 141ddc3..ddf1b36 100644 --- a/pkg/services/telegram_agent_service.go +++ b/pkg/services/telegram_agent_service.go @@ -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) diff --git a/rest/controllers/telegram_agent_controller.go b/rest/controllers/telegram_agent_controller.go index 073a182..566fc0b 100644 --- a/rest/controllers/telegram_agent_controller.go +++ b/rest/controllers/telegram_agent_controller.go @@ -132,7 +132,7 @@ func TelegramAgentWebhook(c *fiber.Ctx) error { sendAgentReply(tgCfg.BotToken, chatID, "No pude descargar el archivo que enviaste, intenta de nuevo.") return c.SendStatus(200) } - services.StageFacturaAttachment(chatID, path, fileName, mimeType) + services.StageTelegramAttachment(chatID, path, fileName, mimeType) nota := fmt.Sprintf("[Documento adjunto recibido: %s]", fileName) switch { case caption != "":