feat: Sistema CBCFacil completo con cola secuencial
- Implementa ProcessingMonitor singleton para procesamiento secuencial de archivos - Agrega AI summary service con soporte para MiniMax API - Agrega PDF generator para resúmenes - Agrega watchers para monitoreo de carpeta remota - Mejora sistema de notificaciones Telegram - Implementa gestión de VRAM para GPU - Configuración mediante variables de entorno (sin hardcoded secrets) - .env y transcriptions/ agregados a .gitignore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,91 +1,447 @@
|
||||
"""
|
||||
Telegram notification service
|
||||
Servicio de notificaciones Telegram.
|
||||
|
||||
Envía mensajes al chat configurado mediante la API de Telegram Bot.
|
||||
Silencioso si no está configurado (TELEGRAM_TOKEN y TELEGRAM_CHAT_ID).
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from config import settings
|
||||
|
||||
try:
|
||||
import requests
|
||||
REQUESTS_AVAILABLE = True
|
||||
except ImportError:
|
||||
REQUESTS_AVAILABLE = False
|
||||
import requests
|
||||
|
||||
from config.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _truncate_safely(text: str, max_length: int) -> str:
|
||||
"""
|
||||
Trunca texto sin romper entidades de formato HTML.
|
||||
|
||||
Args:
|
||||
text: Texto a truncar.
|
||||
max_length: Longitud máxima.
|
||||
|
||||
Returns:
|
||||
Texto truncado de forma segura.
|
||||
"""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
|
||||
# Dejar margen para el sufijo "..."
|
||||
safe_length = max_length - 10
|
||||
|
||||
# Buscar el último espacio o salto de línea antes del límite
|
||||
cut_point = text.rfind("\n", 0, safe_length)
|
||||
if cut_point == -1 or cut_point < safe_length - 100:
|
||||
cut_point = text.rfind(" ", 0, safe_length)
|
||||
if cut_point == -1 or cut_point < safe_length - 50:
|
||||
cut_point = safe_length
|
||||
|
||||
return text[:cut_point] + "..."
|
||||
|
||||
|
||||
class TelegramService:
|
||||
"""Service for sending Telegram notifications"""
|
||||
"""Servicio para enviar notificaciones a Telegram."""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self._token: Optional[str] = None
|
||||
self._chat_id: Optional[str] = None
|
||||
self._last_error_cache: dict = {}
|
||||
def __init__(self) -> None:
|
||||
"""Inicializa el servicio si hay configuración de Telegram."""
|
||||
self._token: Optional[str] = settings.TELEGRAM_TOKEN
|
||||
self._chat_id: Optional[str] = settings.TELEGRAM_CHAT_ID
|
||||
self._configured: bool = settings.has_telegram_config
|
||||
|
||||
def configure(self, token: str, chat_id: str) -> None:
|
||||
"""Configure Telegram credentials"""
|
||||
self._token = token
|
||||
self._chat_id = chat_id
|
||||
self.logger.info("Telegram service configured")
|
||||
# Rate limiting: mínimo tiempo entre mensajes (segundos)
|
||||
self._min_interval: float = 1.0
|
||||
self._last_send_time: float = 0.0
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if Telegram is configured"""
|
||||
return bool(self._token and self._chat_id)
|
||||
if self._configured:
|
||||
logger.info(
|
||||
"TelegramService inicializado",
|
||||
extra={"chat_id": self._mask_chat_id()},
|
||||
)
|
||||
else:
|
||||
logger.debug("TelegramService deshabilitado (sin configuración)")
|
||||
|
||||
def _send_request(self, endpoint: str, data: dict, retries: int = 3, delay: int = 2) -> bool:
|
||||
"""Make API request to Telegram"""
|
||||
if not REQUESTS_AVAILABLE:
|
||||
self.logger.warning("requests library not available")
|
||||
def _mask_chat_id(self) -> str:
|
||||
"""Oculta el chat_id para logging seguro."""
|
||||
if self._chat_id and len(self._chat_id) > 4:
|
||||
return f"***{self._chat_id[-4:]}"
|
||||
return "****"
|
||||
|
||||
def _wait_for_rate_limit(self) -> None:
|
||||
"""Espera si es necesario para cumplir el rate limiting."""
|
||||
now = time.monotonic()
|
||||
elapsed = now - self._last_send_time
|
||||
if elapsed < self._min_interval:
|
||||
sleep_time = self._min_interval - elapsed
|
||||
logger.debug(f"Rate limiting: esperando {sleep_time:.2f}s")
|
||||
time.sleep(sleep_time)
|
||||
self._last_send_time = time.monotonic()
|
||||
|
||||
def _send_request(self, method: str, data: dict) -> bool:
|
||||
"""Envía una request a la API de Telegram."""
|
||||
if not self._configured:
|
||||
return False
|
||||
|
||||
url = f"https://api.telegram.org/bot{self._token}/{endpoint}"
|
||||
url = f"https://api.telegram.org/bot{self._token}/{method}"
|
||||
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
self._wait_for_rate_limit()
|
||||
|
||||
response = requests.post(url, json=data, timeout=10)
|
||||
|
||||
# Intentar parsear JSON para obtener detalles del error
|
||||
try:
|
||||
resp = requests.post(url, data=data, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
return True
|
||||
else:
|
||||
self.logger.error(f"Telegram API error: {resp.status_code}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Telegram request failed (attempt {attempt+1}/{retries}): {e}")
|
||||
time.sleep(delay)
|
||||
return False
|
||||
result = response.json()
|
||||
except ValueError:
|
||||
result = {"raw": response.text}
|
||||
|
||||
def send_message(self, message: str) -> bool:
|
||||
"""Send a text message to Telegram"""
|
||||
if not self.is_configured:
|
||||
self.logger.warning("Telegram not configured, skipping notification")
|
||||
if response.status_code == 200 and result.get("ok"):
|
||||
logger.debug(
|
||||
"Mensaje enviado exitosamente",
|
||||
extra={"message_id": result.get("result", {}).get("message_id")},
|
||||
)
|
||||
return True
|
||||
|
||||
# Error detallado
|
||||
error_code = result.get("error_code", response.status_code)
|
||||
description = result.get("description", response.text)
|
||||
|
||||
logger.error(
|
||||
f"Error de Telegram API: HTTP {response.status_code}",
|
||||
extra={
|
||||
"method": method,
|
||||
"error_code": error_code,
|
||||
"description": description,
|
||||
"response_data": result,
|
||||
"request_data": {
|
||||
k: v if k != "text" else f"<{len(str(v))} chars>"
|
||||
for k, v in data.items()
|
||||
},
|
||||
},
|
||||
)
|
||||
return False
|
||||
data = {"chat_id": self._chat_id, "text": message}
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(
|
||||
f"Error de conexión con Telegram: {e}",
|
||||
extra={"method": method, "data_keys": list(data.keys())},
|
||||
)
|
||||
return False
|
||||
|
||||
def send_message(self, text: str, parse_mode: str = "HTML") -> bool:
|
||||
"""
|
||||
Envía un mensaje de texto al chat configurado.
|
||||
|
||||
Args:
|
||||
text: Contenido del mensaje.
|
||||
parse_mode: Modo de parseo (HTML, Markdown o MarkdownV2).
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente, False en caso contrario.
|
||||
"""
|
||||
if not self._configured:
|
||||
logger.debug(f"Mensaje ignorado (sin configuración): {text[:50]}...")
|
||||
return False
|
||||
|
||||
# Validar que el texto no esté vacío
|
||||
if not text or not text.strip():
|
||||
logger.warning("Intento de enviar mensaje vacío, ignorando")
|
||||
return False
|
||||
|
||||
# Eliminar espacios en blanco al inicio y final
|
||||
text = text.strip()
|
||||
|
||||
# Telegram limita a 4096 caracteres
|
||||
MAX_LENGTH = 4096
|
||||
text = _truncate_safely(text, MAX_LENGTH)
|
||||
|
||||
data = {
|
||||
"chat_id": self._chat_id,
|
||||
"text": text,
|
||||
}
|
||||
|
||||
# Solo incluir parse_mode si hay texto y no está vacío
|
||||
if parse_mode:
|
||||
data["parse_mode"] = parse_mode
|
||||
|
||||
logger.info("Enviando mensaje a Telegram", extra={"length": len(text)})
|
||||
return self._send_request("sendMessage", data)
|
||||
|
||||
def send_start_notification(self) -> bool:
|
||||
"""Send service start notification"""
|
||||
message = "CBCFacil Service Started - AI document processing active"
|
||||
return self.send_message(message)
|
||||
def send_start_notification(self, filename: str) -> bool:
|
||||
"""
|
||||
Envía notificación de inicio de procesamiento.
|
||||
|
||||
def send_error_notification(self, error_key: str, error_message: str) -> bool:
|
||||
"""Send error notification with throttling"""
|
||||
now = datetime.utcnow()
|
||||
prev = self._last_error_cache.get(error_key)
|
||||
if prev is None:
|
||||
self._last_error_cache[error_key] = (error_message, now)
|
||||
Args:
|
||||
filename: Nombre del archivo que se está procesando.
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente.
|
||||
"""
|
||||
if not filename:
|
||||
filename = "(desconocido)"
|
||||
|
||||
# Usar HTML para evitar problemas de escaping
|
||||
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
text = f"▶️ <b>Inicio de procesamiento</b>\n\n📄 Archivo: <code>{safe_filename}</code>"
|
||||
return self.send_message(text, parse_mode="HTML")
|
||||
|
||||
def send_error_notification(self, filename: str, error: str) -> bool:
|
||||
"""
|
||||
Envía notificación de error en procesamiento.
|
||||
|
||||
Args:
|
||||
filename: Nombre del archivo que falló.
|
||||
error: Descripción del error.
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente.
|
||||
"""
|
||||
if not filename:
|
||||
filename = "(desconocido)"
|
||||
if not error:
|
||||
error = "(error desconocido)"
|
||||
|
||||
# Usar HTML para evitar problemas de escaping
|
||||
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
safe_error = error.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
text = f"❌ <b>Error de procesamiento</b>\n\n📄 Archivo: <code>{safe_filename}</code>\n⚠️ Error: {safe_error}"
|
||||
return self.send_message(text, parse_mode="HTML")
|
||||
|
||||
def send_completion_notification(
|
||||
self,
|
||||
filename: str,
|
||||
duration: Optional[float] = None,
|
||||
output_path: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Envía notificación de completado exitoso.
|
||||
|
||||
Args:
|
||||
filename: Nombre del archivo procesado.
|
||||
duration: Duración del procesamiento en segundos (opcional).
|
||||
output_path: Ruta del archivo de salida (opcional).
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente.
|
||||
"""
|
||||
if not filename:
|
||||
filename = "(desconocido)"
|
||||
|
||||
# Usar HTML para evitar problemas de escaping
|
||||
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
duration_text = ""
|
||||
if duration is not None:
|
||||
minutes = int(duration // 60)
|
||||
seconds = int(duration % 60)
|
||||
duration_text = f"\n⏱️ Duración: {minutes}m {seconds}s"
|
||||
|
||||
output_text = ""
|
||||
if output_path:
|
||||
safe_output = output_path.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
output_text = f"\n📁 Salida: <code>{safe_output}</code>"
|
||||
|
||||
text = f"✅ <b>Procesamiento completado</b>\n\n📄 Archivo: <code>{safe_filename}</code>{duration_text}{output_text}"
|
||||
return self.send_message(text, parse_mode="HTML")
|
||||
|
||||
def send_download_complete(self, filename: str) -> bool:
|
||||
"""
|
||||
Envía notificación de descarga completada.
|
||||
|
||||
Args:
|
||||
filename: Nombre del archivo descargado.
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente.
|
||||
"""
|
||||
if not filename:
|
||||
filename = "(desconocido)"
|
||||
|
||||
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
text = f"📥 <b>Archivo descargado</b>\n\n📄 <code>{safe_filename}</code>"
|
||||
return self.send_message(text, parse_mode="HTML")
|
||||
|
||||
def send_transcription_start(self, filename: str) -> bool:
|
||||
"""
|
||||
Envía notificación de inicio de transcripción.
|
||||
|
||||
Args:
|
||||
filename: Nombre del archivo a transcribir.
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente.
|
||||
"""
|
||||
if not filename:
|
||||
filename = "(desconocido)"
|
||||
|
||||
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
text = f"🎙️ <b>Iniciando transcripción...</b>\n\n📄 <code>{safe_filename}</code>"
|
||||
return self.send_message(text, parse_mode="HTML")
|
||||
|
||||
def send_transcription_progress(
|
||||
self,
|
||||
filename: str,
|
||||
progress_percent: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Envía notificación de progreso de transcripción.
|
||||
|
||||
Args:
|
||||
filename: Nombre del archivo.
|
||||
progress_percent: Porcentaje de progreso (0-100).
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente.
|
||||
"""
|
||||
if not filename:
|
||||
filename = "(desconocido)"
|
||||
|
||||
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
text = f"⏳ <b>Transcribiendo...</b>\n\n📄 <code>{safe_filename}</code>\n📊 Progreso: {progress_percent}%"
|
||||
return self.send_message(text, parse_mode="HTML")
|
||||
|
||||
def send_transcription_complete(
|
||||
self,
|
||||
filename: str,
|
||||
text_length: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Envía notificación de transcripción completada.
|
||||
|
||||
Args:
|
||||
filename: Nombre del archivo.
|
||||
text_length: Longitud del texto transcrito.
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente.
|
||||
"""
|
||||
if not filename:
|
||||
filename = "(desconocido)"
|
||||
|
||||
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
# Formatear longitud del texto
|
||||
if text_length >= 1000:
|
||||
length_text = f"{text_length // 1000}k caracteres"
|
||||
else:
|
||||
prev_msg, prev_time = prev
|
||||
if error_message != prev_msg or (now - prev_time).total_seconds() > settings.ERROR_THROTTLE_SECONDS:
|
||||
self._last_error_cache[error_key] = (error_message, now)
|
||||
else:
|
||||
return False
|
||||
return self.send_message(f"Error: {error_message}")
|
||||
length_text = f"{text_length} caracteres"
|
||||
|
||||
text = f"✅ <b>Transcripción completada</b>\n\n📄 <code>{safe_filename}</code>\n📝 {length_text}"
|
||||
return self.send_message(text, parse_mode="HTML")
|
||||
|
||||
def send_summary_start(self, filename: str) -> bool:
|
||||
"""
|
||||
Envía notificación de inicio de resumen con IA.
|
||||
|
||||
Args:
|
||||
filename: Nombre del archivo.
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente.
|
||||
"""
|
||||
if not filename:
|
||||
filename = "(desconocido)"
|
||||
|
||||
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
text = f"🤖 <b>Generando resumen con IA...</b>\n\n📄 <code>{safe_filename}</code>"
|
||||
return self.send_message(text, parse_mode="HTML")
|
||||
|
||||
def send_summary_complete(self, filename: str, has_markdown: bool = True) -> bool:
|
||||
"""
|
||||
Envía notificación de resumen completado.
|
||||
|
||||
Args:
|
||||
filename: Nombre del archivo.
|
||||
has_markdown: Si se creó el archivo markdown.
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente.
|
||||
"""
|
||||
if not filename:
|
||||
filename = "(desconocido)"
|
||||
|
||||
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
status = "✅" if has_markdown else "⚠️"
|
||||
text = f"{status} <b>Resumen completado</b>\n\n📄 <code>{safe_filename}</code>"
|
||||
return self.send_message(text, parse_mode="HTML")
|
||||
|
||||
def send_pdf_start(self, filename: str) -> bool:
|
||||
"""
|
||||
Envía notificación de inicio de generación de PDF.
|
||||
|
||||
Args:
|
||||
filename: Nombre del archivo.
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente.
|
||||
"""
|
||||
if not filename:
|
||||
filename = "(desconocido)"
|
||||
|
||||
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
text = f"📄 <b>Creando PDF...</b>\n\n📄 <code>{safe_filename}</code>"
|
||||
return self.send_message(text, parse_mode="HTML")
|
||||
|
||||
def send_pdf_complete(self, filename: str, pdf_path: str) -> bool:
|
||||
"""
|
||||
Envía notificación de PDF completado.
|
||||
|
||||
Args:
|
||||
filename: Nombre del archivo.
|
||||
pdf_path: Ruta del PDF generado.
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente.
|
||||
"""
|
||||
if not filename:
|
||||
filename = "(desconocido)"
|
||||
|
||||
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
safe_path = pdf_path.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
text = f"📄 <b>PDF creado</b>\n\n📄 <code>{safe_filename}</code>\n📁 <code>{safe_path}</code>"
|
||||
return self.send_message(text, parse_mode="HTML")
|
||||
|
||||
def send_all_complete(
|
||||
self,
|
||||
filename: str,
|
||||
txt_path: Optional[str] = None,
|
||||
md_path: Optional[str] = None,
|
||||
pdf_path: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Envía notificación final con todos los archivos generados.
|
||||
|
||||
Args:
|
||||
filename: Nombre del archivo original.
|
||||
txt_path: Ruta del archivo de texto (opcional).
|
||||
md_path: Ruta del markdown (opcional).
|
||||
pdf_path: Ruta del PDF (opcional).
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente.
|
||||
"""
|
||||
if not filename:
|
||||
filename = "(desconocido)"
|
||||
|
||||
safe_filename = filename.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
files_text = ""
|
||||
if txt_path:
|
||||
safe_txt = txt_path.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
files_text += f"\n📝 <code>{safe_txt}</code>"
|
||||
if md_path:
|
||||
safe_md = md_path.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
files_text += f"\n📋 <code>{safe_md}</code>"
|
||||
if pdf_path:
|
||||
safe_pdf = pdf_path.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
files_text += f"\n📄 <code>{safe_pdf}</code>"
|
||||
|
||||
text = f"✅ <b>¡Proceso completado!</b>\n\n📄 <code>{safe_filename}</code>\n📁 Archivos:{files_text}"
|
||||
return self.send_message(text, parse_mode="HTML")
|
||||
|
||||
|
||||
# Global instance
|
||||
# Instancia global del servicio
|
||||
telegram_service = TelegramService()
|
||||
|
||||
|
||||
def send_telegram_message(message: str, retries: int = 3, delay: int = 2) -> bool:
|
||||
"""Legacy function for backward compatibility"""
|
||||
return telegram_service.send_message(message)
|
||||
|
||||
Reference in New Issue
Block a user