inline keyboard and move to channel with threads

Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
Omar Sánchez Pizarro
2025-10-10 02:45:55 +02:00
parent 967f7e52a2
commit fa79e53950
6 changed files with 272 additions and 18 deletions

3
.gitignore vendored
View File

@@ -159,4 +159,5 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
config.yaml
config.yaml
favorites.json

View File

@@ -43,6 +43,7 @@
| `description_must_include` | Palabras requeridas en la descripción: si no aparece alguna, se descarta. | `["funciona"]` | No |
| `title_first_word_exclude` | Lista de palabras: si el primer término del título coincide, se descarta. (Nuevo) | `["Reacondicionado"]` | No |
| `check_every` | Cada cuántos segundos se actualiza la búsqueda (por defecto, 30s si no se especifica). | `15` | No |
| `thread_id` | ID del tema/hilo de Telegram donde se enviarán los mensajes. Si no se especifica, se envía al tema general. (Nuevo) | `2` | No |
Consulta el archivo de ejemplo [args.json](./args.json) para ver cómo estructurarlo.
@@ -52,6 +53,34 @@
- Multiples criterios combinados para ignorar anuncios indeseados o exigir palabras clave.
- Recibes una galería de imágenes en cada notificación, no solo una imagen.
- El código es más modular y fácil de personalizar para diferentes búsquedas simultáneas.
### Sistema de Favoritos ⭐
- **Botones interactivos**: Cada artículo incluye un botón "⭐ Añadir a favoritos" para guardar rápidamente los que te interesan.
- **Comando /favs**: Escribe `/favs` en tu chat de Telegram para ver todos tus artículos favoritos guardados.
- **Gestión completa**: Puedes añadir y eliminar artículos de favoritos con un solo clic.
- **Persistencia**: Todos tus favoritos se guardan en `favorites.json` y persisten entre reinicios.
- **Enlaces directos**: Cada favorito incluye un enlace directo al mensaje original en Telegram.
### Soporte para Temas de Telegram 📌
Wallabicher ahora soporta grupos de Telegram con temas (topics/hilos). Puedes organizar tus notificaciones enviando cada búsqueda a su tema correspondiente:
- **Configuración por worker**: Añade el parámetro `thread_id` a cada búsqueda en `workers.json` con el ID del tema donde quieres recibir las notificaciones.
- **Tema general**: Si un worker no tiene especificado el `thread_id`, los mensajes se enviarán al tema general del grupo.
- **Cómo obtener el thread_id**:
1. En tu grupo de Telegram, haz clic en el tema donde quieres enviar notificaciones
2. Copia el enlace del tema (tiene el formato: `https://t.me/c/XXXXX/THREAD_ID`)
3. El número después de la última barra es el `thread_id`
**Ejemplo en workers.json**:
```json
{
"name": "Nintendo 64",
"search_query": "nintendo 64",
"thread_id": 6
}
```
## Uso 🚀

View File

@@ -3,7 +3,7 @@ class ItemMonitor:
def __init__(self, name,search_query, latitude, longitude, max_distance,
condition, min_price, max_price, title_exclude,
description_exclude, title_must_include, description_must_include,
title_first_word_exclude, check_every):
title_first_word_exclude, check_every, thread_id):
self._name = name
self._search_query = search_query
self._latitude = latitude
@@ -18,6 +18,7 @@ class ItemMonitor:
self._description_must_include = description_must_include
self._title_first_word_exclude = title_first_word_exclude
self._check_every = check_every
self._thread_id = thread_id
@classmethod
def load_from_json(cls, json_data):
# search_query is mandatory
@@ -38,7 +39,8 @@ class ItemMonitor:
json_data.get('title_must_include', []),
json_data.get('description_must_include', []),
json_data.get('title_first_word_exclude', []),
json_data.get('check_every', 30)
json_data.get('check_every', 30),
json_data.get('thread_id', 1)
)
def get_name(self):
@@ -81,4 +83,7 @@ class ItemMonitor:
return self._title_first_word_exclude
def get_check_every(self):
return self._check_every
return self._check_every
def get_thread_id(self):
return self._thread_id

