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())