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': [],