View File

@@ -19,14 +19,14 @@ class QueueManager:
self._processor_thread = threading.Thread(target=self._process_queue, daemon=True)
self._processor_thread.start()
def add_to_queue(self, article, search_name=None):
def add_to_queue(self, article, search_name=None, thread_id=None):
# Verificar si el artículo ya ha sido enviado
if article in self._notified_articles:
return
if search_name is None:
search_name = "Unknown"
self._queue.put((search_name, article))
self._queue.put((search_name, article, thread_id))
self.logger.debug(f"Artículo añadido a la cola: {article.get_title()}")
self.add_to_notified_articles(article)
@@ -45,14 +45,13 @@ class QueueManager:
try:
# Esperar hasta que haya un elemento en la cola (timeout de 1 segundo)
try:
search_name, article = self._queue.get(timeout=1.0)
search_name, article, thread_id = self._queue.get(timeout=1.0)
except queue.Empty:
continue
# Procesar el artículo
try:
self._telegram_manager.send_telegram_article(search_name, article)
self.logger.info(f"Artículo enviado a Telegram: {article.get_title()} de {search_name}")
self._telegram_manager.send_telegram_article(search_name, article, thread_id)
# Mantener solo los primeros NOTIFIED_ARTICLES_LIMIT artículos después de enviar
if len(self._notified_articles) > NOTIFIED_ARTICLES_LIMIT:

View File

