diff --git a/.gitignore b/.gitignore
index 38fcba4..6d7b980 100644
--- a/.gitignore
+++ b/.gitignore
@@ -159,4 +159,5 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
-config.yaml
\ No newline at end of file
+config.yaml
+favorites.json
\ No newline at end of file
diff --git a/README.md b/README.md
index a1dc6aa..723e91a 100644
--- a/README.md
+++ b/README.md
@@ -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 🚀
diff --git a/datalayer/item_monitor.py b/datalayer/item_monitor.py
index 512abd2..5e36b05 100644
--- a/datalayer/item_monitor.py
+++ b/datalayer/item_monitor.py
@@ -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
\ No newline at end of file
+ return self._check_every
+
+ def get_thread_id(self):
+ return self._thread_id
\ No newline at end of file
diff --git a/managers/queue_manager.py b/managers/queue_manager.py
index 18aa9f8..77c1cdc 100644
--- a/managers/queue_manager.py
+++ b/managers/queue_manager.py
@@ -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:
diff --git a/managers/telegram_manager.py b/managers/telegram_manager.py
index 6594186..a18a157 100644
--- a/managers/telegram_manager.py
+++ b/managers/telegram_manager.py
@@ -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 = """
Resultados para: {search_name}
@@ -20,8 +26,11 @@ ITEM_HTML = """
Ir al anuncio
"""
+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
- )
\ No newline at end of file
+ # 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"⭐ Tus Favoritos ({len(favorites)})\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("Artículo:"):
+ title = line.replace("Artículo:", "").strip()
+ break
+
+ message += f"{idx}. {title}\n"
+ message += f" 📂 Búsqueda: {fav['search_name']}\n"
+ message += f" 🔗 Ver mensaje\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")
\ No newline at end of file
diff --git a/managers/worker.py b/managers/worker.py
index a8bafa4..2215f67 100644
--- a/managers/worker.py
+++ b/managers/worker.py
@@ -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())