Fusionar feature/tablamareasbot en main
This commit is contained in:
parent
68b9bf32a3
commit
a2badd511d
.gitignore
catch-all/06_bots_telegram/10_mareas_bot
1
.gitignore
vendored
1
.gitignore
vendored
@ -127,6 +127,7 @@ celerybeat.pid
|
|||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
.env
|
.env
|
||||||
|
*.env
|
||||||
.venv
|
.venv
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
|
2
catch-all/06_bots_telegram/10_mareas_bot/.dockerignore
Normal file
2
catch-all/06_bots_telegram/10_mareas_bot/.dockerignore
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
.app.env
|
||||||
|
.db.env
|
18
catch-all/06_bots_telegram/10_mareas_bot/Dockerfile
Normal file
18
catch-all/06_bots_telegram/10_mareas_bot/Dockerfile
Normal file
@ -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"]
|
261
catch-all/06_bots_telegram/10_mareas_bot/bot/bot.py
Normal file
261
catch-all/06_bots_telegram/10_mareas_bot/bot/bot.py
Normal file
@ -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()
|
60
catch-all/06_bots_telegram/10_mareas_bot/docker-compose.yaml
Normal file
60
catch-all/06_bots_telegram/10_mareas_bot/docker-compose.yaml
Normal file
@ -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:
|
@ -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
|
Loading…
x
Reference in New Issue
Block a user