diff --git a/flanabot/bots/btc_offers_bot.py b/flanabot/bots/btc_offers_bot.py new file mode 100644 index 0000000..d73dd28 --- /dev/null +++ b/flanabot/bots/btc_offers_bot.py @@ -0,0 +1,268 @@ +from __future__ import annotations # todo0 remove when it's by default + +__all__ = ['BtcOffersBot'] + +import asyncio +import functools +import json +import os +from abc import ABC +from collections.abc import Callable + +import aiohttp +import flanautils +from multibot import MultiBot, constants as multibot_constants +from websockets.asyncio import client + +import constants +from flanabot.models import Chat, Message + + +# ---------------------------------------------------- # +# -------------------- DECORATORS -------------------- # +# ---------------------------------------------------- # +def preprocess_btc_offers(func: Callable) -> Callable: + @functools.wraps(func) + async def wrapper(self: BtcOffersBot, message: Message): + if message.chat.is_group and not self.is_bot_mentioned(message): + return + + eur_mode = ( + '€' in message.text + or + bool( + flanautils.cartesian_product_string_matching( + message.text, + constants.KEYWORDS['eur'], + multibot_constants.PARSER_MIN_SCORE_DEFAULT + ) + ) + ) + usd_mode = ( + '$' in message.text + or + bool( + flanautils.cartesian_product_string_matching( + message.text, + constants.KEYWORDS['usd'], + multibot_constants.PARSER_MIN_SCORE_DEFAULT + ) + ) + ) + premium_mode = ( + '%' in message.text + or + bool( + flanautils.cartesian_product_string_matching( + message.text, + constants.KEYWORDS['premium'], + multibot_constants.PARSER_MIN_SCORE_DEFAULT + ) + ) + ) + + if len([arg for arg in (eur_mode, usd_mode, premium_mode) if arg]) > 1: + await self.send_error( + 'Indica únicamente uno de los siguientes: precio en euros, precio en dólares o prima.', + message + ) + return + + parsed_number = flanautils.text_to_number(message.text) + + if parsed_number and not any((eur_mode, usd_mode, premium_mode)): + eur_mode = True + + if eur_mode: + query = {'max_price_eur': parsed_number} + elif usd_mode: + query = {'max_price_usd': parsed_number} + elif premium_mode: + query = {'max_premium': parsed_number} + else: + query = {} + + return await func(self, message, query) + + return wrapper + + +# ---------------------------------------------------------------------------------------------------- # +# ------------------------------------------ BTC_OFFERS_BOT ------------------------------------------ # +# ---------------------------------------------------------------------------------------------------- # +class BtcOffersBot(MultiBot, ABC): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._websocket: client.ClientConnection | None = None + self._notification_task: asyncio.Task[None] | None = None + self._api_endpoint = f"{os.environ['BTC_OFFERS_API_HOST']}:{os.environ['BTC_OFFERS_API_PORT']}/offers" + + # -------------------------------------------------------- # + # ------------------- PROTECTED METHODS ------------------ # + # -------------------------------------------------------- # + def _add_handlers(self): + super()._add_handlers() + + self.register(self._on_btc_offers, keywords=constants.KEYWORDS['offer']) + self.register(self._on_btc_offers, keywords=constants.KEYWORDS['money']) + self.register(self._on_btc_offers, keywords=(constants.KEYWORDS['offer'], constants.KEYWORDS['money'])) + + self.register(self._on_notify_btc_offers, keywords=constants.KEYWORDS['notify']) + self.register(self._on_notify_btc_offers, keywords=(constants.KEYWORDS['notify'], constants.KEYWORDS['offer'])) + self.register(self._on_notify_btc_offers, keywords=(constants.KEYWORDS['notify'], constants.KEYWORDS['money'])) + self.register(self._on_notify_btc_offers, keywords=(constants.KEYWORDS['notify'], constants.KEYWORDS['offer'], constants.KEYWORDS['money'])) + + self.register(self._on_stop_btc_offers_notification, keywords=(multibot_constants.KEYWORDS['deactivate'], constants.KEYWORDS['offer'])) + self.register(self._on_stop_btc_offers_notification, keywords=(multibot_constants.KEYWORDS['deactivate'], constants.KEYWORDS['money'])) + self.register(self._on_stop_btc_offers_notification, keywords=(multibot_constants.KEYWORDS['deactivate'], constants.KEYWORDS['notify'])) + self.register(self._on_stop_btc_offers_notification, keywords=(multibot_constants.KEYWORDS['stop'], constants.KEYWORDS['offer'])) + self.register(self._on_stop_btc_offers_notification, keywords=(multibot_constants.KEYWORDS['stop'], constants.KEYWORDS['money'])) + self.register(self._on_stop_btc_offers_notification, keywords=(multibot_constants.KEYWORDS['stop'], constants.KEYWORDS['notify'])) + + async def _send_offers(self, offers: list[dict], chat: Chat, notifications_disabled=False): + offers_parts = [] + for i, offer in enumerate(offers, start=1): + offer_parts = [ + f'{i}.', + f"Plataforma: {offer['exchange']}", + f"Id: {offer['id']}" + ] + + if offer['author']: + offer_parts.append(f"Autor: {offer['author']}") + + payment_methods_text = ''.join(f'\n {payment_method}' for payment_method in offer['payment_methods']) + + offer_parts.extend( + ( + f"Cantidad: {offer['amount']}", + f"Precio (EUR): {offer['price_eur']:.2f} €", + f"Precio (USD): {offer['price_usd']:.2f} $", + f"Prima: {offer['premium']:.2f} %", + f'Métodos de pago:{payment_methods_text}' + ) + ) + + if offer['description']: + offer_parts.append(f"Descripción:\n{offer['description']}") + + offers_parts.append('\n'.join(offer_parts)) + + offers_parts_chunks = flanautils.chunks(offers_parts, 5) + + messages_parts = [ + [ + '💰💰💰 OFERTAS BTC 💰💰💰', + '', + '\n\n'.join(offers_parts_chunks[0]) + ] + ] + + for offers_parts_chunk in offers_parts_chunks[1:]: + messages_parts.append( + [ + '­', + '\n\n'.join(offers_parts_chunk) + ] + ) + + if notifications_disabled: + messages_parts[-1].extend( + ( + '', + '-' * 70, + 'ℹ️ Los avisos de ofertas de BTC se han desactivado. Si quieres volver a recibirlos, no dudes en pedírmelo.' + ) + ) + + for message_parts in messages_parts: + await self.send('\n'.join(message_parts), chat) + + async def _wait_btc_offers_notification(self): + while True: + data = json.loads(await self._websocket.recv()) + chat = await self.get_chat(data['chat_id']) + chat.btc_offers_max_eur = None + chat.save(pull_exclude_fields=('btc_offers_max_eur',)) + await self._send_offers(data['offers'], chat, notifications_disabled=True) + + # ---------------------------------------------- # + # HANDLERS # + # ---------------------------------------------- # + + @preprocess_btc_offers + async def _on_btc_offers(self, message: Message, query: dict[str, float]): + bot_state_message = await self.send('Obteniendo ofertas BTC...', message) + + async with aiohttp.ClientSession() as session: + async with session.get(f'http://{self._api_endpoint}', params=query) as response: + offers = await response.json() + + if offers: + await self._send_offers(offers, message.chat) + await self.delete_message(bot_state_message) + else: + await self.edit('No hay ofertas BTC actualmente que cumplan esa condición.', bot_state_message) + + @preprocess_btc_offers + async def _on_notify_btc_offers(self, message: Message, query: dict[str, float]): + if message.chat.is_group and not self.is_bot_mentioned(message): + return + + if max_price_eur := query.get('max_price_eur'): + response_text = f'✅ ¡Perfecto! Te avisaré cuando existan ofertas por {max_price_eur:.2f} € o menos.' + else: + async with aiohttp.ClientSession() as session: + async with session.get(constants.YADIO_API_ENDPOINT) as response: + yadio_data = await response.json() + + if max_price_usd := query.get('max_price_usd'): + max_price_eur = max_price_usd / yadio_data['EUR']['USD'] + response_text = f'✅ ¡Perfecto! Te avisaré cuando existan ofertas por {max_price_usd:.2f} $ o menos.' + else: + max_price_eur = yadio_data['BTC'] + query['max_premium'] / 100 * yadio_data['BTC'] + response_text = f"✅ ¡Perfecto! Te avisaré cuando existan ofertas con una prima del {query['max_premium']:.2f} % o menor." + + await self.send(response_text, message) + await self.start_btc_offers_notification(message.chat, max_price_eur) + + async def _on_ready(self): + await super()._on_ready() + + for chat in self.Chat.find({ + 'platform': self.platform.value, + 'btc_offers_max_eur': {'$exists': True, '$ne': None} + }): + chat = await self.get_chat(chat.id) + chat.pull_from_database(overwrite_fields=('_id', 'btc_offers_max_eur')) + await self.start_btc_offers_notification(chat, chat.btc_offers_max_eur) + + async def _on_stop_btc_offers_notification(self, message: Message): + previous_btc_offers_max_eur = message.chat.btc_offers_max_eur + + await self.stop_btc_offers_notification(message.chat) + + if previous_btc_offers_max_eur: + await self.send('🛑 Los avisos de ofertas de BTC se han desactivado.', message) + else: + await self.send('🤔 No existía ningún aviso de ofertas de BTC configurado.', message) + + # -------------------------------------------------------- # + # -------------------- PUBLIC METHODS -------------------- # + # -------------------------------------------------------- # + async def start_btc_offers_notification(self, chat: Chat, max_price_eur: float): + if not self._websocket: + self._websocket = await client.connect(f'ws://{self._api_endpoint}') + self._notification_task = asyncio.create_task(self._wait_btc_offers_notification()) + + chat.btc_offers_max_eur = max_price_eur + chat.save() + await self._websocket.send(json.dumps({'action': 'start', 'chat_id': chat.id, 'max_price_eur': max_price_eur})) + + async def stop_btc_offers_notification(self, chat: Chat): + if not self._websocket: + return + + await self._websocket.send(json.dumps({'action': 'stop', 'chat_id': chat.id})) + chat.btc_offers_max_eur = None + chat.save(pull_exclude_fields=('btc_offers_max_eur',)) diff --git a/flanabot/bots/flana_bot.py b/flanabot/bots/flana_bot.py index 2836782..e73a099 100644 --- a/flanabot/bots/flana_bot.py +++ b/flanabot/bots/flana_bot.py @@ -15,6 +15,7 @@ from flanautils import return_if_first_empty from multibot import BadRoleError, MessagesFormat, MultiBot, Platform, RegisteredCallback, Role, User, bot_mentioned, constants as multibot_constants, group, ignore_self_message, inline, owner from flanabot import constants +from flanabot.bots.btc_offers_bot import BtcOffersBot from flanabot.bots.connect_4_bot import Connect4Bot from flanabot.bots.penalty_bot import PenaltyBot from flanabot.bots.poll_bot import PollBot @@ -28,7 +29,7 @@ from flanabot.models import Action, BotAction, ButtonsGroup, Chat, Message # ----------------------------------------------------------------------------------------------------- # # --------------------------------------------- FLANA_BOT --------------------------------------------- # # ----------------------------------------------------------------------------------------------------- # -class FlanaBot(Connect4Bot, PenaltyBot, PollBot, ScraperBot, SteamBot, UberEatsBot, WeatherBot, MultiBot, ABC): +class FlanaBot(Connect4Bot, BtcOffersBot, PenaltyBot, PollBot, ScraperBot, SteamBot, UberEatsBot, WeatherBot, MultiBot, ABC): Chat = Chat Message = Message diff --git a/flanabot/constants.py b/flanabot/constants.py index b9f57ca..903ec96 100644 --- a/flanabot/constants.py +++ b/flanabot/constants.py @@ -45,6 +45,7 @@ STEAM_REGION_CODE_MAPPING = {'eu': 'EUR', 'ru': 'RUB', 'pk': 'USD', 'ua': 'UAH', 'kz': 'KZT', 'co': 'COP', 'mx': 'MXN', 'qa': 'QAR', 'sg': 'SGD', 'jp': 'JPY', 'uy': 'UYU', 'ae': 'AED', 'kr': 'KRW', 'hk': 'HKD', 'cr': 'CRC', 'nz': 'NZD', 'ca': 'CAD', 'au': 'AUD', 'il': 'ILS', 'us': 'USD', 'no': 'NOK', 'uk': 'GBP', 'pl': 'PLN', 'ch': 'CHF'} +YADIO_API_ENDPOINT = 'https://api.yadio.io/exrates/EUR' BANNED_POLL_PHRASES = ( 'Deja de dar por culo {presser_name} que no puedes votar aqui', @@ -114,16 +115,17 @@ INSULTS = ('._.', 'aha', 'Aléjate de mi.', 'Ante la duda mi dedo corazón te sa KEYWORDS = { 'choose': ('choose', 'elige', 'escoge'), 'connect_4': (('conecta', 'connect', 'ralla', 'raya'), ('4', 'cuatro', 'four')), - 'covid_chart': ('case', 'caso', 'contagiado', 'contagio', 'corona', 'coronavirus', 'covid', 'covid19', 'death', - 'disease', 'enfermedad', 'enfermos', 'fallecido', 'incidencia', 'jacovid', 'mascarilla', 'muerte', - 'muerto', 'pandemia', 'sick', 'virus'), - 'currency_chart': ('argentina', 'bitcoin', 'cardano', 'cripto', 'crypto', 'criptodivisa', 'cryptodivisa', - 'cryptomoneda', 'cryptocurrency', 'currency', 'dinero', 'divisa', 'ethereum', 'inversion', - 'moneda', 'pasta'), 'dice': ('dado', 'dice'), + 'eur': ('eur', 'euro', 'euros', '€'), 'force': ('force', 'forzar', 'fuerza'), + 'money': ('bitcoin', 'btc', 'cripto', 'criptomoneda', 'crypto', 'cryptocurrency', 'currency', 'currency', 'dinero', + 'divisa', 'moneda', 'money', 'precio', 'price', 'satoshi'), 'multiple_answer': ('multi', 'multi-answer', 'multiple', 'multirespuesta'), + 'notify': ('alert', 'alertame', 'alertar', 'avisame', 'avisar', 'aviso', 'inform', 'informame', 'informar', + 'notificacion', 'notificame', 'notificar', 'notification'), + 'offer': ('oferta', 'offer', 'orden', 'order', 'post', 'publicacion'), 'poll': ('encuesta', 'quiz', 'votacion', 'votar', 'voting'), + 'premium': ('%', 'premium', 'prima'), 'punish': ('acaba', 'aprende', 'ataca', 'atalo', 'azota', 'beating', 'boss', 'castiga', 'castigo', 'condena', 'controla', 'destroy', 'destroza', 'duro', 'ejecuta', 'enseña', 'escarmiento', 'execute', 'fuck', 'fusila', 'hell', 'humos', 'infierno', 'jefe', 'jode', 'learn', 'leccion', 'lesson', 'manda', 'paliza', @@ -138,6 +140,7 @@ KEYWORDS = { 'tunnel': ('canal', 'channel', 'tunel', 'tunnel'), 'unpunish': ('absolve', 'forgive', 'innocent', 'inocente', 'perdona', 'spare'), 'until': ('hasta', 'until'), + 'usd': ('$', 'dolar', 'dolares', 'dollar', 'dollars', 'usd'), 'vote': ('vote', 'voto'), 'weather': ('atmosfera', 'atmosferico', 'calle', 'calor', 'caloret', 'clima', 'climatologia', 'cloud', 'cloudless', 'cloudy', 'cold', 'congelar', 'congelado', 'denbora', 'despejado', 'diluvio', 'frio', 'frost', 'hielo', diff --git a/flanabot/models/chat.py b/flanabot/models/chat.py index b4c7f58..8a57c38 100644 --- a/flanabot/models/chat.py +++ b/flanabot/models/chat.py @@ -16,6 +16,7 @@ class Chat(MultiBotChat): 'scraping_delete_original': True, 'ubereats': False }) + btc_offers_max_eur: float | None = None ubereats: dict = field(default_factory=lambda: { 'cookies': [], 'last_codes': [],