Files
soft_usite/pkg/services/factura_attachment_service.go
T
Lizandro GDandClaude Sonnet 5 a0b2e9ae0a 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>
2026-08-03 02:11:02 +00:00

141 lines
4.3 KiB
Go

package services
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// 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 telegramAttachmentTTL = 15 * time.Minute
var (
telegramAttachmentsMu sync.Mutex
telegramAttachments = map[int64]*TelegramAttachment{}
)
// DescargarDocumentoTelegram descarga un archivo de Telegram (por file_id) usando
// la API getFile + el endpoint de descarga, y lo deja en un directorio temporal.
func DescargarDocumentoTelegram(botToken, fileID, originalName, mimeType string) (string, error) {
getFileURL := fmt.Sprintf("https://api.telegram.org/bot%s/getFile?file_id=%s", botToken, fileID)
resp, err := http.Get(getFileURL) //nolint:noctx
if err != nil {
return "", fmt.Errorf("no se pudo consultar el archivo en Telegram: %w", err)
}
defer resp.Body.Close()
var result struct {
OK bool `json:"ok"`
Result struct {
FilePath string `json:"file_path"`
} `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || !result.OK || result.Result.FilePath == "" {
return "", fmt.Errorf("Telegram no devolvió la ruta del archivo")
}
downloadURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", botToken, result.Result.FilePath)
fresp, err := http.Get(downloadURL) //nolint:noctx
if err != nil {
return "", fmt.Errorf("no se pudo descargar el archivo: %w", err)
}
defer fresp.Body.Close()
dir := "uploads/facturas/tmp"
if err := os.MkdirAll(dir, 0755); err != nil {
return "", fmt.Errorf("no se pudo crear el directorio temporal: %w", err)
}
ext := filepath.Ext(originalName)
if ext == "" {
ext = filepath.Ext(result.Result.FilePath)
}
localPath := filepath.Join(dir, fmt.Sprintf("%d%s", time.Now().UnixNano(), ext))
out, err := os.Create(localPath)
if err != nil {
return "", fmt.Errorf("no se pudo guardar el archivo: %w", err)
}
defer out.Close()
if _, err := io.Copy(out, fresp.Body); err != nil {
return "", fmt.Errorf("error escribiendo el archivo: %w", err)
}
return localPath, nil
}
// 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(),
}
}
// PopTelegramAttachment retorna y remueve el archivo pendiente de un chat, si
// existe y no expiró (si expiró, borra el archivo temporal del disco).
func PopTelegramAttachment(chatID int64) (*TelegramAttachment, bool) {
telegramAttachmentsMu.Lock()
defer telegramAttachmentsMu.Unlock()
a, ok := telegramAttachments[chatID]
if !ok {
return nil, false
}
delete(telegramAttachments, chatID)
if time.Since(a.StagedAt) > telegramAttachmentTTL {
_ = os.Remove(a.Path)
return nil, false
}
return a, true
}
// 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 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
}
// ResolverMimeType usa el mime_type que mandó Telegram si vino, y si no,
// adivina por la extensión del archivo (Claude solo acepta ciertos tipos).
func ResolverMimeType(mimeType, filename string) string {
if mimeType != "" {
return mimeType
}
switch strings.ToLower(filepath.Ext(filename)) {
case ".pdf":
return "application/pdf"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".webp":
return "image/webp"
case ".gif":
return "image/gif"
default:
return "application/octet-stream"
}
}