@@ -3,7 +3,13 @@ import yaml
import telegram
import html
import telegram.ext
from telegram.ext import CommandHandler, CallbackQueryHandler
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
import os
import json
import logging
import threading
from datetime import datetime
ITEM_HTML = """
<b>Resultados para:</b> {search_name}
@@ -20,8 +26,11 @@ ITEM_HTML = """
<a href="https://es.wallapop.com/item/{url}">Ir al anuncio</a>
"""
FAVORITES_FILE = "favorites.json"
class TelegramManager:
def __init__(self):
self.logger = logging.getLogger(__name__)
token, channel, chat_id = self.get_config()
self._channel = channel
self._chat_id = chat_id
@@ -36,10 +45,20 @@ class TelegramManager:
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
# Inicializar archivo de favoritos
self._favorites_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), FAVORITES_FILE)
self._ensure_favorites_file()
# Añadir handlers para comandos y callbacks
self._add_handlers()
# Iniciar polling en un thread separado
self._start_polling()
def get_config(self):
base_dir = os.path.dirname(os.path.abspath(__file__))
config_file = os.path.join(base_dir, 'config.yaml')
config_file = os.path.join(os.path.dirname(base_dir), 'config.yaml')
with open(config_file, 'r') as file:
config = yaml.safe_load(file)
token = config['telegram_token']
@@ -50,10 +69,10 @@ class TelegramManager:
def escape_html(self, text):
return html.escape(str(text))
def send_telegram_article(self, search_name, article):
self._loop.run_until_complete(self.send_telegram_article_async(search_name, article))
def send_telegram_article(self, search_name, article, thread_id=None):
self._loop.run_until_complete(self.send_telegram_article_async(search_name, article, thread_id))
async def send_telegram_article_async(self, search_name, article):
async def send_telegram_article_async(self, search_name, article, thread_id=None):
message = ITEM_HTML.format(
search_name=self.escape_html(search_name),
title=self.escape_html(article.get_title()),
@@ -84,7 +103,207 @@ class TelegramManager:
)
)
await self._bot.send_media_group(
chat_id=self._chat_id,
media=media
)
# Enviar el media group
sent_messages = await self._bot.send_media_group(
chat_id=self._channel,
media=media,
message_thread_id=thread_id
)
# Crear botones inline para el primer mensaje del grupo
keyboard = [
[InlineKeyboardButton("⭐ Añadir a favoritos", callback_data=f"fav_{article.get_id()}_{search_name}")]
]
reply_markup = InlineKeyboardMarkup(keyboard)
# Enviar un mensaje adicional con los botones (reply al primer mensaje del grupo)
await self._bot.send_message(
chat_id=self._channel,
text="💾 Acciones:",
reply_markup=reply_markup,
reply_to_message_id=sent_messages[0].message_id,
message_thread_id=thread_id
)
def _ensure_favorites_file(self):
"""Crea el archivo de favoritos si no existe"""
if not os.path.exists(self._favorites_file):
with open(self._favorites_file, 'w') as f:
json.dump([], f)
self.logger.info(f"Archivo de favoritos creado: {self._favorites_file}")
def _load_favorites(self):
"""Carga los favoritos desde el archivo JSON"""
try:
with open(self._favorites_file, 'r') as f:
return json.load(f)
except Exception as e:
self.logger.error(f"Error al cargar favoritos: {e}")
return []
def _save_favorites(self, favorites):
"""Guarda los favoritos en el archivo JSON"""
try:
with open(self._favorites_file, 'w') as f:
json.dump(favorites, f, indent=2, ensure_ascii=False)
self.logger.info(f"Favoritos guardados: {len(favorites)} items")
except Exception as e:
self.logger.error(f"Error al guardar favoritos: {e}")
def _add_handlers(self):
"""Añade los handlers para comandos y callbacks"""
self._application.add_handler(CommandHandler("favs", self.handle_favs_command))
self._application.add_handler(CallbackQueryHandler(self.handle_favorite_callback, pattern="^fav_"))
self._application.add_handler(CallbackQueryHandler(self.handle_unfavorite_callback, pattern="^unfav_"))
self.logger.info("Handlers de comandos y callbacks añadidos")
def _start_polling(self):
"""Inicia el bot en modo polling en un thread separado"""
def run_polling():
try:
self.logger.info("Iniciando polling de Telegram bot...")
# Crear un nuevo event loop para este thread
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Iniciar polling
loop.run_until_complete(self._application.initialize())
loop.create_task(self._application.start())
loop.create_task(self._application.updater.start_polling(allowed_updates=["message", "callback_query"]))
loop.run_forever()
except Exception as e:
self.logger.error(f"Error en polling: {e}")
polling_thread = threading.Thread(target=run_polling, daemon=True)
polling_thread.start()
self.logger.info("Thread de polling iniciado")
async def handle_favorite_callback(self, update: telegram.Update, context: telegram.ext.ContextTypes.DEFAULT_TYPE):
"""Maneja el callback cuando se presiona el botón de favoritos"""
query = update.callback_query
await query.answer()
# Extraer el ID del artículo y el nombre de búsqueda del callback_data
callback_data = query.data
parts = callback_data.split("_", 2) # fav_ID_search_name
if len(parts) < 3:
await query.edit_message_text("❌ Error al procesar favorito")
return
article_id = parts[1]
search_name = parts[2]
# Obtener el mensaje original (el que tiene reply)
original_message = query.message.reply_to_message
if not original_message:
await query.edit_message_text("❌ No se pudo encontrar el mensaje original")
return
# Extraer información del caption
caption = original_message.caption or ""
# Crear el objeto favorito
favorite = {
"id": article_id,
"search_name": search_name,
"caption": caption,
"date_added": datetime.now().isoformat(),
"message_link": f"https://t.me/c/{str(self._channel).replace('-100', '')}/{original_message.message_id}"
}
# Cargar favoritos existentes
favorites = self._load_favorites()
# Verificar si ya existe
if any(fav["id"] == article_id for fav in favorites):
await query.edit_message_text(" Este artículo ya está en favoritos")
return
# Añadir nuevo favorito
favorites.append(favorite)
self._save_favorites(favorites)
# Actualizar el mensaje del botón
new_keyboard = [
[InlineKeyboardButton("✅ En favoritos", callback_data=f"already_fav_{article_id}")],
[InlineKeyboardButton("🗑️ Quitar de favoritos", callback_data=f"unfav_{article_id}")]
]
await query.edit_message_text(
text="💾 Acciones:",
reply_markup=InlineKeyboardMarkup(new_keyboard)
)
self.logger.info(f"Artículo {article_id} añadido a favoritos")
async def handle_favs_command(self, update: telegram.Update, context: telegram.ext.ContextTypes.DEFAULT_TYPE):
"""Maneja el comando /favs para mostrar los favoritos"""
favorites = self._load_favorites()
if not favorites:
await update.message.reply_text("📭 No tienes favoritos guardados aún")
return
# Crear mensaje con lista de favoritos
message = f"⭐ <b>Tus Favoritos ({len(favorites)})</b>\n\n"
for idx, fav in enumerate(favorites, 1):
# Extraer título del caption (está después de "Artículo:")
caption_lines = fav["caption"].split("\n")
title = "Sin título"
for line in caption_lines:
if line.startswith("<b>Artículo:</b>"):
title = line.replace("<b>Artículo:</b>", "").strip()
break
message += f"{idx}. {title}\n"
message += f" 📂 Búsqueda: <i>{fav['search_name']}</i>\n"
message += f" 🔗 <a href='{fav['message_link']}'>Ver mensaje</a>\n\n"
# Dividir el mensaje si es muy largo
if len(message) > 4000:
chunks = [message[i:i+4000] for i in range(0, len(message), 4000)]
for chunk in chunks:
await update.message.reply_text(chunk, parse_mode="HTML", disable_web_page_preview=True)
else:
await update.message.reply_text(message, parse_mode="HTML", disable_web_page_preview=True)
async def handle_unfavorite_callback(self, update: telegram.Update, context: telegram.ext.ContextTypes.DEFAULT_TYPE):
"""Maneja el callback cuando se presiona el botón de quitar de favoritos"""
query = update.callback_query
await query.answer()
# Extraer el ID del artículo del callback_data
callback_data = query.data
parts = callback_data.split("_", 1) # unfav_ID
if len(parts) < 2:
await query.edit_message_text("❌ Error al procesar")
return
article_id = parts[1]
# Cargar favoritos existentes
favorites = self._load_favorites()
# Buscar y eliminar el favorito
original_count = len(favorites)
favorites = [fav for fav in favorites if fav["id"] != article_id]
if len(favorites) == original_count:
await query.edit_message_text(" Este artículo no estaba en favoritos")
return
# Guardar favoritos actualizados
self._save_favorites(favorites)
# Restaurar el botón original
keyboard = [
[InlineKeyboardButton("⭐ Añadir a favoritos", callback_data=f"fav_{article_id}_unknown")]
]
await query.edit_message_text(
text="💾 Acciones:",
reply_markup=InlineKeyboardMarkup(keyboard)
)
self.logger.info(f"Artículo {article_id} eliminado de favoritos")

View File

@@ -4,6 +4,7 @@ import logging
from datalayer.wallapop_article import WallapopArticle
import traceback
REQUEST_SLEEP_TIME = 30
REQUEST_RETRY_TIME = 5
ERROR_SLEEP_TIME = 60
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36'
@@ -121,7 +122,7 @@ class Worker:
for article in articles:
if self._meets_item_conditions(article):
try:
self._queue_manager.add_to_queue(article, self._item_monitoring.get_name())
self._queue_manager.add_to_queue(article, self._item_monitoring.get_name(), self._item_monitoring.get_thread_id())
except Exception as e:
self.logger.error(f"{self._item_monitoring.get_name()} worker crashed: {e}")
time.sleep(self._item_monitoring.get_check_every())