V2 Pro: Logging rotativo, Redis cache, Health Check de LibreTranslate y Rate Limiting en botones (#7)
This commit is contained in:
@@ -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
|
||||
|
||||
25
action_plan_pro.md
Normal file
25
action_plan_pro.md
Normal 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.*
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
14
docker-compose-redis.yaml
Normal file
14
docker-compose-redis.yaml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -12,3 +12,5 @@ mysql-connector-python>=8.0.0
|
||||
nest-asyncio
|
||||
bcrypt
|
||||
passlib
|
||||
redis>=5.0.0
|
||||
hiredis>=2.3.0
|
||||
|
||||
0
utils/__init__.py
Normal file
0
utils/__init__.py
Normal file
BIN
utils/__pycache__/cache.cpython-312.pyc
Normal file
BIN
utils/__pycache__/cache.cpython-312.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/logger.cpython-312.pyc
Normal file
BIN
utils/__pycache__/logger.cpython-312.pyc
Normal file
Binary file not shown.
83
utils/cache.py
Normal file
83
utils/cache.py
Normal 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
50
utils/logger.py
Normal 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")
|
||||
Reference in New Issue
Block a user