diff --git a/royalnet/bots/discord.py b/royalnet/bots/discord.py index 47fccfec..4b9660f7 100644 --- a/royalnet/bots/discord.py +++ b/royalnet/bots/discord.py @@ -127,6 +127,8 @@ class DiscordBot(GenericBot): error_message += '\n'.join(e.args) log.error(f"Error in {command.name}: {error_message}") await data.reply(f"⛔️ {error_message}") + if __debug__: + raise async def on_ready(cli): log.debug("Connection successful, client is ready") diff --git a/royalnet/bots/generic.py b/royalnet/bots/generic.py index d6e0d57a..4380bc43 100644 --- a/royalnet/bots/generic.py +++ b/royalnet/bots/generic.py @@ -154,6 +154,8 @@ class GenericBot: self._init_royalnet(royalnet_config=royalnet_config) except Exception as e: sentry_sdk.capture_exception(e) + log.error(f"{e.__class__.__name__} while initializing Royalnet: {' | '.join(e.args)}") + raise async def run(self): """A blocking coroutine that should make the bot start listening to commands and requests.""" diff --git a/royalnet/bots/telegram.py b/royalnet/bots/telegram.py index 0841df14..fb60b836 100644 --- a/royalnet/bots/telegram.py +++ b/royalnet/bots/telegram.py @@ -67,7 +67,12 @@ class TelegramBot(GenericBot): disable_web_page_preview=True) async def get_author(data, error_if_none=False): - user: telegram.User = data.update.effective_user + if data.update.message is not None: + user: telegram.User = data.update.message.from_user + elif data.update.callback_query is not None: + user: telegram.user = data.update.callback_query.from_user + else: + raise UnregisteredError("Author can not be determined") if user is None: if error_if_none: raise UnregisteredError("No author for this message") @@ -148,6 +153,8 @@ class TelegramBot(GenericBot): error_message = f"⛔️ [b]{e.__class__.__name__}[/b]\n" error_message += '\n'.join(e.args) await data.reply(error_message) + if __debug__: + raise async def _handle_callback_query(self, update: telegram.Update): query: telegram.CallbackQuery = update.callback_query diff --git a/royalnet/commands/royalgames/__init__.py b/royalnet/commands/royalgames/__init__.py index 4cfc4841..8b78fa0b 100644 --- a/royalnet/commands/royalgames/__init__.py +++ b/royalnet/commands/royalgames/__init__.py @@ -23,6 +23,7 @@ from .videochannel import VideochannelCommand from .dnditem import DnditemCommand from .dndspell import DndspellCommand from .trivia import TriviaCommand +from .mm import MmCommand __all__ = [ "CiaoruoziCommand", @@ -44,5 +45,6 @@ __all__ = [ "VideochannelCommand", "DnditemCommand", "DndspellCommand", - "TriviaCommand" + "TriviaCommand", + "MmCommand" ] diff --git a/royalnet/commands/royalgames/mm.py b/royalnet/commands/royalgames/mm.py new file mode 100644 index 00000000..a3a3c6e6 --- /dev/null +++ b/royalnet/commands/royalgames/mm.py @@ -0,0 +1,271 @@ +import typing +import datetime +import dateparser +import os +import telegram +import asyncio +from ..command import Command +from ..commandargs import CommandArgs +from ..commanddata import CommandData +from ...database.tables import MMEvent, MMDecision, MMResponse +from ...error import * +from ...utils import asyncify, telegram_escape, sleep_until + + +class MmCommand(Command): + """Matchmaking command. + + Requires the MM_CHANNEL_ID envvar to be set.""" + name: str = "mm" + + description: str = "Trova giocatori per una partita a qualcosa." + + syntax: str = "[ (data) ] (nomegioco)\n[descrizione]" + + require_alchemy_tables = {MMEvent, MMDecision, MMResponse} + + @staticmethod + def _decision_string(mmevent: MMEvent) -> str: + return f"⚫️ Hai detto che forse parteciperai a [b]{mmevent.title}[/b] alle [b]{mmevent.datetime.strftime('%H:%M')}[/b].\n" \ + f"Confermi di volerci essere?" + + @staticmethod + def _decision_keyboard(mmevent: MMEvent) -> telegram.InlineKeyboardMarkup: + return telegram.InlineKeyboardMarkup([ + [telegram.InlineKeyboardButton("🔵 Ci sarò!", callback_data=f"mm_{mmevent.mmid}_d_YES"), + telegram.InlineKeyboardButton("🔴 Non mi interessa...", callback_data=f"mm_{mmevent.mmid}_d_NO")] + ]) + + @staticmethod + def _response_string(mmevent: MMEvent, count: int = 0) -> str: + if count == 0: + return f"🚩 E' ora di [b]{mmevent.title}[/b]!\n" \ + f"Sei pronto?" + else: + return f"🕒 Sei in ritardo di [b]{count * 5}[/b] minuti per [b]{mmevent.title}[/b]...\n" \ + f"Sei pronto?" + + @staticmethod + def _response_keyboard(mmevent: MMEvent) -> telegram.InlineKeyboardMarkup: + return telegram.InlineKeyboardMarkup([ + [telegram.InlineKeyboardButton("✅ Ci sono!", callback_data=f"mm_{mmevent.mmid}_r_YES")], + [telegram.InlineKeyboardButton("🕒 Aspettatemi 5 minuti!", callback_data=f"mm_{mmevent.mmid}_r_LATER")], + [telegram.InlineKeyboardButton("❌ Non ci sono più, mi spiace.", callback_data=f"mm_{mmevent.mmid}_r_NO")] + ]) + + async def _update_message(self, mmevent: MMEvent) -> None: + client: telegram.Bot = self.interface.bot.client + try: + await asyncify(client.edit_message_text, + text=telegram_escape(str(mmevent)), + chat_id=os.environ["MM_CHANNEL_ID"], + message_id=mmevent.message_id, + parse_mode="HTML", + disable_web_page_preview=True, + reply_markup=mmevent.main_keyboard()) + except telegram.error.BadRequest: + pass + + async def run(self, args: CommandArgs, data: CommandData) -> None: + if self.interface.name != "telegram": + raise UnsupportedError("mm is supported only on Telegram") + client: telegram.Bot = self.interface.bot.client + creator = await data.get_author(error_if_none=True) + timestring, title, description = args.match(r"\[\s*([^]]+)\s*]\s*([^\n]+)\s*\n?\s*(.+)?\s*") + + try: + dt: typing.Optional[datetime.datetime] = dateparser.parse(timestring) + except OverflowError: + dt = None + if dt is None: + await data.reply("⚠️ La data che hai specificato non è valida.") + return + if dt <= datetime.datetime.now(): + await data.reply("⚠️ La data che hai specificato è nel passato.") + return + + mmevent: MMEvent = self.interface.alchemy.MMEvent(creator=creator, + datetime=dt, + title=title, + description=description, + state="WAITING") + self.interface.session.add(mmevent) + await asyncify(self.interface.session.commit) + + async def decision_yes(data: CommandData): + royal = await data.get_author() + mmdecision: MMDecision = await asyncify(self.interface.session.query(self.interface.alchemy.MMDecision).filter_by(mmevent=mmevent, royal=royal).one_or_none) + if mmdecision is None: + mmdecision: MMDecision = self.interface.alchemy.MMDecision(royal=royal, + mmevent=mmevent, + decision="YES") + self.interface.session.add(mmdecision) + else: + mmdecision.decision = "YES" + # Can't asyncify this + self.interface.session.commit() + await self._update_message(mmevent) + return "🔵 Hai detto che ci sarai!" + + async def decision_maybe(data: CommandData): + royal = await data.get_author() + mmdecision: MMDecision = await asyncify(self.interface.session.query(self.interface.alchemy.MMDecision).filter_by(mmevent=mmevent, royal=royal).one_or_none) + if mmdecision is None: + mmdecision: MMDecision = self.interface.alchemy.MMDecision(royal=royal, + mmevent=mmevent, + decision="MAYBE") + self.interface.session.add(mmdecision) + else: + mmdecision.decision = "MAYBE" + # Can't asyncify this + self.interface.session.commit() + await self._update_message(mmevent) + return "⚫️ Hai detto che forse ci sarai. Rispondi al messaggio di conferma 5 minuti prima dell'inizio!" + + async def decision_no(data: CommandData): + royal = await data.get_author() + mmdecision: MMDecision = await asyncify(self.interface.session.query(self.interface.alchemy.MMDecision).filter_by(mmevent=mmevent, royal=royal).one_or_none) + if mmdecision is None: + mmdecision: MMDecision = self.interface.alchemy.MMDecision(royal=royal, + mmevent=mmevent, + decision="NO") + self.interface.session.add(mmdecision) + else: + mmdecision.decision = "NO" + # Can't asyncify this + self.interface.session.commit() + await self._update_message(mmevent) + return "🔴 Hai detto che non ti interessa." + + self.interface.register_keyboard_key(f"mm_{mmevent.mmid}_d_YES", decision_yes) + self.interface.register_keyboard_key(f"mm_{mmevent.mmid}_d_MAYBE", decision_maybe) + self.interface.register_keyboard_key(f"mm_{mmevent.mmid}_d_NO", decision_no) + + message: telegram.Message = await asyncify(client.send_message, + chat_id=os.environ["MM_CHANNEL_ID"], + text=telegram_escape(str(mmevent)), + parse_mode="HTML", + disable_webpage_preview=True, + reply_markup=mmevent.main_keyboard()) + + mmevent.message_id = message.message_id + # Can't asyncify this + self.interface.session.commit() + + await sleep_until(dt - datetime.timedelta(minutes=10)) + + mmevent.state = "DECISION" + await asyncify(self.interface.session.commit) + for mmdecision in mmevent.decisions: + mmdecision: MMDecision + if mmdecision.decision == "MAYBE": + await asyncify(client.send_message, + chat_id=mmdecision.royal.telegram[0].tg_id, + text=telegram_escape(self._decision_string(mmevent)), + parse_mode="HTML", + disable_webpage_preview=True, + reply_markup=self._decision_keyboard(mmevent)) + await self._update_message(mmevent) + + await sleep_until(dt) + + async def response_yes(data: CommandData): + royal = await data.get_author() + mmresponse: MMResponse = await asyncify( + self.interface.session.query(self.interface.alchemy.MMResponse).filter_by(mmevent=mmevent, royal=royal).one_or_none) + mmresponse.response = "YES" + # Can't asyncify this + self.interface.session.commit() + await self._update_message(mmevent) + return "✅ Sei pronto!" + + async def response_later(data: CommandData): + royal = await data.get_author() + mmresponse: MMResponse = await asyncify( + self.interface.session.query(self.interface.alchemy.MMResponse).filter_by(mmevent=mmevent, royal=royal).one_or_none) + mmresponse.response = "LATER" + # Can't asyncify this + self.interface.session.commit() + await self._update_message(mmevent) + return "🕒 Hai chiesto agli altri di aspettarti 5 minuti." + + async def response_no(data: CommandData): + royal = await data.get_author() + mmresponse: MMResponse = await asyncify( + self.interface.session.query(self.interface.alchemy.MMResponse).filter_by(mmevent=mmevent, royal=royal).one_or_none) + mmresponse.response = "NO" + # Can't asyncify this + self.interface.session.commit() + await self._update_message(mmevent) + return "❌ Hai detto che non ci sarai." + + async def start_now(): + mmevent.state = "STARTED" + for mmresponse in mmevent.responses: + if mmresponse.response is None: + mmresponse.response = "NO" + if mmresponse.response == "LATER": + mmresponse.response = "NO" + await self._update_message(mmevent) + await asyncify(self.interface.session.commit) + self.interface.unregister_keyboard_key(f"mm_{mmevent.mmid}_r_YES") + self.interface.unregister_keyboard_key(f"mm_{mmevent.mmid}_r_LATER") + self.interface.unregister_keyboard_key(f"mm_{mmevent.mmid}_r_NO") + self.interface.unregister_keyboard_key(f"mm_{mmevent.mmid}_start") + + async def start_key(data: CommandData): + royal = await data.get_author() + if royal == creator: + await start_now() + + mmevent.state = "READY_CHECK" + + self.interface.unregister_keyboard_key(f"mm_{mmevent.mmid}_d_YES") + self.interface.unregister_keyboard_key(f"mm_{mmevent.mmid}_d_MAYBE") + self.interface.unregister_keyboard_key(f"mm_{mmevent.mmid}_d_NO") + + for mmdecision in mmevent.decisions: + if mmdecision.decision == "MAYBE": + mmdecision.decision = "NO" + elif mmdecision.decision == "YES": + mmresponse: MMResponse = self.interface.alchemy.MMResponse(royal=mmdecision.royal, mmevent=mmevent) + self.interface.session.add(mmresponse) + await asyncify(self.interface.session.commit) + + self.interface.register_keyboard_key(f"mm_{mmevent.mmid}_r_YES", response_yes) + self.interface.register_keyboard_key(f"mm_{mmevent.mmid}_r_LATER", response_later) + self.interface.register_keyboard_key(f"mm_{mmevent.mmid}_r_NO", response_no) + self.interface.register_keyboard_key(f"mm_{mmevent.mmid}_start", start_key) + + count = 0 + while True: + for mmresponse in mmevent.responses: + # Send messages + if mmresponse.response is None: + await asyncify(client.send_message, + chat_id=mmresponse.royal.telegram[0].tg_id, + text=telegram_escape(self._response_string(mmevent, count=count)), + parse_mode="HTML", + disable_webpage_preview=True, + reply_markup=self._response_keyboard(mmevent)) + await self._update_message(mmevent) + # Wait + await asyncio.sleep(300) + + # Advance cycle + for mmresponse in mmevent.responses: + if mmresponse.response is None: + mmresponse.response = "NO" + if mmresponse.response == "LATER": + mmresponse.response = None + + # Check if the event can start + for mmresponse in mmevent.responses: + if mmresponse.response is None: + break + else: + break + + count += 1 + + await start_now() diff --git a/royalnet/commands/royalgames/reminder.py b/royalnet/commands/royalgames/reminder.py index e79c3e94..bbfe850b 100644 --- a/royalnet/commands/royalgames/reminder.py +++ b/royalnet/commands/royalgames/reminder.py @@ -61,6 +61,9 @@ class ReminderCommand(Command): if date is None: await data.reply("⚠️ La data che hai inserito non è valida.") return + if date <= datetime.datetime.now(): + await data.reply("⚠️ La data che hai specificato è nel passato.") + return await data.reply(f"✅ Promemoria impostato per [b]{date.strftime('%Y-%m-%d %H:%M:%S')}[/b]") if self.interface.name == "telegram": interface_data = pickle.dumps(data.update.effective_chat.id) diff --git a/royalnet/database/tables/__init__.py b/royalnet/database/tables/__init__.py index e4995cc9..891254b4 100644 --- a/royalnet/database/tables/__init__.py +++ b/royalnet/database/tables/__init__.py @@ -13,6 +13,10 @@ from .medalawards import MedalAward from .bios import Bio from .reminders import Reminder from .triviascores import TriviaScore +from .mmdecisions import MMDecision +from .mmevents import MMEvent +from .mmresponse import MMResponse __all__ = ["Royal", "Telegram", "Diario", "Alias", "ActiveKvGroup", "Keyvalue", "Keygroup", "Discord", "WikiPage", - "WikiRevision", "Medal", "MedalAward", "Bio", "Reminder", "TriviaScore"] + "WikiRevision", "Medal", "MedalAward", "Bio", "Reminder", "TriviaScore", "MMDecision", "MMEvent", + "MMResponse"] diff --git a/royalnet/database/tables/mmdecisions.py b/royalnet/database/tables/mmdecisions.py new file mode 100644 index 00000000..12b06cf5 --- /dev/null +++ b/royalnet/database/tables/mmdecisions.py @@ -0,0 +1,36 @@ +from sqlalchemy import Column, \ + Integer, \ + String, \ + ForeignKey +from sqlalchemy.orm import relationship +from sqlalchemy.ext.declarative import declared_attr +from .royals import Royal +from .mmevents import MMEvent + + +class MMDecision: + __tablename__ = "mmdecisions" + + @declared_attr + def royal_id(self): + return Column(Integer, ForeignKey("royals.uid"), primary_key=True) + + @declared_attr + def royal(self): + return relationship("Royal", backref="mmdecisions_taken") + + @declared_attr + def mmevent_id(self): + return Column(Integer, ForeignKey("mmevents.mmid"), primary_key=True) + + @declared_attr + def mmevent(self): + return relationship("MMEvent", backref="decisions") + + @declared_attr + def decision(self): + # Valid decisions are YES, MAYBE or NO + return Column(String, nullable=False) + + def __repr__(self): + return f"" diff --git a/royalnet/database/tables/mmevents.py b/royalnet/database/tables/mmevents.py new file mode 100644 index 00000000..dc6d28e9 --- /dev/null +++ b/royalnet/database/tables/mmevents.py @@ -0,0 +1,113 @@ +import telegram +import typing +from sqlalchemy import Column, \ + Integer, \ + DateTime, \ + String, \ + Text, \ + ForeignKey, \ + BigInteger +from sqlalchemy.orm import relationship +from sqlalchemy.ext.declarative import declared_attr +from .royals import Royal +if typing.TYPE_CHECKING: + from .mmdecisions import MMDecision + from .mmresponse import MMResponse + + +class MMEvent: + __tablename__ = "mmevents" + + @declared_attr + def creator_id(self): + return Column(Integer, ForeignKey("royals.uid"), nullable=False) + + @declared_attr + def creator(self): + return relationship("Royal", backref="mmevents_created") + + @declared_attr + def mmid(self): + return Column(Integer, primary_key=True) + + @declared_attr + def datetime(self): + return Column(DateTime, nullable=False) + + @declared_attr + def title(self): + return Column(String, nullable=False) + + @declared_attr + def description(self): + return Column(Text, nullable=False, default="") + + @declared_attr + def state(self): + # Valid states are WAITING, DECISION, READY_CHECK, STARTED + return Column(String, nullable=False, default="WAITING") + + @declared_attr + def message_id(self): + return Column(BigInteger) + + def __repr__(self): + return f"" + + def __str__(self): + text = f"🌐 [b]{self.title}[/b] - [b]{self.datetime.strftime('%Y-%m-%d %H:%M')}[/b]\n" + if self.description: + text += f"{self.description}\n" + text += "\n" + if self.state == "WAITING" or self.state == "DECISION": + for mmdecision in self.decisions: + mmdecision: "MMDecision" + if mmdecision.decision == "YES": + text += "🔵 " + elif mmdecision.decision == "MAYBE": + text += "⚫️ " + elif mmdecision.decision == "NO": + text += "🔴 " + else: + raise ValueError(f"decision is of an unknown value ({mmdecision.decision})") + text += f"{mmdecision.royal}\n" + elif self.state == "READY_CHECK": + for mmresponse in self.responses: + mmresponse: "MMResponse" + if mmresponse.response is None: + text += "❔ " + elif mmresponse.response == "YES": + text += "✅ " + elif mmresponse.response == "LATER": + text += "🕒 " + elif mmresponse.response == "NO": + text += "❌ " + else: + raise ValueError(f"response is of an unknown value ({mmresponse.response})") + text += f"{mmresponse.royal}\n" + elif self.state == "STARTED": + for mmresponse in self.responses: + if mmresponse.response == "YES": + text += f"✅ {mmresponse.royal}\n" + return text + + def main_keyboard(self) -> typing.Optional[telegram.InlineKeyboardMarkup]: + if self.state == "WAITING": + return telegram.InlineKeyboardMarkup([ + [telegram.InlineKeyboardButton("🔵 Ci sarò!", callback_data=f"mm_{self.mmid}_d_YES")], + [telegram.InlineKeyboardButton("⚫️ Forse...", callback_data=f"mm_{self.mmid}_d_MAYBE")], + [telegram.InlineKeyboardButton("🔴 Non mi interessa.", callback_data=f"mm_{self.mmid}_d_NO")] + ]) + elif self.state == "DECISION": + return telegram.InlineKeyboardMarkup([ + [telegram.InlineKeyboardButton("🔵 Ci sarò!", callback_data=f"mm_{self.mmid}_d_YES"), + telegram.InlineKeyboardButton("🔴 Non mi interessa...", callback_data=f"mm_{self.mmid}_d_NO")] + ]) + elif self.state == "READY_CHECK": + return telegram.InlineKeyboardMarkup([ + [telegram.InlineKeyboardButton("🚩 Avvia la partita", callback_data=f"mm_{self.mmid}_start")] + ]) + elif self.state == "STARTED": + return None + else: + raise ValueError(f"state is of an unknown value ({self.state})") diff --git a/royalnet/database/tables/mmresponse.py b/royalnet/database/tables/mmresponse.py new file mode 100644 index 00000000..60ae3ff9 --- /dev/null +++ b/royalnet/database/tables/mmresponse.py @@ -0,0 +1,36 @@ +from sqlalchemy import Column, \ + Integer, \ + String, \ + ForeignKey +from sqlalchemy.orm import relationship +from sqlalchemy.ext.declarative import declared_attr +from .royals import Royal +from .mmevents import MMEvent + + +class MMResponse: + __tablename__ = "mmresponse" + + @declared_attr + def royal_id(self): + return Column(Integer, ForeignKey("royals.uid"), primary_key=True) + + @declared_attr + def royal(self): + return relationship("Royal", backref="mmresponses_given") + + @declared_attr + def mmevent_id(self): + return Column(Integer, ForeignKey("mmevents.mmid"), primary_key=True) + + @declared_attr + def mmevent(self): + return relationship("MMEvent", backref="responses") + + @declared_attr + def response(self): + # Valid decisions are YES, LATER or NO + return Column(String) + + def __repr__(self): + return f"" diff --git a/royalnet/royalgames.py b/royalnet/royalgames.py index 41d4ed7c..d6bd8c98 100644 --- a/royalnet/royalgames.py +++ b/royalnet/royalgames.py @@ -24,7 +24,6 @@ sentry_dsn = os.environ.get("SENTRY_DSN") # noinspection PyUnreachableCode if __debug__: commands = [ - ] log.setLevel(logging.DEBUG) else: @@ -48,7 +47,8 @@ else: VideochannelCommand, DnditemCommand, DndspellCommand, - TriviaCommand + TriviaCommand, + MmCommand ] log.setLevel(logging.INFO)