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
+27 -25
View File
@@ -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
+80
View File
@@ -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
}
+73
View File
@@ -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
}
+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)
@@ -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 != "":