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 0000000..c782351 Binary files /dev/null and b/utils/__pycache__/cache.cpython-312.pyc differ diff --git a/utils/__pycache__/logger.cpython-312.pyc b/utils/__pycache__/logger.cpython-312.pyc new file mode 100644 index 0000000..8a5126c Binary files /dev/null and b/utils/__pycache__/logger.cpython-312.pyc differ 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")