feat: enviar factura por Telegram como documento adjunto
El bot solo procesaba mensajes de texto: un documento o foto enviado al chat se ignoraba por completo (Telegram lo manda en message.document/photo con caption, no en el campo text). Ahora el webhook detecta el adjunto, lo descarga desde la API de Telegram y lo deja pendiente para el chat; el agente usa la nueva tool adjuntar_factura para asociarlo a un cliente (preguntando cliente/monto si el caption no los trae) y crear la Factura con el archivo ya vinculado, igual que si se subiera desde el dashboard. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
8fce587304
commit
b292d98cf6
@@ -0,0 +1,102 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"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 {
|
||||||
|
Path string
|
||||||
|
OriginalName string
|
||||||
|
MimeType string
|
||||||
|
StagedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const facturaAttachmentTTL = 15 * time.Minute
|
||||||
|
|
||||||
|
var (
|
||||||
|
facturaAttachmentsMu sync.Mutex
|
||||||
|
facturaAttachments = map[int64]*FacturaAttachment{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{
|
||||||
|
Path: path, OriginalName: originalName, MimeType: mimeType, StagedAt: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PopFacturaAttachment 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]
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
delete(facturaAttachments, chatID)
|
||||||
|
if time.Since(a.StagedAt) > facturaAttachmentTTL {
|
||||||
|
_ = os.Remove(a.Path)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return a, true
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -137,6 +139,13 @@ func agentTools() []agentTool {
|
|||||||
"search": str("Búsqueda por número, cliente o descripción"),
|
"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", ""}},
|
"estado": agentToolParam{Type: "string", Description: "pendiente | pagada | vencida | cancelada", Enum: []string{"pendiente", "pagada", "vencida", "cancelada", ""}},
|
||||||
}, nil)),
|
}, nil)),
|
||||||
|
tool("adjuntar_factura", "Guarda como factura el documento (PDF/foto) que el usuario acaba de enviar por Telegram en este chat. Solo funciona si hay un archivo adjunto pendiente; si no lo hay, pide al usuario que lo reenvíe.",
|
||||||
|
obj(map[string]agentToolParam{
|
||||||
|
"cliente_id": num("ID del cliente al que pertenece la factura (usa listar_clientes si no lo sabes)"),
|
||||||
|
"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"})),
|
||||||
|
|
||||||
// ── Clientes ─────────────────────────────────────────────────────────
|
// ── Clientes ─────────────────────────────────────────────────────────
|
||||||
tool("listar_clientes", "Lista clientes con paginación y búsqueda.",
|
tool("listar_clientes", "Lista clientes con paginación y búsqueda.",
|
||||||
@@ -312,8 +321,8 @@ func agentTools() []agentTool {
|
|||||||
|
|
||||||
// ─── Ejecución de herramientas ────────────────────────────────────────────────
|
// ─── Ejecución de herramientas ────────────────────────────────────────────────
|
||||||
|
|
||||||
func executeAgentTool(name string, args map[string]interface{}) string {
|
func executeAgentTool(chatID int64, name string, args map[string]interface{}) string {
|
||||||
result, err := runTool(name, args)
|
result, err := runTool(chatID, name, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Sprintf(`{"error": %q}`, err.Error())
|
return fmt.Sprintf(`{"error": %q}`, err.Error())
|
||||||
}
|
}
|
||||||
@@ -321,7 +330,7 @@ func executeAgentTool(name string, args map[string]interface{}) string {
|
|||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runTool(name string, a map[string]interface{}) (interface{}, error) {
|
func runTool(chatID int64, name string, a map[string]interface{}) (interface{}, error) {
|
||||||
getInt := func(key string, def int) int {
|
getInt := func(key string, def int) int {
|
||||||
if v, ok := a[key]; ok {
|
if v, ok := a[key]; ok {
|
||||||
switch x := v.(type) {
|
switch x := v.(type) {
|
||||||
@@ -456,6 +465,42 @@ func runTool(name string, a map[string]interface{}) (interface{}, error) {
|
|||||||
}
|
}
|
||||||
return map[string]interface{}{"items": items, "total": total, "page": page}, nil
|
return map[string]interface{}{"items": items, "total": total, "page": page}, nil
|
||||||
|
|
||||||
|
case "adjuntar_factura":
|
||||||
|
att, ok := PopFacturaAttachment(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 := uint(getInt("cliente_id", 0))
|
||||||
|
if clienteID == 0 {
|
||||||
|
return nil, fmt.Errorf("cliente_id requerido")
|
||||||
|
}
|
||||||
|
f := &models.Factura{
|
||||||
|
ClienteID: clienteID,
|
||||||
|
Numero: getStr("numero"),
|
||||||
|
Descripcion: getStr("descripcion"),
|
||||||
|
Monto: float64(getInt("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 "listar_clientes":
|
case "listar_clientes":
|
||||||
page := getInt("page", 1)
|
page := getInt("page", 1)
|
||||||
search := getStr("search")
|
search := getStr("search")
|
||||||
@@ -1192,6 +1237,7 @@ COMPORTAMIENTO:
|
|||||||
- Los valores monetarios son en COP (pesos colombianos)
|
- Los valores monetarios son en COP (pesos colombianos)
|
||||||
- Si una herramienta falla, explica el error y sugiere alternativas
|
- 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
|
- 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. Identifica el cliente (usa listar_clientes si hace falta) y el monto de la factura a partir de lo que escribió; si falta alguno de los dos, pregúntalo antes de guardar. Cuando tengas cliente y monto, usa la tool adjuntar_factura para guardarlo — no la uses si el mensaje no menciona ningún documento adjunto
|
||||||
|
|
||||||
COMANDOS ESPECIALES (el usuario puede escribirlos):
|
COMANDOS ESPECIALES (el usuario puede escribirlos):
|
||||||
- /reset — olvidar el historial de esta conversación
|
- /reset — olvidar el historial de esta conversación
|
||||||
@@ -1296,7 +1342,7 @@ Comandos: /reset /instancias /ayuda`, nil
|
|||||||
_ = json.Unmarshal([]byte(tc.Function.Arguments), &toolArgs)
|
_ = json.Unmarshal([]byte(tc.Function.Arguments), &toolArgs)
|
||||||
|
|
||||||
log.Printf("[AGENT] Ejecutando tool: %s args: %s", tc.Function.Name, tc.Function.Arguments)
|
log.Printf("[AGENT] Ejecutando tool: %s args: %s", tc.Function.Name, tc.Function.Arguments)
|
||||||
toolResult := executeAgentTool(tc.Function.Name, toolArgs)
|
toolResult := executeAgentTool(chatID, tc.Function.Name, toolArgs)
|
||||||
|
|
||||||
// Agregar resultado al contexto (solo en memoria, no en BD)
|
// Agregar resultado al contexto (solo en memoria, no en BD)
|
||||||
messages = append(messages, agentMessage{
|
messages = append(messages, agentMessage{
|
||||||
|
|||||||
@@ -25,11 +25,26 @@ type tgChat struct {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type tgDocument struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tgPhotoSize struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
}
|
||||||
|
|
||||||
type tgMessage struct {
|
type tgMessage struct {
|
||||||
MessageID int `json:"message_id"`
|
MessageID int `json:"message_id"`
|
||||||
From tgUser `json:"from"`
|
From tgUser `json:"from"`
|
||||||
Chat tgChat `json:"chat"`
|
Chat tgChat `json:"chat"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
Document *tgDocument `json:"document"`
|
||||||
|
Photo []tgPhotoSize `json:"photo"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type tgUpdate struct {
|
type tgUpdate struct {
|
||||||
@@ -53,13 +68,18 @@ func TelegramAgentWebhook(c *fiber.Ctx) error {
|
|||||||
if err := c.BodyParser(&update); err != nil {
|
if err := c.BodyParser(&update); err != nil {
|
||||||
return c.SendStatus(200) // siempre 200 a Telegram
|
return c.SendStatus(200) // siempre 200 a Telegram
|
||||||
}
|
}
|
||||||
if update.Message == nil || strings.TrimSpace(update.Message.Text) == "" {
|
if update.Message == nil {
|
||||||
return c.SendStatus(200)
|
return c.SendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := update.Message
|
msg := update.Message
|
||||||
chatID := msg.Chat.ID
|
chatID := msg.Chat.ID
|
||||||
text := strings.TrimSpace(msg.Text)
|
text := strings.TrimSpace(msg.Text)
|
||||||
|
caption := strings.TrimSpace(msg.Caption)
|
||||||
|
hasAttachment := msg.Document != nil || len(msg.Photo) > 0
|
||||||
|
if text == "" && caption == "" && !hasAttachment {
|
||||||
|
return c.SendStatus(200)
|
||||||
|
}
|
||||||
|
|
||||||
// Buscar el config del agente que tenga este bot token
|
// Buscar el config del agente que tenga este bot token
|
||||||
ai, tgCfg, err := models.GetAgenteBotConfig()
|
ai, tgCfg, err := models.GetAgenteBotConfig()
|
||||||
@@ -86,6 +106,45 @@ func TelegramAgentWebhook(c *fiber.Ctx) error {
|
|||||||
return c.SendStatus(200)
|
return c.SendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Documento o foto adjunto: se descarga y queda "pendiente" para que el
|
||||||
|
// agente lo asocie a una factura (tool adjuntar_factura) según lo que diga
|
||||||
|
// el caption o lo que responda el usuario a continuación.
|
||||||
|
if hasAttachment {
|
||||||
|
fileID, fileName, mimeType := "", "documento", ""
|
||||||
|
switch {
|
||||||
|
case msg.Document != nil:
|
||||||
|
fileID = msg.Document.FileID
|
||||||
|
if msg.Document.FileName != "" {
|
||||||
|
fileName = msg.Document.FileName
|
||||||
|
}
|
||||||
|
mimeType = msg.Document.MimeType
|
||||||
|
case len(msg.Photo) > 0:
|
||||||
|
// Telegram manda varias resoluciones de la misma foto; la última es la de mayor calidad.
|
||||||
|
best := msg.Photo[len(msg.Photo)-1]
|
||||||
|
fileID = best.FileID
|
||||||
|
fileName = "foto.jpg"
|
||||||
|
mimeType = "image/jpeg"
|
||||||
|
}
|
||||||
|
if fileID != "" {
|
||||||
|
path, dlErr := services.DescargarDocumentoTelegram(tgCfg.BotToken, fileID, fileName, mimeType)
|
||||||
|
if dlErr != nil {
|
||||||
|
log.Printf("[AGENT_WEBHOOK] Error descargando adjunto: %v", dlErr)
|
||||||
|
sendAgentReply(tgCfg.BotToken, chatID, "No pude descargar el archivo que enviaste, intenta de nuevo.")
|
||||||
|
return c.SendStatus(200)
|
||||||
|
}
|
||||||
|
services.StageFacturaAttachment(chatID, path, fileName, mimeType)
|
||||||
|
nota := fmt.Sprintf("[Documento adjunto recibido: %s]", fileName)
|
||||||
|
switch {
|
||||||
|
case caption != "":
|
||||||
|
text = nota + " " + caption
|
||||||
|
case text == "":
|
||||||
|
text = nota + " ¿A qué cliente corresponde y cuál es el monto de la factura?"
|
||||||
|
default:
|
||||||
|
text = nota + " " + text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Procesar en goroutine para responder 200 inmediatamente a Telegram
|
// Procesar en goroutine para responder 200 inmediatamente a Telegram
|
||||||
go func() {
|
go func() {
|
||||||
response, err := services.ProcessAgentMessage(chatID, text, ai)
|
response, err := services.ProcessAgentMessage(chatID, text, ai)
|
||||||
|
|||||||
Reference in New Issue
Block a user