Files
soft_usite/pkg/services/umind_canal_telegram_service.go
T
Lizandro GuarnizoandClaude Sonnet 5 08265510ea feat(umind): mide el consumo de IA, OCR y transcripción por tenant
Es la base del cobro por uso: hasta ahora no había ninguna medición de
consumo en todo el repo.

- UmindUso registra cada evento facturable con el costo YA calculado al
  precio vigente del plan. Congelarlo evita que subir un precio revalúe
  consumo pasado, que haría indefendible una factura ante un reclamo.
- callAI devuelve los tokens que reportó el proveedor (campo usage, igual
  en todos los OpenAI-compatibles; input+output en Anthropic). Se mide
  cada ronda de tool-calling, no solo la última: todas gastan tokens.
- ExtraerTextoOCR y TranscribirAudioSelfHosted reciben agenteID; 0 = no
  medir, que es lo que pasan los botones "Probar" del panel de staff.
- Aviso al superar el tope del plan, una vez por mes y sin cortar el
  servicio. El flag de "ya avisé" es en memoria a propósito.
- GET /app/umind/uso con filtros de fecha: resumen por tipo + detalle.
- Test del cálculo de costo por tipo, incluida fracción de 1k tokens y
  tenant sin plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:41:23 -05:00

146 lines
4.9 KiB
Go

package services
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// ProcesarMensajeTelegramUmind adapta un mensaje entrante del canal Telegram
// de un agente al mismo motor que atiende el widget web
// (ProcessWidgetMessage) y responde usando el bot token propio del canal
// (no el bot interno de staff). La sesión se separa por chat_id con un
// prefijo para no colisionar con session_ids del widget.
func ProcesarMensajeTelegramUmind(canal *models.UmindCanal, chatID int64, texto string) error {
agente, err := models.GetUmindAgenteByID(canal.AgenteID)
if err != nil || !agente.Activo {
return fmt.Errorf("agente no encontrado o inactivo: %w", err)
}
credenciales, err := DescifrarCredencialesCanal(canal.CredencialesEnc)
if err != nil {
return fmt.Errorf("credenciales del canal corruptas: %w", err)
}
botToken := credenciales["bot_token"]
if botToken == "" {
return fmt.Errorf("el canal no tiene bot_token configurado")
}
sessionID := fmt.Sprintf("tg:%d", chatID)
respuesta, err := ProcessWidgetMessage(agente, sessionID, texto)
if err != nil {
return fmt.Errorf("error del agente: %w", err)
}
return (&TelegramService{}).SendMessageWithToken(chatID, respuesta, botToken)
}
// ProcesarMediaTelegramUmind atiende notas de voz/audio y fotos entrantes:
// si el canal tiene la conversión habilitada, descarga el archivo vía
// getFile, lo pasa por Whisper/OCR y responde igual que un mensaje de texto.
// Si no está habilitada, se ignora en silencio.
func ProcesarMediaTelegramUmind(canal *models.UmindCanal, chatID int64, fileID, tipo string) error {
agente, err := models.GetUmindAgenteByID(canal.AgenteID)
if err != nil || !agente.Activo {
return fmt.Errorf("agente no encontrado o inactivo: %w", err)
}
credenciales, err := DescifrarCredencialesCanal(canal.CredencialesEnc)
if err != nil {
return fmt.Errorf("credenciales del canal corruptas: %w", err)
}
botToken := credenciales["bot_token"]
if botToken == "" {
return fmt.Errorf("el canal no tiene bot_token configurado")
}
var texto string
switch tipo {
case "audio":
if !canal.UsarWhisperAudio {
return nil
}
data, err := descargarArchivoTelegram(botToken, fileID)
if err != nil {
return fmt.Errorf("no se pudo descargar el audio de Telegram: %w", err)
}
texto, err = TranscribirAudioSelfHosted(canal.AgenteID, data, "audio.ogg")
if err != nil {
return err
}
case "image":
if !canal.UsarOcrImagenes {
return nil
}
data, err := descargarArchivoTelegram(botToken, fileID)
if err != nil {
return fmt.Errorf("no se pudo descargar la imagen de Telegram: %w", err)
}
texto, err = ExtraerTextoOCR(canal.AgenteID, data, "image/jpeg")
if err != nil {
return err
}
default:
return nil
}
if strings.TrimSpace(texto) == "" {
return fmt.Errorf("no se pudo extraer texto del %s recibido", tipo)
}
sessionID := fmt.Sprintf("tg:%d", chatID)
respuesta, err := ProcessWidgetMessage(agente, sessionID, texto)
if err != nil {
return fmt.Errorf("error del agente: %w", err)
}
return (&TelegramService{}).SendMessageWithToken(chatID, respuesta, botToken)
}
// descargarArchivoTelegram resuelve el file_path de un file_id (getFile) y
// descarga el archivo desde el CDN de archivos de Telegram.
func descargarArchivoTelegram(botToken, fileID string) ([]byte, error) {
getFileURL := fmt.Sprintf("https://api.telegram.org/bot%s/getFile?file_id=%s", botToken, url.QueryEscape(fileID))
resp, err := telegramHTTPClient.Get(getFileURL)
if err != nil {
return nil, fmt.Errorf("no se pudo consultar getFile: %w", err)
}
defer resp.Body.Close()
var out struct {
Result struct {
FilePath string `json:"file_path"`
} `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Result.FilePath == "" {
return nil, fmt.Errorf("respuesta inesperada de getFile")
}
fileURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", botToken, out.Result.FilePath)
resp2, err := telegramHTTPClient.Get(fileURL)
if err != nil {
return nil, fmt.Errorf("no se pudo descargar el archivo: %w", err)
}
defer resp2.Body.Close()
return io.ReadAll(io.LimitReader(resp2.Body, 20*1024*1024))
}
// RegistrarWebhookTelegram le dice a Telegram a qué URL mandar los updates
// del bot — se llama una vez al crear el canal (o al reconfigurar el token).
func RegistrarWebhookTelegram(botToken, webhookURL string) error {
if botToken == "" || webhookURL == "" {
return fmt.Errorf("bot_token y webhookURL son requeridos")
}
api := fmt.Sprintf("https://api.telegram.org/bot%s/setWebhook?url=%s", botToken, url.QueryEscape(webhookURL))
resp, err := telegramHTTPClient.Get(api)
if err != nil {
return fmt.Errorf("no se pudo contactar la API de Telegram: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Telegram respondió %d al registrar el webhook", resp.StatusCode)
}
return nil
}