diff --git a/royalgames.py b/royalgames.py index a0596b8c..e9fc4423 100644 --- a/royalgames.py +++ b/royalgames.py @@ -1,7 +1,7 @@ import os import asyncio import logging -from royalnet.bots import DiscordBot, DiscordConfig +from royalnet.bots import DiscordBot, DiscordConfig, TelegramBot, TelegramConfig from royalnet.commands import * from royalnet.commands.debug_create import DebugCreateCommand from royalnet.commands.error_handler import ErrorHandlerCommand @@ -12,8 +12,12 @@ from royalnet.database.tables import Royal, Telegram, Discord loop = asyncio.get_event_loop() log = logging.root -log.addHandler(logging.StreamHandler()) +stream_handler = logging.StreamHandler() +stream_handler.formatter = logging.Formatter("{asctime}\t{name}\t{levelname}\t{message}", style="{") +log.addHandler(stream_handler) logging.getLogger("royalnet.bots.generic").setLevel(logging.DEBUG) +logging.getLogger("royalnet.bots.discord").setLevel(logging.DEBUG) +logging.getLogger("royalnet.bots.telegram").setLevel(logging.DEBUG) commands = [PingCommand, ShipCommand, SmecdsCommand, ColorCommand, CiaoruoziCommand, DebugCreateCommand, SyncCommand, AuthorCommand, DiarioCommand, RageCommand, DateparserCommand, ReminderCommand, KvactiveCommand, KvCommand, @@ -27,7 +31,13 @@ ds_bot = DiscordBot(discord_config=DiscordConfig(os.environ["DS_AK"]), database_config=DatabaseConfig(os.environ["DB_PATH"], Royal, Discord, "discord_id"), commands=commands, error_command=ErrorHandlerCommand) +tg_bot = TelegramBot(telegram_config=TelegramConfig(os.environ["TG_AK"]), + royalnet_config=RoyalnetConfig(f"ws://{address}:{port}", "sas"), + database_config=DatabaseConfig(os.environ["DB_PATH"], Royal, Telegram, "tg_id"), + commands=commands, + error_command=ErrorHandlerCommand) loop.run_until_complete(master.run()) +loop.create_task(tg_bot.run()) loop.create_task(ds_bot.run()) print("Starting loop...") loop.run_forever() diff --git a/royalnet/bots/__init__.py b/royalnet/bots/__init__.py index d229f4c2..59c8fbc1 100644 --- a/royalnet/bots/__init__.py +++ b/royalnet/bots/__init__.py @@ -1,4 +1,4 @@ -from .telegram import TelegramBot +from .telegram import TelegramBot, TelegramConfig from .discord import DiscordBot, DiscordConfig -__all__ = ["TelegramBot", "DiscordBot", "DiscordConfig"] +__all__ = ["TelegramBot", "TelegramConfig", "DiscordBot", "DiscordConfig"] diff --git a/royalnet/bots/discord.py b/royalnet/bots/discord.py index da51b9c5..8f02262a 100644 --- a/royalnet/bots/discord.py +++ b/royalnet/bots/discord.py @@ -6,7 +6,7 @@ from .generic import GenericBot from ..commands import NullCommand from ..utils import asyncify, Call, Command from ..error import UnregisteredError, NoneFoundError, TooManyFoundError, InvalidConfigError -from ..network import Message, RequestError, RoyalnetConfig +from ..network import Message, RoyalnetConfig from ..database import DatabaseConfig from ..audio import PlayMode, Playlist @@ -27,9 +27,12 @@ class DiscordBot(GenericBot): interface_name = "discord" def _init_voice(self): + log.debug(f"Creating music_data dict") self.music_data: typing.Dict[discord.Guild, PlayMode] = {} def _call_factory(self) -> typing.Type[Call]: + log.debug(f"Creating DiscordCall") + # noinspection PyMethodParameters class DiscordCall(Call): interface_name = self.interface_name @@ -56,9 +59,8 @@ class DiscordBot(GenericBot): async def net_request(call, message: Message, destination: str): if self.network is None: raise InvalidConfigError("Royalnet is not enabled on this bot") - response = await self.network.request(message, destination) - if isinstance(response, RequestError): - raise response.exc + response: Message = await self.network.request(message, destination) + response.raise_on_error() return response async def get_author(call, error_if_none=False): @@ -77,12 +79,15 @@ class DiscordBot(GenericBot): def _bot_factory(self) -> typing.Type[discord.Client]: """Create a new DiscordClient class based on this DiscordBot.""" + log.debug(f"Creating DiscordClient") + # noinspection PyMethodParameters class DiscordClient(discord.Client): async def vc_connect_or_move(cli, channel: discord.VoiceChannel): # Connect to voice chat try: await channel.connect() + log.debug(f"Connecting to Voice in {channel}") except discord.errors.ClientException: # Move to the selected channel, instead of connecting # noinspection PyUnusedLocal @@ -91,8 +96,10 @@ class DiscordBot(GenericBot): if voice_client.guild != channel.guild: continue await voice_client.move_to(channel) + log.debug(f"Moved {voice_client} to {channel}") # Create a music_data entry, if it doesn't exist; default is a Playlist if not self.music_data.get(channel.guild): + log.debug(f"Creating music_data for {channel.guild}") self.music_data[channel.guild] = Playlist() @staticmethod # Not really static because of the self reference @@ -157,17 +164,21 @@ class DiscordBot(GenericBot): def _init_client(self): """Create a bot instance.""" - self.client = self._bot_factory()() + log.debug(f"Creating DiscordClient instance") + self._Client = self._bot_factory() + self.client = self._Client() def __init__(self, *, discord_config: DiscordConfig, - royalnet_config: RoyalnetConfig, + royalnet_config: typing.Optional[RoyalnetConfig] = None, database_config: typing.Optional[DatabaseConfig] = None, + command_prefix: str = "!", commands: typing.List[typing.Type[Command]] = None, missing_command: typing.Type[Command] = NullCommand, error_command: typing.Type[Command] = NullCommand): super().__init__(royalnet_config=royalnet_config, database_config=database_config, + command_prefix=command_prefix, commands=commands, missing_command=missing_command, error_command=error_command) @@ -176,7 +187,9 @@ class DiscordBot(GenericBot): self._init_voice() async def run(self): + log.debug(f"Logging in to Discord") await self.client.login(self._discord_config.token) + log.debug(f"Connecting to Discord") await self.client.connect() # TODO: how to stop? diff --git a/royalnet/bots/generic.py b/royalnet/bots/generic.py index e38bdd00..411d4d2d 100644 --- a/royalnet/bots/generic.py +++ b/royalnet/bots/generic.py @@ -16,6 +16,7 @@ class GenericBot: interface_name = NotImplemented def _init_commands(self, + command_prefix: str, commands: typing.List[typing.Type[Command]], missing_command: typing.Type[Command], error_command: typing.Type[Command]): @@ -24,7 +25,7 @@ class GenericBot: self.commands: typing.Dict[str, typing.Type[Command]] = {} self.network_handlers: typing.Dict[typing.Type[Message], typing.Type[NetworkHandler]] = {} for command in commands: - self.commands[f"!{command.command_name}"] = command + self.commands[f"{command_prefix}{command.command_name}"] = command self.network_handlers = {**self.network_handlers, **command.network_handler_dict()} self.missing_command: typing.Type[Command] = missing_command self.error_command: typing.Type[Command] = error_command @@ -36,7 +37,7 @@ class GenericBot: def _init_royalnet(self, royalnet_config: RoyalnetConfig): """Create a RoyalnetLink, and run it as a task.""" - self.network: RoyalnetLink = RoyalnetLink(royalnet_config.master_uri, royalnet_config.master_secret, "discord", + self.network: RoyalnetLink = RoyalnetLink(royalnet_config.master_uri, royalnet_config.master_secret, self.interface_name, self._network_handler) log.debug(f"Running RoyalnetLink {self.network}") loop.create_task(self.network.run()) @@ -46,15 +47,17 @@ class GenericBot: log.debug(f"Received {message} from the RoyalnetLink") try: network_handler = self.network_handlers[message.__class__] - except KeyError as exc: + except KeyError: + _, exc, tb = sys.exc_info() log.debug(f"Missing network_handler for {message}") - return RequestError(KeyError("Missing network_handler")) + return RequestError(exc=exc) try: log.debug(f"Using {network_handler} as handler for {message}") - return await getattr(network_handler, self.interface_name)(message) - except Exception as exc: + return await getattr(network_handler, self.interface_name)(self, message) + except Exception: + _, exc, tb = sys.exc_info() log.debug(f"Exception {exc} in {network_handler}") - return RequestError(exc) + return RequestError(exc=exc) def _init_database(self, commands: typing.List[typing.Type[Command]], database_config: DatabaseConfig): """Connect to the database, and create the missing tables required by the selected commands.""" @@ -74,6 +77,7 @@ class GenericBot: def __init__(self, *, royalnet_config: typing.Optional[RoyalnetConfig] = None, database_config: typing.Optional[DatabaseConfig] = None, + command_prefix: str, commands: typing.List[typing.Type[Command]] = None, missing_command: typing.Type[Command] = NullCommand, error_command: typing.Type[Command] = NullCommand): @@ -86,7 +90,7 @@ class GenericBot: self._init_database(commands=commands, database_config=database_config) if commands is None: commands = [] - self._init_commands(commands, missing_command=missing_command, error_command=error_command) + self._init_commands(command_prefix, commands, missing_command=missing_command, error_command=error_command) self._Call = self._call_factory() if royalnet_config is None: self.network = None @@ -95,15 +99,18 @@ class GenericBot: async def call(self, command_name: str, channel, parameters: typing.List[str] = None, **kwargs): """Call a command by its string, or missing_command if it doesn't exists, or error_command if an exception is raised during the execution.""" + log.debug(f"Trying to call {command_name}") if parameters is None: parameters = [] try: command: typing.Type[Command] = self.commands[command_name] except KeyError: + log.debug(f"Calling missing_command because {command_name} does not exist") command = self.missing_command try: await self._Call(channel, command, parameters, **kwargs).run() except Exception as exc: + log.debug(f"Calling error_command because of an error in {command_name}") await self._Call(channel, self.error_command, exception_info=sys.exc_info(), previous_command=command, **kwargs).run() diff --git a/royalnet/bots/telegram.py b/royalnet/bots/telegram.py index f204283f..8a611dc3 100644 --- a/royalnet/bots/telegram.py +++ b/royalnet/bots/telegram.py @@ -3,10 +3,11 @@ import asyncio import typing import logging as _logging import sys +from .generic import GenericBot from ..commands import NullCommand from ..utils import asyncify, Call, Command from ..error import UnregisteredError, InvalidConfigError -from ..network import RoyalnetLink, Message, RequestError +from ..network import RoyalnetLink, Message, RequestError, RoyalnetConfig from ..database import Alchemy, relationshiplinkchain, DatabaseConfig loop = asyncio.get_event_loop() @@ -17,48 +18,25 @@ async def todo(message: Message): log.warning(f"Skipped {message} because handling isn't supported yet.") -class TelegramBot: - def __init__(self, - api_key: str, - master_server_uri: str, - master_server_secret: str, - commands: typing.List[typing.Type[Command]], - missing_command: typing.Type[Command] = NullCommand, - error_command: typing.Type[Command] = NullCommand, - database_config: typing.Optional[DatabaseConfig] = None): - self.bot: telegram.Bot = telegram.Bot(api_key) - self.should_run: bool = False - self.offset: int = -100 - self.missing_command = missing_command - self.error_command = error_command - self.network: RoyalnetLink = RoyalnetLink(master_server_uri, master_server_secret, "telegram", todo) - loop.create_task(self.network.run()) - # Generate _commands - self.commands = {} - required_tables = set() - for command in commands: - self.commands[f"/{command.command_name}"] = command - required_tables = required_tables.union(command.require_alchemy_tables) - # Generate the Alchemy database - if database_config: - self.alchemy = Alchemy(database_config.database_uri, required_tables) - self.master_table = self.alchemy.__getattribute__(database_config.master_table.__name__) - self.identity_table = self.alchemy.__getattribute__(database_config.identity_table.__name__) - self.identity_column = self.identity_table.__getattribute__(self.identity_table, database_config.identity_column_name) - self.identity_chain = relationshiplinkchain(self.master_table, self.identity_table) - else: - if required_tables: - raise InvalidConfigError("Tables are required by the _commands, but Alchemy is not configured") - self.alchemy = None - self.master_table = None - self.identity_table = None - self.identity_column = None - self.identity_chain = None +class TelegramConfig: + def __init__(self, token: str): + self.token: str = token + + +class TelegramBot(GenericBot): + interface_name = "telegram" + + def _init_client(self): + self.client = telegram.Bot(self._telegram_config.token) + self._offset: int = -100 + + def _call_factory(self) -> typing.Type[Call]: # noinspection PyMethodParameters class TelegramCall(Call): - interface_name = "telegram" + interface_name = self.interface_name interface_obj = self interface_prefix = "/" + alchemy = self.alchemy async def reply(call, text: str): @@ -75,9 +53,10 @@ class TelegramBot: await asyncify(call.channel.send_message, escaped_text, parse_mode="HTML") async def net_request(call, message: Message, destination: str): - response = await self.network.request(message, destination) - if isinstance(response, RequestError): - raise response.exc + if self.network is None: + raise InvalidConfigError("Royalnet is not enabled on this bot") + response: Message = await self.network.request(message, destination) + response.raise_on_error() return response async def get_author(call, error_if_none=False): @@ -94,29 +73,26 @@ class TelegramBot: result = await asyncify(query.one_or_none) if result is None and error_if_none: raise UnregisteredError("Author is not registered") - return result + return TelegramCall - self.TelegramCall = TelegramCall + def __init__(self, *, + telegram_config: TelegramConfig, + royalnet_config: typing.Optional[RoyalnetConfig] = None, + database_config: typing.Optional[DatabaseConfig] = None, + command_prefix: str = "/", + commands: typing.List[typing.Type[Command]] = None, + missing_command: typing.Type[Command] = NullCommand, + error_command: typing.Type[Command] = NullCommand): + super().__init__(royalnet_config=royalnet_config, + database_config=database_config, + command_prefix=command_prefix, + commands=commands, + missing_command=missing_command, + error_command=error_command) + self._telegram_config = telegram_config + self._init_client() - async def run(self): - self.should_run = True - while self.should_run: - # Get the latest 100 updates - try: - last_updates: typing.List[telegram.Update] = await asyncify(self.bot.get_updates, offset=self.offset, timeout=60) - except telegram.error.TimedOut: - continue - # Handle updates - for update in last_updates: - # noinspection PyAsyncCall - asyncio.create_task(self.handle_update(update)) - # Recalculate offset - try: - self.offset = last_updates[-1].update_id + 1 - except IndexError: - pass - - async def handle_update(self, update: telegram.Update): + async def _handle_update(self, update: telegram.Update): # Skip non-message updates if update.message is None: return @@ -130,33 +106,31 @@ class TelegramBot: return # Find and clean parameters command_text, *parameters = text.split(" ") - command_text.replace(f"@{self.bot.username}", "") - # Find the function - try: - command = self.commands[command_text] - except KeyError: - # Skip inexistent _commands - command = self.missing_command + command_text.replace(f"@{self.client.username}", "") # Call the command - # noinspection PyBroadException - try: - return await self.TelegramCall(message.chat, command, parameters, log, - update=update).run() - except Exception as exc: - try: - return await self.TelegramCall(message.chat, self.error_command, parameters, log, - update=update, - exception_info=sys.exc_info(), - previous_command=command).run() - except Exception as exc2: - log.error(f"Exception in error handler command: {exc2}") + await self.call(command_text, update.message.chat, parameters, update=update) - def generate_botfather_command_string(self): + async def run(self): + while True: + # Get the latest 100 updates + try: + last_updates: typing.List[telegram.Update] = await asyncify(self.client.get_updates, offset=self._offset, timeout=60) + except telegram.error.TimedOut: + continue + # Handle updates + for update in last_updates: + # noinspection PyAsyncCall + loop.create_task(self._handle_update(update)) + # Recalculate offset + try: + self._offset = last_updates[-1].update_id + 1 + except IndexError: + pass + + @property + def botfather_command_string(self) -> str: string = "" for command_key in self.commands: command = self.commands[command_key] string += f"{command.command_name} - {command.command_description}\n" return string - - async def handle_net_request(self, message: Message): - pass diff --git a/royalnet/commands/error_handler.py b/royalnet/commands/error_handler.py index 7ef38ef2..27bfd3e2 100644 --- a/royalnet/commands/error_handler.py +++ b/royalnet/commands/error_handler.py @@ -1,5 +1,4 @@ import logging as _logging -import traceback from ..utils import Command, Call from ..error import NoneFoundError, \ TooManyFoundError, \ @@ -21,33 +20,28 @@ class ErrorHandlerCommand(Command): @classmethod async def common(cls, call: Call): - try: - e_type, e_value, e_tb = call.kwargs["exception_info"] - except InvalidInputError: - await call.reply("⚠️ Questo comando non può essere chiamato da solo.") - return - if e_type == NoneFoundError: + exception: Exception = call.kwargs["exception"] + if isinstance(exception, NoneFoundError): await call.reply("⚠️ L'elemento richiesto non è stato trovato.") return - if e_type == TooManyFoundError: + if isinstance(exception, TooManyFoundError): await call.reply("⚠️ La richiesta effettuata è ambigua, pertanto è stata annullata.") return - if e_type == UnregisteredError: + if isinstance(exception, UnregisteredError): await call.reply("⚠️ Devi essere registrato a Royalnet per usare questo comando!") return - if e_type == UnsupportedError: + if isinstance(exception, UnsupportedError): await call.reply("⚠️ Il comando richiesto non è disponibile tramite questa interfaccia.") return - if e_type == InvalidInputError: + if isinstance(exception, InvalidInputError): command = call.kwargs["previous_command"] await call.reply(f"⚠️ Sintassi non valida.\nSintassi corretta: [c]{call.interface_prefix}{command.command_name} {command.command_syntax}[/c]") return - if e_type == InvalidConfigError: + if isinstance(exception, InvalidConfigError): await call.reply("⚠️ Il bot non è stato configurato correttamente, quindi questo comando non può essere eseguito. L'errore è stato segnalato all'amministratore.") return - if e_type == ExternalError: + if isinstance(exception, ExternalError): await call.reply("⚠️ Una risorsa esterna necessaria per l'esecuzione del comando non ha funzionato correttamente, quindi il comando è stato annullato.") return - await call.reply(f"❌ Eccezione non gestita durante l'esecuzione del comando:\n[b]{e_type.__name__}[/b]\n{e_value}") - formatted_tb: str = '\n'.join(traceback.format_tb(e_tb)) - log.error(f"Unhandled exception - {e_type.__name__}: {e_value}\n{formatted_tb}") + await call.reply(f"❌ Eccezione non gestita durante l'esecuzione del comando:\n[b]{exception.__class__.__name__}[/b]\n{exception}") + log.error(f"Unhandled exception - {exception.__class__.__name__}: {exception}") diff --git a/royalnet/commands/play.py b/royalnet/commands/play.py index 6f37d076..977bb64a 100644 --- a/royalnet/commands/play.py +++ b/royalnet/commands/play.py @@ -50,6 +50,5 @@ class PlayCommand(Command): @classmethod async def common(cls, call: Call): guild, url = call.args.match(r"(?:\[(.+)])?\s*(\S+)\s*") - response: typing.Union[RequestSuccessful, RequestError] = await call.net_request(PlayMessage(url, guild), "discord") - response.raise_on_error() + response: RequestSuccessful = await call.net_request(PlayMessage(url, guild), "discord") await call.reply(f"✅ Richiesta la riproduzione di [c]{url}[/c].") diff --git a/royalnet/commands/summon.py b/royalnet/commands/summon.py index dc301de0..27fc4240 100644 --- a/royalnet/commands/summon.py +++ b/royalnet/commands/summon.py @@ -14,7 +14,7 @@ loop = asyncio.get_event_loop() class SummonMessage(Message): def __init__(self, channel_identifier: typing.Union[int, str], guild_identifier: typing.Optional[typing.Union[int, str]] = None): - self.channel_identifier = channel_identifier + self.channel_name = channel_identifier self.guild_identifier = guild_identifier @@ -24,7 +24,7 @@ class SummonNH(NetworkHandler): @classmethod async def discord(cls, bot: "DiscordBot", message: SummonMessage): """Handle a summon Royalnet request. That is, join a voice channel, or move to a different one if that is not possible.""" - channel = bot.client.find_channel(message.channel_identifier) + channel = bot.client.find_channel_by_name(message.channel_name) if not isinstance(channel, discord.VoiceChannel): raise NoneFoundError("Channel is not a voice channel") loop.create_task(bot.client.vc_connect_or_move(channel)) diff --git a/royalnet/error.py b/royalnet/error.py index 206ad0aa..249011b4 100644 --- a/royalnet/error.py +++ b/royalnet/error.py @@ -22,5 +22,12 @@ class InvalidConfigError(Exception): """The bot has not been configured correctly, therefore the command can not function.""" +class RoyalnetError(Exception): + """An error was raised while handling the Royalnet request. + This exception contains the exception that was raised during the handling.""" + def __init__(self, exc): + self.exc = exc + + class ExternalError(Exception): """Something went wrong in a non-Royalnet component and the command execution cannot be completed.""" diff --git a/royalnet/network/messages.py b/royalnet/network/messages.py index d63b9c20..fbc2747a 100644 --- a/royalnet/network/messages.py +++ b/royalnet/network/messages.py @@ -1,3 +1,7 @@ +import traceback +from ..error import RoyalnetError + + class Message: def __repr__(self): return f"<{self.__class__.__name__}>" @@ -34,7 +38,7 @@ class RequestSuccessful(Message): class RequestError(Message): def __init__(self, exc: Exception): - self.exc = exc + self.exc: Exception = exc def raise_on_error(self): - raise self.exc + raise RoyalnetError(exc=self.exc)