V2 Pro: Logging rotativo, Redis cache, Health Check de LibreTranslate y Rate Limiting en botones (#7)

This commit is contained in:
2026-03-20 18:03:56 -06:00
parent 57d570ad31
commit 06da793709
13 changed files with 264 additions and 17 deletions

View File

@@ -25,3 +25,9 @@ DB_USER=
DB_PASSWORD= DB_PASSWORD=
DB_NAME=mi_red DB_NAME=mi_red
DATABASE_PATH=bots_config.db 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

25
action_plan_pro.md Normal file
View File

@@ -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.*

View File

@@ -3,6 +3,8 @@ import re
import asyncio import asyncio
from botdiscord.config import get_libretranslate_url, get_languages from botdiscord.config import get_libretranslate_url, get_languages
from botdiscord.database import get_available_languages, get_bot_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): def load_lang_mappings(bot_type: str = None):
global LANG_MAPPING, REVERSE_MAPPING, FLAG_MAPPING, _cached_bot_type, NAME_TO_CODE 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: if not available:
from botdiscord.config import get_languages from botdiscord.config import get_languages
available = 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] 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: if _cached_bot_type:
active_codes = get_bot_languages(_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: if not active_codes:
active_codes = all_codes active_codes = all_codes
else: 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} 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} 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 LANG_MAPPING = code_to_name
NAME_TO_CODE = name_to_code NAME_TO_CODE = name_to_code
FLAG_MAPPING = flag_dict FLAG_MAPPING = flag_dict
@@ -67,18 +66,57 @@ async def _translate_segment(session, url, segment, target_code):
except Exception: except Exception:
return segment 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: async def translate_text(text: str, target_lang: str) -> str:
url = get_libretranslate_url() url = get_libretranslate_url()
if not url: return text 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) 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) segments = re.split(r'([.?!]+\s*|\n+)', text)
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
tasks = [_translate_segment(session, url, seg, target_code) for seg in segments] tasks = [_translate_segment(session, url, seg, target_code) for seg in segments]
translated_segments = await asyncio.gather(*tasks) 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: except Exception:
return text return text

View File

@@ -11,6 +11,11 @@ from botdiscord.database import (
get_available_languages, get_available_languages,
save_message 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): class TranslationButton(discord.ui.Button):
def __init__(self, lang_name: str, lang_code: str, flag: str): 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): async def callback(self, interaction: discord.Interaction):
await interaction.response.defer() 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: try:
if not interaction.message.reference: if not interaction.message.reference:
await interaction.followup.send("⚠️ No se pudo encontrar el mensaje original.", ephemeral=True) 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, mention)
translated = translated.replace(placeholder.replace(" ", ""), mention) translated = translated.replace(placeholder.replace(" ", ""), mention)
# --- FILTRADO DINÁMICO Y BANDERAS DE LA BD ---
guild_id = interaction.guild_id guild_id = interaction.guild_id
active_codes = get_active_languages(guild_id) active_codes = get_active_languages(guild_id)
if not active_codes: if not active_codes:
active_codes = get_bot_languages("discord") active_codes = get_bot_languages("discord")
# Obtenemos los idiomas y banderas REALES de la base de datos
db_langs = get_available_languages() db_langs = get_available_languages()
new_view = discord.ui.View(timeout=None) new_view = discord.ui.View(timeout=None)
for lang in db_langs: for lang in db_langs:
if lang['code'] in active_codes: 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', ''))) new_view.add_item(TranslationButton(lang['name'], lang['code'], lang.get('flag', '')))
await interaction.edit_original_response(content=translated, view=new_view) await interaction.edit_original_response(content=translated, view=new_view)
except Exception as e: 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) await interaction.followup.send(f"❌ Error: {str(e)}", ephemeral=True)
class PersistentTranslationView(discord.ui.View): class PersistentTranslationView(discord.ui.View):

14
docker-compose-redis.yaml Normal file
View File

@@ -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

View File

@@ -25,6 +25,11 @@ services:
- DB_NAME=${DB_NAME} - DB_NAME=${DB_NAME}
- PYTHONDONTWRITEBYTECODE=1 - PYTHONDONTWRITEBYTECODE=1
- PYTHONOPTIMIZE=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_file:
- .env - .env
mem_limit: 512m mem_limit: 512m

View File

@@ -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")) 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 = {} _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"): def translate_filter(text, lang="es"):
if lang == "es" or not text: if lang == "es" or not text:
return text return text
@@ -47,24 +50,33 @@ def translate_filter(text, lang="es"):
text = _normalize_text(text) text = _normalize_text(text)
if not text: return "" if not text: return ""
# 1. Buscamos en caché de MEMORIA (RAM) - Ultra rápido cache_key = f"ui:{lang}:{text}"
cache_key = f"{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: if cache_key in _ui_memory_cache:
return _ui_memory_cache[cache_key] 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) cached = get_ui_translation(text, lang)
if cached: if cached:
_ui_memory_cache[cache_key] = cached _ui_memory_cache[cache_key] = cached
cache_set(cache_key, cached, ttl=604800) # 7 días
return cached 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 from botdiscord.translate import translate_text_sync
translated = translate_text_sync(text, lang) translated = translate_text_sync(text, lang)
if translated and translated != text: if translated and translated != text:
save_ui_translation(text, lang, translated) save_ui_translation(text, lang, translated)
_ui_memory_cache[cache_key] = translated _ui_memory_cache[cache_key] = translated
cache_set(cache_key, translated, ttl=604800) # 7 días
return translated return translated

View File

@@ -12,3 +12,5 @@ mysql-connector-python>=8.0.0
nest-asyncio nest-asyncio
bcrypt bcrypt
passlib passlib
redis>=5.0.0
hiredis>=2.3.0

0
utils/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

83
utils/cache.py Normal file
View File

@@ -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

50
utils/logger.py Normal file
View File

@@ -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")