From a2badd511d6f042f737db0381206d4fa13edcbff Mon Sep 17 00:00:00 2001 From: Manuel Vergara <manuel@vergaracarmona.es> Date: Wed, 19 Feb 2025 21:24:50 +0100 Subject: [PATCH] Fusionar feature/tablamareasbot en main --- .gitignore | 1 + .../10_mareas_bot/.dockerignore | 2 + .../06_bots_telegram/10_mareas_bot/Dockerfile | 18 ++ .../06_bots_telegram/10_mareas_bot/bot/bot.py | 261 ++++++++++++++++++ .../10_mareas_bot/docker-compose.yaml | 60 ++++ .../10_mareas_bot/requirements.txt | 7 + 6 files changed, 349 insertions(+) create mode 100644 catch-all/06_bots_telegram/10_mareas_bot/.dockerignore create mode 100644 catch-all/06_bots_telegram/10_mareas_bot/Dockerfile create mode 100644 catch-all/06_bots_telegram/10_mareas_bot/bot/bot.py create mode 100644 catch-all/06_bots_telegram/10_mareas_bot/docker-compose.yaml create mode 100644 catch-all/06_bots_telegram/10_mareas_bot/requirements.txt diff --git a/.gitignore b/.gitignore index 570bc0f..eded19a 100644 --- a/.gitignore +++ b/.gitignore @@ -127,6 +127,7 @@ celerybeat.pid # Environments .env +*.env .venv env/ venv/ diff --git a/catch-all/06_bots_telegram/10_mareas_bot/.dockerignore b/catch-all/06_bots_telegram/10_mareas_bot/.dockerignore new file mode 100644 index 0000000..ac830ce --- /dev/null +++ b/catch-all/06_bots_telegram/10_mareas_bot/.dockerignore @@ -0,0 +1,2 @@ +.app.env +.db.env \ No newline at end of file diff --git a/catch-all/06_bots_telegram/10_mareas_bot/Dockerfile b/catch-all/06_bots_telegram/10_mareas_bot/Dockerfile new file mode 100644 index 0000000..11f5f15 --- /dev/null +++ b/catch-all/06_bots_telegram/10_mareas_bot/Dockerfile @@ -0,0 +1,18 @@ +# Usamos una imagen de Python oficial basada en Alpine +FROM python:3.9-alpine + +# Establecemos el directorio de trabajo dentro del contenedor +WORKDIR /app + +# Copiamos el archivo de requerimientos y lo instalamos +COPY requirements.txt . + +# Instalamos las dependencias del proyecto +RUN pip install --no-cache-dir -r requirements.txt + +# Copiamos el resto de los archivos de la aplicación +COPY .env /app/. +COPY ./bot /app + +# Comando para ejecutar el programa +CMD ["python", "bot.py"] diff --git a/catch-all/06_bots_telegram/10_mareas_bot/bot/bot.py b/catch-all/06_bots_telegram/10_mareas_bot/bot/bot.py new file mode 100644 index 0000000..6ba6b63 --- /dev/null +++ b/catch-all/06_bots_telegram/10_mareas_bot/bot/bot.py @@ -0,0 +1,261 @@ +import logging +import os +import psycopg2 +import redis +import json +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update +from telegram.ext import ApplicationBuilder, CommandHandler, CallbackQueryHandler +from dotenv import load_dotenv +import aiohttp +from bs4 import BeautifulSoup + +# Cargar variables de entorno +load_dotenv() + +# Configuración de logging: toma el nivel desde la variable de entorno LOG_LEVEL +log_level = os.getenv('LOG_LEVEL', 'INFO').upper() +logging.basicConfig( + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + level=getattr(logging, log_level, logging.INFO) +) + +# Excluir mensajes de nivel INFO de httpx +class ExcludeInfoFilter(logging.Filter): + def filter(self, record): + return record.levelno != logging.INFO + +httpx_logger = logging.getLogger("httpx") +httpx_logger.addFilter(ExcludeInfoFilter()) + +# Helper: Quitar dominio de la URL para que el callback_data sea corto +def shorten_url(url): + domain = "https://tablademareas.com" + if url.startswith(domain): + return url[len(domain):] + return url + +# Conexión a Redis +try: + REDIS_PASSWORD = os.getenv('REDIS_PASSWORD') + r = redis.Redis( + host='tablamareas-redis', port=6379, db=0, + password=REDIS_PASSWORD, decode_responses=True + ) + logging.info("✅ Conectado a Redis") +except Exception as e: + logging.error(f"⚠️ Error conectando a Redis: {e}") + r = None + +# Funciones de caché +def cache_set(key, value, expire=3600): + if r: + r.set(key, json.dumps(value), ex=expire) + logging.info(f"🗃️ Guardado en caché: {key}") + +def cache_get(key): + if r: + data = r.get(key) + if data: + logging.info(f"💾 Cache HIT para {key}") + return json.loads(data) + logging.info(f"🚫 Cache MISS para {key}") + return None + +# Conexión a PostgreSQL +DATABASE_URL = os.getenv('DATABASE_URL') +def get_db_connection(): + try: + conn = psycopg2.connect(DATABASE_URL) + return conn + except Exception as e: + logging.error(f"❌ Error conectando a PostgreSQL: {e}") + return None + +def init_db(): + conn = get_db_connection() + if not conn: + logging.error("⚠️ No se pudo inicializar la base de datos.") + return + with conn: + with conn.cursor() as cur: + cur.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + telegram_id BIGINT UNIQUE, + username TEXT, + first_name TEXT, + last_name TEXT + ); + """) + cur.execute(""" + CREATE TABLE IF NOT EXISTS events ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + event_type TEXT, + event_data JSONB, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + logging.info("✅ Base de datos inicializada correctamente.") + +# Obtener token de Telegram +TELEGRAM_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN') + +# Función para obtener datos de la web con caché +async def fetch(url): + url = url.split("#")[0] + cached_data = cache_get(url) + if cached_data: + return cached_data + async with aiohttp.ClientSession() as session: + try: + async with session.get(url, headers={"User-Agent": "Mozilla/5.0"}) as response: + response.raise_for_status() + text = await response.text() + cache_set(url, text) + return text + except Exception as e: + logging.error(f"❌ Error en fetch {url}: {e}") + return None + +# Función para registrar eventos +def log_user_event(telegram_id, event_type, event_data): + conn = get_db_connection() + if not conn: + return + with conn: + with conn.cursor() as cur: + cur.execute(""" + INSERT INTO events (user_id, event_type, event_data) + VALUES ((SELECT id FROM users WHERE telegram_id = %s), %s, %s); + """, (telegram_id, event_type, json.dumps(event_data))) + logging.info(f"📝 Evento registrado: {event_type} - {event_data}") + +# Comando /start +async def start(update: Update, context): + user = update.message.from_user + conn = get_db_connection() + if conn: + with conn: + with conn.cursor() as cur: + cur.execute(""" + INSERT INTO users (telegram_id, username, first_name, last_name) + VALUES (%s, %s, %s, %s) + ON CONFLICT (telegram_id) DO NOTHING; + """, (user.id, user.username, user.first_name, user.last_name)) + logging.info(f"👤 Nuevo usuario registrado: {user.username} ({user.id})") + log_user_event(user.id, "start_command", {}) + await update.message.reply_text("¡Bienvenido! Soy un bot de tabla de mareas (https://tablademareas.com/).") + await show_continents(update) + +# Comando /reset +async def reset(update: Update, context): + user = update.message.from_user + conn = get_db_connection() + if conn: + with conn: + with conn.cursor() as cur: + cur.execute(""" + DELETE FROM events WHERE user_id = (SELECT id FROM users WHERE telegram_id = %s); + """, (user.id,)) + logging.info(f"🔄 Historial borrado para {user.username} ({user.id}).") + log_user_event(user.id, "reset_command", {}) + await update.message.reply_text("Tu historial ha sido borrado.") + +# Función para obtener continentes +async def show_continents(update: Update): + full_url = "https://tablademareas.com/" + response_text = await fetch(full_url) + if response_text is None: + await update.message.reply_text("Error al obtener los continentes.") + return + soup = BeautifulSoup(response_text, 'html.parser') + continentes = soup.select('div#sitios_continentes a') + + keyboard = [[InlineKeyboardButton(c.text.strip(), callback_data=f"continent:{shorten_url(c['href'])}")] + for c in continentes] + logging.info(f"🌍 Mostrando continentes a {update.message.chat.username}({update.message.chat.id})") + log_user_event(update.message.chat.id, "show_continents", {}) + await update.message.reply_text('Selecciona un continente:', reply_markup=InlineKeyboardMarkup(keyboard)) + +# Callback para continente: mostrar países +async def continent_callback(update: Update, context): + query = update.callback_query + await query.answer() + short_url = query.data.split(":", 1)[1] + full_url = "https://tablademareas.com" + short_url + response_text = await fetch(full_url) + if response_text is None: + await query.edit_message_text("Error al obtener los países.") + return + soup = BeautifulSoup(response_text, 'html.parser') + paises = soup.select('a.sitio_reciente_a') + keyboard = [[InlineKeyboardButton(p.text.strip(), callback_data=f"country:{shorten_url(p['href'])}")] + for p in paises] + logging.info(f"🌎 {query.from_user.username} ({query.from_user.id}) seleccionó un continente") + log_user_event(query.from_user.id, "continent_selected", {"url": full_url}) + await query.edit_message_text('Selecciona un país:', reply_markup=InlineKeyboardMarkup(keyboard)) + +# Callback para país: mostrar provincias y corregir mensaje +async def country_callback(update: Update, context): + query = update.callback_query + await query.answer() + short_url = query.data.split(":", 1)[1] + full_url = "https://tablademareas.com" + short_url + response_text = await fetch(full_url) + if response_text is None: + await query.edit_message_text("Error al obtener las provincias.") + return + soup = BeautifulSoup(response_text, 'html.parser') + provincias = soup.select('a.sitio_reciente_a') + keyboard = [[InlineKeyboardButton(p.text.strip(), callback_data=f"province:{shorten_url(p['href'])}")] + for p in provincias] + logging.info(f"🚢 {query.from_user.username} ({query.from_user.id}) seleccionó un país") + log_user_event(query.from_user.id, "country_selected", {"url": full_url}) + await query.edit_message_text('Selecciona una provincia:', reply_markup=InlineKeyboardMarkup(keyboard)) + +# Callback para provincia: mostrar puertos +async def province_callback(update: Update, context): + query = update.callback_query + await query.answer() + short_url = query.data.split(":", 1)[1] + full_url = "https://tablademareas.com" + short_url + response_text = await fetch(full_url) + if response_text is None: + await query.edit_message_text("Error al obtener los puertos.") + return + soup = BeautifulSoup(response_text, 'html.parser') + puertos = soup.select('a.sitio_estacion_a') + if not puertos: + await query.edit_message_text("No se encontraron puertos en la página.") + return + keyboard = [[InlineKeyboardButton(p.text.strip(), callback_data=f"port:{shorten_url(p['href'])}")] + for p in puertos] + logging.info(f"⚓ {query.from_user.username} ({query.from_user.id}) seleccionó una provincia") + log_user_event(query.from_user.id, "province_selected", {"url": full_url}) + await query.edit_message_text('Selecciona un puerto:', reply_markup=InlineKeyboardMarkup(keyboard)) + +# Callback para puerto: acción final (mostrar enlace) +async def port_callback(update: Update, context): + query = update.callback_query + await query.answer() + short_url = query.data.split(":", 1)[1] + full_url = "https://tablademareas.com" + short_url + logging.info(f"🚩 {query.from_user.username} ({query.from_user.id}) seleccionó un puerto") + log_user_event(query.from_user.id, "port_selected", {"url": full_url}) + await query.edit_message_text(f"Enlace del puerto: {full_url}") + +def main(): + app = ApplicationBuilder().token(TELEGRAM_TOKEN).build() + app.add_handler(CommandHandler("start", start)) + app.add_handler(CommandHandler("reset", reset)) + app.add_handler(CallbackQueryHandler(continent_callback, pattern='^continent:')) + app.add_handler(CallbackQueryHandler(country_callback, pattern='^country:')) + app.add_handler(CallbackQueryHandler(province_callback, pattern='^province:')) + app.add_handler(CallbackQueryHandler(port_callback, pattern='^port:')) + logging.info("🤖 Iniciando el bot...") + init_db() + app.run_polling() + +if __name__ == "__main__": + main() diff --git a/catch-all/06_bots_telegram/10_mareas_bot/docker-compose.yaml b/catch-all/06_bots_telegram/10_mareas_bot/docker-compose.yaml new file mode 100644 index 0000000..66d6d56 --- /dev/null +++ b/catch-all/06_bots_telegram/10_mareas_bot/docker-compose.yaml @@ -0,0 +1,60 @@ +services: + tablamareas-app: + build: . + container_name: tablamareas-app + depends_on: + tablamareas-db: + condition: service_healthy + tablamareas-redis: + condition: service_healthy + env_file: + - .env + networks: + - tablamareas_network + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pgrep -f bot.py || exit 1"] + interval: 10s + retries: 3 + start_period: 10s + + + tablamareas-db: + image: postgres:16-alpine + container_name: tablamareas-db + env_file: + - .env + networks: + - tablamareas_network + volumes: + - pgdata:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER"] + interval: 10s + retries: 5 + start_period: 10s + + + tablamareas-redis: + image: redis:alpine + container_name: tablamareas-redis + env_file: + - .env + networks: + - tablamareas_network + restart: unless-stopped + command: redis-server --requirepass "$REDIS_PASSWORD" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + retries: 5 + start_period: 5s + + +networks: + tablamareas_network: + + +volumes: + pgdata: diff --git a/catch-all/06_bots_telegram/10_mareas_bot/requirements.txt b/catch-all/06_bots_telegram/10_mareas_bot/requirements.txt new file mode 100644 index 0000000..36f3151 --- /dev/null +++ b/catch-all/06_bots_telegram/10_mareas_bot/requirements.txt @@ -0,0 +1,7 @@ +aiohttp==3.10.9 +beautifulsoup4==4.12.3 +psycopg2-binary==2.9.10 +python-dotenv==1.0.1 +python-telegram-bot>=20.0 +redis==5.2.1 +requests==2.31.0 \ No newline at end of file