mareas_bot: Fix errors. Update dependencies. Add enhancements

This commit is contained in:
2025-02-19 23:27:15 +01:00
parent a2badd511d
commit 1fa360a109
2 changed files with 133 additions and 30 deletions

View File

@@ -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 logging
import os import os
import psycopg2 import psycopg2
import redis import redis
import json
from bs4 import BeautifulSoup
from dotenv import load_dotenv
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import ApplicationBuilder, CommandHandler, CallbackQueryHandler from telegram.ext import ApplicationBuilder, CommandHandler, CallbackQueryHandler
from dotenv import load_dotenv
import aiohttp
from bs4 import BeautifulSoup
# Cargar variables de entorno # Cargar variables de entorno
load_dotenv() load_dotenv()
@@ -20,20 +30,36 @@ logging.basicConfig(
) )
# Excluir mensajes de nivel INFO de httpx # Excluir mensajes de nivel INFO de httpx
class ExcludeInfoFilter(logging.Filter): class ExcludeInfoFilter(logging.Filter):
def filter(self, record): def filter(self, record):
return record.levelno != logging.INFO return record.levelno != logging.INFO
httpx_logger = logging.getLogger("httpx") httpx_logger = logging.getLogger("httpx")
httpx_logger.addFilter(ExcludeInfoFilter()) httpx_logger.addFilter(ExcludeInfoFilter())
# Helper: Quitar dominio de la URL para que el callback_data sea corto # Helper: Quitar dominio de la URL para que el callback_data sea corto
def shorten_url(url): def shorten_url(url):
domain = "https://tablademareas.com" domain = "https://tablademareas.com"
if url.startswith(domain): if url.startswith(domain):
return url[len(domain):] return url[len(domain):]
return url 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 # Conexión a Redis
try: try:
REDIS_PASSWORD = os.getenv('REDIS_PASSWORD') REDIS_PASSWORD = os.getenv('REDIS_PASSWORD')
@@ -47,11 +73,14 @@ except Exception as e:
r = None r = None
# Funciones de caché # Funciones de caché
def cache_set(key, value, expire=3600): def cache_set(key, value, expire=3600):
if r: if r:
r.set(key, json.dumps(value), ex=expire) r.set(key, json.dumps(value), ex=expire)
logging.info(f"🗃️ Guardado en caché: {key}") logging.info(f"🗃️ Guardado en caché: {key}")
def cache_get(key): def cache_get(key):
if r: if r:
data = r.get(key) data = r.get(key)
@@ -61,8 +90,11 @@ def cache_get(key):
logging.info(f"🚫 Cache MISS para {key}") logging.info(f"🚫 Cache MISS para {key}")
return None return None
# Conexión a PostgreSQL # Conexión a PostgreSQL
DATABASE_URL = os.getenv('DATABASE_URL') DATABASE_URL = os.getenv('DATABASE_URL')
def get_db_connection(): def get_db_connection():
try: try:
conn = psycopg2.connect(DATABASE_URL) conn = psycopg2.connect(DATABASE_URL)
@@ -71,6 +103,7 @@ def get_db_connection():
logging.error(f"❌ Error conectando a PostgreSQL: {e}") logging.error(f"❌ Error conectando a PostgreSQL: {e}")
return None return None
def init_db(): def init_db():
conn = get_db_connection() conn = get_db_connection()
if not conn: if not conn:
@@ -98,12 +131,15 @@ def init_db():
""") """)
logging.info("✅ Base de datos inicializada correctamente.") logging.info("✅ Base de datos inicializada correctamente.")
# Obtener token de Telegram # Obtener token de Telegram
TELEGRAM_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN') TELEGRAM_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN')
# Función para obtener datos de la web con caché # Función para obtener datos de la web con caché
async def fetch(url): async def fetch(url):
url = url.split("#")[0] url = url.split("#")[0] # eliminar fragmentos
cached_data = cache_get(url) cached_data = cache_get(url)
if cached_data: if cached_data:
return cached_data return cached_data
@@ -119,6 +155,8 @@ async def fetch(url):
return None return None
# Función para registrar eventos # Función para registrar eventos
def log_user_event(telegram_id, event_type, event_data): def log_user_event(telegram_id, event_type, event_data):
conn = get_db_connection() conn = get_db_connection()
if not conn: 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}") logging.info(f"📝 Evento registrado: {event_type} - {event_data}")
# Comando /start # Comando /start
async def start(update: Update, context): async def start(update: Update, context):
user = update.message.from_user user = update.message.from_user
conn = get_db_connection() conn = get_db_connection()
@@ -149,6 +189,8 @@ async def start(update: Update, context):
await show_continents(update) await show_continents(update)
# Comando /reset # Comando /reset
async def reset(update: Update, context): async def reset(update: Update, context):
user = update.message.from_user user = update.message.from_user
conn = get_db_connection() conn = get_db_connection()
@@ -162,23 +204,42 @@ async def reset(update: Update, context):
log_user_event(user.id, "reset_command", {}) log_user_event(user.id, "reset_command", {})
await update.message.reply_text("Tu historial ha sido borrado.") 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): async def show_continents(update: Update):
chat = get_chat(update)
full_url = "https://tablademareas.com/" full_url = "https://tablademareas.com/"
response_text = await fetch(full_url) response_text = await fetch(full_url)
if response_text is None: 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 return
soup = BeautifulSoup(response_text, 'html.parser') soup = BeautifulSoup(response_text, 'html.parser')
continentes = soup.select('div#sitios_continentes a') continentes = soup.select('div#sitios_continentes a')
keyboard = [[InlineKeyboardButton(c.text.strip(), callback_data=f"continent:{shorten_url(c['href'])}")] keyboard = [[InlineKeyboardButton(c.text.strip(), callback_data=f"continent:{shorten_url(c['href'])}")]
for c in continentes] for c in continentes]
logging.info(f"🌍 Mostrando continentes a {update.message.chat.username}({update.message.chat.id})") logging.info(f"🌍 Mostrando continentes a {chat.username}({chat.id})")
log_user_event(update.message.chat.id, "show_continents", {}) log_user_event(chat.id, "show_continents", {})
await update.message.reply_text('Selecciona un continente:', reply_markup=InlineKeyboardMarkup(keyboard))
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 # Callback para continente: mostrar países
async def continent_callback(update: Update, context): async def continent_callback(update: Update, context):
query = update.callback_query query = update.callback_query
await query.answer() await query.answer()
@@ -190,13 +251,20 @@ async def continent_callback(update: Update, context):
return return
soup = BeautifulSoup(response_text, 'html.parser') soup = BeautifulSoup(response_text, 'html.parser')
paises = soup.select('a.sitio_reciente_a') paises = soup.select('a.sitio_reciente_a')
keyboard = [[InlineKeyboardButton(p.text.strip(), callback_data=f"country:{shorten_url(p['href'])}")] buttons = sorted(
for p in paises] [InlineKeyboardButton(p.text.strip(
logging.info(f"🌎 {query.from_user.username} ({query.from_user.id}) seleccionó un continente") ), 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}) 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)) 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): async def country_callback(update: Update, context):
query = update.callback_query query = update.callback_query
await query.answer() await query.answer()
@@ -208,13 +276,20 @@ async def country_callback(update: Update, context):
return return
soup = BeautifulSoup(response_text, 'html.parser') soup = BeautifulSoup(response_text, 'html.parser')
provincias = soup.select('a.sitio_reciente_a') provincias = soup.select('a.sitio_reciente_a')
keyboard = [[InlineKeyboardButton(p.text.strip(), callback_data=f"province:{shorten_url(p['href'])}")] buttons = sorted(
for p in provincias] [InlineKeyboardButton(p.text.strip(
logging.info(f"🚢 {query.from_user.username} ({query.from_user.id}) seleccionó un país") ), 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}) log_user_event(query.from_user.id, "country_selected", {"url": full_url})
await query.edit_message_text('Selecciona una provincia:', reply_markup=InlineKeyboardMarkup(keyboard)) await query.edit_message_text('Selecciona una provincia:', reply_markup=InlineKeyboardMarkup(keyboard))
# Callback para provincia: mostrar puertos # Callback para provincia: mostrar puertos
async def province_callback(update: Update, context): async def province_callback(update: Update, context):
query = update.callback_query query = update.callback_query
await query.answer() await query.answer()
@@ -229,33 +304,61 @@ async def province_callback(update: Update, context):
if not puertos: if not puertos:
await query.edit_message_text("No se encontraron puertos en la página.") await query.edit_message_text("No se encontraron puertos en la página.")
return return
keyboard = [[InlineKeyboardButton(p.text.strip(), callback_data=f"port:{shorten_url(p['href'])}")] buttons = []
for p in puertos] for p in puertos:
logging.info(f"{query.from_user.username} ({query.from_user.id}) seleccionó una provincia") 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}) log_user_event(query.from_user.id, "province_selected", {"url": full_url})
await query.edit_message_text('Selecciona un puerto:', reply_markup=InlineKeyboardMarkup(keyboard)) 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): async def port_callback(update: Update, context):
query = update.callback_query query = update.callback_query
await query.answer() await query.answer()
short_url = query.data.split(":", 1)[1] short_url = query.data.split(":", 1)[1]
full_url = "https://tablademareas.com" + short_url 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}) log_user_event(query.from_user.id, "port_selected", {"url": full_url})
await query.edit_message_text(f"Enlace del puerto: {full_url}") await query.edit_message_text(f"Enlace del puerto: {full_url}")
# Función principal para iniciar el bot
def main(): def main():
app = ApplicationBuilder().token(TELEGRAM_TOKEN).build() app = ApplicationBuilder().token(TELEGRAM_TOKEN).build()
app.add_handler(CommandHandler("start", start)) app.add_handler(CommandHandler("start", start))
app.add_handler(CommandHandler("reset", reset)) app.add_handler(CommandHandler("reset", reset))
app.add_handler(CallbackQueryHandler(continent_callback, pattern='^continent:')) app.add_handler(CommandHandler("help", help_command))
app.add_handler(CallbackQueryHandler(country_callback, pattern='^country:')) app.add_handler(CallbackQueryHandler(
app.add_handler(CallbackQueryHandler(province_callback, pattern='^province:')) 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:')) app.add_handler(CallbackQueryHandler(port_callback, pattern='^port:'))
logging.info("🤖 Iniciando el bot...") logging.info("🤖 Iniciando el bot...")
init_db() init_db()
app.run_polling() app.run_polling()
# Ejecutar la función principal si el script se ejecuta directamente
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -1,7 +1,7 @@
aiohttp==3.10.9 aiohttp==3.11.12
beautifulsoup4==4.12.3 beautifulsoup4==4.13.3
psycopg2-binary==2.9.10 psycopg2-binary==2.9.10
python-dotenv==1.0.1 python-dotenv==1.0.1
python-telegram-bot>=20.0 python-telegram-bot>=21.10
redis==5.2.1 redis==5.2.1
requests==2.31.0 requests==2.32.3