From 06da7937092015df2f25a9a879e29ed0c99272b0 Mon Sep 17 00:00:00 2001 From: nickpons666 Date: Fri, 20 Mar 2026 18:03:56 -0600 Subject: [PATCH] V2 Pro: Logging rotativo, Redis cache, Health Check de LibreTranslate y Rate Limiting en botones (#7) --- .env.example | 6 ++ action_plan_pro.md | 25 +++++++ botdiscord/translate.py | 52 ++++++++++++-- botdiscord/ui.py | 20 ++++-- docker-compose-redis.yaml | 14 ++++ docker-compose.yml | 5 ++ panel/main.py | 24 +++++-- requirements.txt | 2 + utils/__init__.py | 0 utils/__pycache__/cache.cpython-312.pyc | Bin 0 -> 3585 bytes utils/__pycache__/logger.cpython-312.pyc | Bin 0 -> 2283 bytes utils/cache.py | 83 +++++++++++++++++++++++ utils/logger.py | 50 ++++++++++++++ 13 files changed, 264 insertions(+), 17 deletions(-) create mode 100644 action_plan_pro.md create mode 100644 docker-compose-redis.yaml create mode 100644 utils/__init__.py create mode 100644 utils/__pycache__/cache.cpython-312.pyc create mode 100644 utils/__pycache__/logger.cpython-312.pyc create mode 100644 utils/cache.py create mode 100644 utils/logger.py diff --git a/.env.example b/.env.example index dded767..e08693b 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,9 @@ DB_USER= DB_PASSWORD= DB_NAME=mi_red DATABASE_PATH=bots_config.db + +# Configuración de Redis (caché compartida) +REDIS_HOST=192.168.1.X # IP de tu servidor OMV +REDIS_PORT=6379 +REDIS_PASSWORD=translation_redis_secret +REDIS_DB=0 diff --git a/action_plan_pro.md b/action_plan_pro.md new file mode 100644 index 0000000..4ca067c --- /dev/null +++ b/action_plan_pro.md @@ -0,0 +1,25 @@ +# Plan de Acción: Nivel Profesional (V2) + +## 🎯 Progreso de Mejoras + +- [ ] **1. Panel de Métricas (Analytics & Estadísticas)** + - Agregar gráficos en el Dashboard (FastAPI/Jinja). + - Contabilizar traducciones totales, por idioma y por plataforma. + +- [ ] **2. Rate Limiting (Prevención de Spam y Abusos)** + - Limitar botones de Discord/Telegram a X usos por minuto por usuario. + - Implementar mensajes efímeros de advertencia por spam. + +- [ ] **3. Sistema de Logging Real y Monitoreo (Observabilidad)** + - Reemplazar `print()` por la librería estandar `logging` con guardado en disco rotatorio (archivos diarios). + - Enmascarar errores y alertas. + +- [ ] **4. Reemplazo de Caché en RAM por Redis** + - Configurar contenedor oficial de Redis en OMV (`docker-compose-redis.yaml`). + - Adaptar `botdiscord/database.py` y `panel/main.py` para usar Redis si está disponible. + +- [ ] **5. Sistema de "Health Check" de LibreTranslate** + - Comprobar que el endpoint de traducción está vivo antes de lanzar errores, devolviendo mensaje de mantenimiento. + +--- +*Este documento guiará la transformación del ecosistema de bots a un entorno de producción masiva.* diff --git a/botdiscord/translate.py b/botdiscord/translate.py index 532ad4d..1ecd79e 100644 --- a/botdiscord/translate.py +++ b/botdiscord/translate.py @@ -3,6 +3,8 @@ import re import asyncio from botdiscord.config import get_libretranslate_url, get_languages from botdiscord.database import get_available_languages, get_bot_languages +from utils.logger import discord_logger as log +from utils.cache import cache_get, cache_set def load_lang_mappings(bot_type: str = None): global LANG_MAPPING, REVERSE_MAPPING, FLAG_MAPPING, _cached_bot_type, NAME_TO_CODE @@ -15,14 +17,14 @@ def load_lang_mappings(bot_type: str = None): if not available: from botdiscord.config import get_languages available = get_languages() - print(f"[DEBUG] Idiomas desde config: {available}") + log.debug(f"Idiomas desde config: {available}") all_codes = [lang["code"] for lang in available] - print(f"[DEBUG] Códigos disponibles: {all_codes}") + log.debug(f"Códigos disponibles: {all_codes}") if _cached_bot_type: active_codes = get_bot_languages(_cached_bot_type) - print(f"[DEBUG] Códigos activos para {_cached_bot_type}: {active_codes}") + log.debug(f"Códigos activos para {_cached_bot_type}: {active_codes}") if not active_codes: active_codes = all_codes else: @@ -35,9 +37,6 @@ def load_lang_mappings(bot_type: str = None): code_to_name = {lang["code"]: lang["name"] for lang in available if lang["code"] in active_codes} flag_dict = {lang["code"]: lang.get("flag", "") for lang in available} - print(f"[DEBUG] FLAG_MAPPING: {flag_dict}") - print(f"[DEBUG] NAME_TO_CODE: {name_to_code}") - LANG_MAPPING = code_to_name NAME_TO_CODE = name_to_code FLAG_MAPPING = flag_dict @@ -67,18 +66,57 @@ async def _translate_segment(session, url, segment, target_code): except Exception: return segment +_libretranslate_healthy: bool = True +_last_health_check: float = 0 + +async def check_libretranslate_health(url: str) -> bool: + """Verifica si LibreTranslate está disponible. Cachea el resultado 30 segundos.""" + global _libretranslate_healthy, _last_health_check + import time + now = time.monotonic() + if now - _last_health_check < 30: + return _libretranslate_healthy + + _last_health_check = now + try: + async with aiohttp.ClientSession() as session: + async with session.get(url.replace("/translate", "/languages"), timeout=3) as resp: + _libretranslate_healthy = resp.status == 200 + except Exception: + _libretranslate_healthy = False + + if not _libretranslate_healthy: + log.warning("⚠️ LibreTranslate no está disponible o tardó demasiado en responder.") + return _libretranslate_healthy + async def translate_text(text: str, target_lang: str) -> str: url = get_libretranslate_url() if not url: return text + # Health Check: si LibreTranslate está caído, retornamos el texto con aviso + if not await check_libretranslate_health(url): + return f"⚠️ *Servicio de traducción en mantenimiento.* | {text}" + target_code = NAME_TO_CODE.get(target_lang, target_lang) + + # Revisamos caché Redis antes de hacer cualquier petición + redis_key = f"trans:{target_code}:{hash(text)}" + cached = cache_get(redis_key) + if cached: + return cached + segments = re.split(r'([.?!]+\s*|\n+)', text) try: async with aiohttp.ClientSession() as session: tasks = [_translate_segment(session, url, seg, target_code) for seg in segments] translated_segments = await asyncio.gather(*tasks) - return "".join(translated_segments) + result = "".join(translated_segments) + + # Guardar en Redis por 24 horas + if result != text: + cache_set(redis_key, result, ttl=86400) + return result except Exception: return text diff --git a/botdiscord/ui.py b/botdiscord/ui.py index 8d18443..680a187 100644 --- a/botdiscord/ui.py +++ b/botdiscord/ui.py @@ -11,6 +11,11 @@ from botdiscord.database import ( get_available_languages, save_message ) +from utils.logger import discord_logger as log +from utils.cache import cache_increment + +# Rate Limit: máximo 1 clic por usuario por idioma cada 3 segundos +_RATE_LIMIT_SECONDS = 3 class TranslationButton(discord.ui.Button): def __init__(self, lang_name: str, lang_code: str, flag: str): @@ -24,6 +29,16 @@ class TranslationButton(discord.ui.Button): async def callback(self, interaction: discord.Interaction): await interaction.response.defer() + # Rate Limiting: verificamos que el usuario no esté haciendo spam + rate_key = f"rl:discord:{interaction.user.id}:{self.lang_code}" + clicks = cache_increment(rate_key, ttl=_RATE_LIMIT_SECONDS) + if clicks > 1: + await interaction.followup.send( + f"⏳ Por favor espera {_RATE_LIMIT_SECONDS} segundos entre traducciones.", + ephemeral=True + ) + return + try: if not interaction.message.reference: await interaction.followup.send("⚠️ No se pudo encontrar el mensaje original.", ephemeral=True) @@ -52,25 +67,22 @@ class TranslationButton(discord.ui.Button): translated = translated.replace(placeholder, mention) translated = translated.replace(placeholder.replace(" ", ""), mention) - # --- FILTRADO DINÁMICO Y BANDERAS DE LA BD --- guild_id = interaction.guild_id active_codes = get_active_languages(guild_id) if not active_codes: active_codes = get_bot_languages("discord") - # Obtenemos los idiomas y banderas REALES de la base de datos db_langs = get_available_languages() new_view = discord.ui.View(timeout=None) for lang in db_langs: if lang['code'] in active_codes: - # Usamos el nombre y la bandera que vienen de MySQL new_view.add_item(TranslationButton(lang['name'], lang['code'], lang.get('flag', ''))) await interaction.edit_original_response(content=translated, view=new_view) except Exception as e: - print(f"[ERROR UI] {e}") + log.error(f"Error en botón de traducción Discord: {e}") await interaction.followup.send(f"❌ Error: {str(e)}", ephemeral=True) class PersistentTranslationView(discord.ui.View): diff --git a/docker-compose-redis.yaml b/docker-compose-redis.yaml new file mode 100644 index 0000000..e585109 --- /dev/null +++ b/docker-compose-redis.yaml @@ -0,0 +1,14 @@ +version: '3.8' + +services: + redis-cache: + image: redis:alpine + container_name: redis-translation-cache + restart: unless-stopped + command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-translation_redis_secret} + ports: + - "6379:6379" + volumes: + - /media/DATOS/AppData/redis:/data + mem_limit: 256m + mem_reservation: 128m diff --git a/docker-compose.yml b/docker-compose.yml index 66f977e..4ce7d9d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,11 @@ services: - DB_NAME=${DB_NAME} - PYTHONDONTWRITEBYTECODE=1 - PYTHONOPTIMIZE=1 + # Redis caché compartida + - REDIS_HOST=${REDIS_HOST} + - REDIS_PORT=${REDIS_PORT:-6379} + - REDIS_PASSWORD=${REDIS_PASSWORD} + - REDIS_DB=${REDIS_DB:-0} env_file: - .env mem_limit: 512m diff --git a/panel/main.py b/panel/main.py index 1200b80..7ca0b46 100644 --- a/panel/main.py +++ b/panel/main.py @@ -35,10 +35,13 @@ SCRIPT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) templates = Jinja2Templates(directory=os.path.join(SCRIPT_DIR, "panel", "templates")) -# Caché de memoria para traducciones UI (evita 50 queries SQL por página) +from utils.logger import panel_logger as log +from utils.cache import cache_get, cache_set, cache_increment + +# Caché de memoria RAM como fallback si Redis no está disponible _ui_memory_cache = {} -# Filtro de traducción para Jinja2 +# Filtro de traducción para Jinja2 (usa Redis → RAM → DB → LibreTranslate en cascada) def translate_filter(text, lang="es"): if lang == "es" or not text: return text @@ -47,24 +50,33 @@ def translate_filter(text, lang="es"): text = _normalize_text(text) if not text: return "" - # 1. Buscamos en caché de MEMORIA (RAM) - Ultra rápido - cache_key = f"{lang}:{text}" + cache_key = f"ui:{lang}:{text}" + + # 1. Redis (compartido y persistente entre reinicios) + redis_val = cache_get(cache_key) + if redis_val: + _ui_memory_cache[cache_key] = redis_val + return redis_val + + # 2. RAM local (ultra rápida, temporal) if cache_key in _ui_memory_cache: return _ui_memory_cache[cache_key] - # 2. Buscamos en caché de la base de datos (acceso rápido) + # 3. Base de datos MySQL cached = get_ui_translation(text, lang) if cached: _ui_memory_cache[cache_key] = cached + cache_set(cache_key, cached, ttl=604800) # 7 días return cached - # 3. Si no está en ningún caché, traducimos de forma síncrona + # 4. LibreTranslate (sincrónico, solo si no está en ningún caché) from botdiscord.translate import translate_text_sync translated = translate_text_sync(text, lang) if translated and translated != text: save_ui_translation(text, lang, translated) _ui_memory_cache[cache_key] = translated + cache_set(cache_key, translated, ttl=604800) # 7 días return translated diff --git a/requirements.txt b/requirements.txt index 9086b5f..b3c42d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,5 @@ mysql-connector-python>=8.0.0 nest-asyncio bcrypt passlib +redis>=5.0.0 +hiredis>=2.3.0 diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/__pycache__/cache.cpython-312.pyc b/utils/__pycache__/cache.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c782351a70b01f29752116417671b00955f323f0 GIT binary patch literal 3585 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 QCLdfmSWkR(BWxe&A1wSQ+5i9m literal 0 HcmV?d00001 diff --git a/utils/cache.py b/utils/cache.py new file mode 100644 index 0000000..5566e7e --- /dev/null +++ b/utils/cache.py @@ -0,0 +1,83 @@ +""" +Módulo de caché Redis - Bots de Traducción +Proporciona una interfaz unificada de caché con Redis como backend +y fallback a memoria RAM si Redis no está disponible. +""" +import os +import redis +from utils.logger import discord_logger as log + +_redis_client = None + +def get_redis() -> redis.Redis | None: + """Retorna cliente Redis, intentando conectar si no existe. Devuelve None si no disponible.""" + global _redis_client + if _redis_client is not None: + return _redis_client + + host = os.getenv("REDIS_HOST", "localhost") + port = int(os.getenv("REDIS_PORT", "6379")) + password = os.getenv("REDIS_PASSWORD", "translation_redis_secret") + db = int(os.getenv("REDIS_DB", "0")) + + try: + client = redis.Redis( + host=host, + port=port, + password=password, + db=db, + decode_responses=True, + socket_connect_timeout=2, + socket_timeout=2 + ) + client.ping() + _redis_client = client + log.info(f"✅ Redis conectado en {host}:{port}") + return _redis_client + except Exception as e: + log.warning(f"⚠️ Redis no disponible, usando caché en RAM: {e}") + return None + +# ─── API de caché genérica (con TTL en segundos) ─────────────────────────── + +def cache_get(key: str) -> str | None: + """Obtiene un valor del caché. Devuelve None si no existe o si Redis no está disponible.""" + r = get_redis() + if r: + try: + return r.get(key) + except Exception: + pass + return None + +def cache_set(key: str, value: str, ttl: int = 86400) -> None: + """Guarda un valor en el caché con TTL en segundos (default 24h).""" + r = get_redis() + if r: + try: + r.setex(key, ttl, value) + except Exception: + pass + +def cache_delete(key: str) -> None: + """Elimina una clave del caché.""" + r = get_redis() + if r: + try: + r.delete(key) + except Exception: + pass + +def cache_increment(key: str, ttl: int = 60) -> int: + """Incrementa un contador atómico en Redis. Ideal para Rate Limiting.""" + r = get_redis() + if r: + try: + pipe = r.pipeline() + pipe.incr(key) + pipe.expire(key, ttl) + results = pipe.execute() + return results[0] + except Exception: + pass + return 0 diff --git a/utils/logger.py b/utils/logger.py new file mode 100644 index 0000000..6fd7c22 --- /dev/null +++ b/utils/logger.py @@ -0,0 +1,50 @@ +""" +Módulo de Logging Centralizado - Bots de Traducción +Proporciona loggers rotativos por día para Discord, Telegram y Panel Web. +""" +import logging +import os +from logging.handlers import TimedRotatingFileHandler + +# Directorio base para almacenar los logs +LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logs") +os.makedirs(LOG_DIR, exist_ok=True) + +def _create_logger(name: str, filename: str, level=logging.INFO) -> logging.Logger: + """Crea y configura un logger con salida a archivo rotativo y a consola.""" + logger = logging.getLogger(name) + + if logger.handlers: + return logger # Ya está configurado, no duplicar handlers + + logger.setLevel(level) + + formatter = logging.Formatter( + "[%(asctime)s] %(levelname)s [%(name)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" + ) + + # Handler de archivo: rota a medianoche, guarda los últimos 14 días + file_handler = TimedRotatingFileHandler( + os.path.join(LOG_DIR, filename), + when="midnight", + interval=1, + backupCount=14, + encoding="utf-8" + ) + file_handler.setFormatter(formatter) + file_handler.suffix = "%Y-%m-%d.log" + + # Handler de consola para visualización en Docker + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) + + logger.addHandler(file_handler) + logger.addHandler(console_handler) + + return logger + +# Loggers disponibles para cada componente del ecosistema +discord_logger = _create_logger("discord_bot", "discord.log") +telegram_logger = _create_logger("telegram_bot", "telegram.log") +panel_logger = _create_logger("panel_web", "panel.log")