inline keyboard and move to channel with threads
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||
29
README.md
29
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 🚀
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user