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 index 6ba6b63..bcd2929 100644 --- a/catch-all/06_bots_telegram/10_mareas_bot/bot/bot.py +++ b/catch-all/06_bots_telegram/10_mareas_bot/bot/bot.py @@ -1,13 +1,23 @@ +""" +Bot de Telegram para mostrar la tabla de mareas de tablademareas.com + +Comandos: +- /start: Iniciar el bot +- /reset: Borrar el historial +- /help: Mostrar este mensaje de ayuda +""" + +import aiohttp +import json import logging import os import psycopg2 import redis -import json + +from bs4 import BeautifulSoup +from dotenv import load_dotenv 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() @@ -20,20 +30,36 @@ logging.basicConfig( ) # 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 +# Helper: Obtener el objeto chat, ya sea de update.message o de update.callback_query.message + + +def get_chat(update: Update): + if update.message: + return update.message.chat + elif update.callback_query and update.callback_query.message: + return update.callback_query.message.chat + return None + + # Conexión a Redis try: REDIS_PASSWORD = os.getenv('REDIS_PASSWORD') @@ -47,11 +73,14 @@ except Exception as 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) @@ -61,8 +90,11 @@ def cache_get(key): 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) @@ -71,6 +103,7 @@ def get_db_connection(): logging.error(f"❌ Error conectando a PostgreSQL: {e}") return None + def init_db(): conn = get_db_connection() if not conn: @@ -98,12 +131,15 @@ def init_db(): """) 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] + url = url.split("#")[0] # eliminar fragmentos cached_data = cache_get(url) if cached_data: return cached_data @@ -119,6 +155,8 @@ async def fetch(url): return None # Función para registrar eventos + + def log_user_event(telegram_id, event_type, event_data): conn = get_db_connection() if not conn: @@ -132,6 +170,8 @@ def log_user_event(telegram_id, event_type, 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() @@ -149,6 +189,8 @@ async def start(update: Update, context): await show_continents(update) # Comando /reset + + async def reset(update: Update, context): user = update.message.from_user conn = get_db_connection() @@ -162,23 +204,42 @@ async def reset(update: Update, context): log_user_event(user.id, "reset_command", {}) await update.message.reply_text("Tu historial ha sido borrado.") -# Función para obtener continentes +# Comando /help + + +async def help_command(update: Update, context): + await update.message.reply_text( + "Opciones:\n- /start: Iniciar el bot\n- /reset: Borrar el historial\n- /help: Mostrar este mensaje de ayuda" + ) + +# Función para obtener continentes (nivel 0) + + async def show_continents(update: Update): + chat = get_chat(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.") + if update.message: + await update.message.reply_text("Error al obtener los continentes.") + else: + await update.callback_query.edit_message_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)) + logging.info(f"🌍 Mostrando continentes a {chat.username}({chat.id})") + log_user_event(chat.id, "show_continents", {}) + + if update.message: + await update.message.reply_text('Selecciona un continente:', reply_markup=InlineKeyboardMarkup(keyboard)) + else: + await update.callback_query.edit_message_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() @@ -190,13 +251,20 @@ async def continent_callback(update: Update, context): 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") + buttons = sorted( + [InlineKeyboardButton(p.text.strip( + ), callback_data=f"country:{shorten_url(p['href'])}") for p in paises], + key=lambda btn: btn.text.lower() + ) + keyboard = [[btn] for btn in buttons] + 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 +# Callback para país: mostrar provincias + + async def country_callback(update: Update, context): query = update.callback_query await query.answer() @@ -208,13 +276,20 @@ async def country_callback(update: Update, context): 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") + buttons = sorted( + [InlineKeyboardButton(p.text.strip( + ), callback_data=f"province:{shorten_url(p['href'])}") for p in provincias], + key=lambda btn: btn.text.lower() + ) + keyboard = [[btn] for btn in buttons] + 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() @@ -229,33 +304,61 @@ async def province_callback(update: Update, context): 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") + buttons = [] + for p in puertos: + href = p.get('href') + if not href: + continue + station_container = p.find("div", class_="sitio_estacion") + port_name = None + if station_container: + first_div = station_container.find("div", recursive=False) + if first_div: + port_name = first_div.get_text(strip=True) + if not port_name: + port_name = p.text.strip() + buttons.append(InlineKeyboardButton( + port_name, callback_data=f"port:{shorten_url(href)}")) + buttons = sorted(buttons, key=lambda btn: btn.text.lower()) + keyboard = [[btn] for btn in buttons] + 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) +# Callback para puerto: acción final + + 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") + 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}") +# Función principal para iniciar el bot + + 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(CommandHandler("help", help_command)) + 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() + +# Ejecutar la función principal si el script se ejecuta directamente if __name__ == "__main__": main() diff --git a/catch-all/06_bots_telegram/10_mareas_bot/requirements.txt b/catch-all/06_bots_telegram/10_mareas_bot/requirements.txt index 36f3151..ffe4393 100644 --- a/catch-all/06_bots_telegram/10_mareas_bot/requirements.txt +++ b/catch-all/06_bots_telegram/10_mareas_bot/requirements.txt @@ -1,7 +1,7 @@ -aiohttp==3.10.9 -beautifulsoup4==4.12.3 +aiohttp==3.11.12 +beautifulsoup4==4.13.3 psycopg2-binary==2.9.10 python-dotenv==1.0.1 -python-telegram-bot>=20.0 +python-telegram-bot>=21.10 redis==5.2.1 -requests==2.31.0 \ No newline at end of file +requests==2.32.3 \ No newline at end of file