diff --git a/royalnet/backpack/commands/__init__.py b/royalnet/backpack/commands/__init__.py index 39e16489..661ed137 100644 --- a/royalnet/backpack/commands/__init__.py +++ b/royalnet/backpack/commands/__init__.py @@ -1,11 +1,13 @@ # Imports go here! from .ping import PingCommand from .version import VersionCommand +from .summon import SummonCommand # Enter the commands of your Pack here! available_commands = [ PingCommand, - VersionCommand + VersionCommand, + SummonCommand, ] # Don't change this, it should automatically generate __all__ diff --git a/royalnet/backpack/commands/summon.py b/royalnet/backpack/commands/summon.py new file mode 100644 index 00000000..6c40f059 --- /dev/null +++ b/royalnet/backpack/commands/summon.py @@ -0,0 +1,54 @@ +from royalnet.commands import * +from typing import TYPE_CHECKING, Optional, List +import asyncio + +try: + import discord +except ImportError: + discord = None + +if TYPE_CHECKING: + from royalnet.serf.discord import DiscordSerf + + +class SummonCommand(Command): + name: str = "summon" + + description = "Connect the bot to a Discord voice channel." + + syntax = "[channelname]" + + async def run(self, args: CommandArgs, data: CommandData) -> None: + # This command only runs on Discord! + if self.interface.name != "discord": + raise UnsupportedError() + # noinspection PyUnresolvedReferences + message: discord.Message = data.message + serf: DiscordSerf = self.interface.bot + channel_name: Optional[str] = args.joined() + # If the channel name was passed as an argument... + if channel_name != "": + # Try to find the specified channel + channels: List[discord.abc.GuildChannel] = serf.client.find_channel(channel_name) + # TODO: if there are multiple channels, try to find the most appropriate one + # TODO: ensure that the channel is a voice channel + if len(channels) != 1: + raise CommandError("Couldn't decide on a channel to connect to.") + else: + channel = channels[0] + else: + # Try to use the channel in which the command author is in + voice: Optional[discord.VoiceState] = message.author.voice + if voice is None: + raise CommandError("You must be connected to a voice channel to summon the bot without any arguments.") + channel: discord.VoiceChannel = voice.channel + # Try to connect to the voice channel + try: + await channel.connect() + except asyncio.TimeoutError: + raise ExternalError("Timed out while trying to connect to the channel") + except discord.opus.OpusNotLoaded: + raise ConfigurationError("[c]libopus[/c] is not loaded in the serf") + except discord.ClientException as e: + # TODO: handle this someway + raise diff --git a/royalnet/commands/__init__.py b/royalnet/commands/__init__.py index 6cbc9c77..5cb361d0 100644 --- a/royalnet/commands/__init__.py +++ b/royalnet/commands/__init__.py @@ -2,7 +2,12 @@ from .commandinterface import CommandInterface from .command import Command from .commanddata import CommandData from .commandargs import CommandArgs -from .errors import CommandError, InvalidInputError, UnsupportedError, KeyboardExpiredError, ConfigurationError +from .errors import CommandError, \ + InvalidInputError, \ + UnsupportedError, \ + ConfigurationError, \ + ExternalError, \ + UserError __all__ = [ "CommandInterface", @@ -12,6 +17,7 @@ __all__ = [ "CommandError", "InvalidInputError", "UnsupportedError", - "KeyboardExpiredError", "ConfigurationError", + "ExternalError", + "UserError", ] diff --git a/royalnet/commands/errors.py b/royalnet/commands/errors.py index fbd5725b..45baa5b3 100644 --- a/royalnet/commands/errors.py +++ b/royalnet/commands/errors.py @@ -9,21 +9,22 @@ class CommandError(Exception): return f"{self.__class__.__qualname__}({repr(self.message)})" -class InvalidInputError(CommandError): - """The command has received invalid input and cannot complete. +class UserError(CommandError): + """The command failed to execute, and the error is because of something that the user did.""" - Display an error message to the user, along with the correct syntax for the command.""" + +class InvalidInputError(UserError): + """The command has received invalid input and cannot complete.""" class UnsupportedError(CommandError): - """A requested feature is not available on this interface. - - Display an error message to the user, telling them to use another interface.""" - - -class KeyboardExpiredError(CommandError): - """A special type of exception that can be raised in keyboard handlers to mark a specific keyboard as expired.""" + """A requested feature is not available on this interface.""" class ConfigurationError(CommandError): - """The command is misconfigured and cannot work.""" + """The command cannot work because of a wrong configuration by part of the Royalnet admin.""" + + +class ExternalError(CommandError): + """The command failed to execute, but the problem was because of an external factor (such as an external API going + down).""" diff --git a/royalnet/serf/discord/discordserf.py b/royalnet/serf/discord/discordserf.py index d03dab23..7c1623c4 100644 --- a/royalnet/serf/discord/discordserf.py +++ b/royalnet/serf/discord/discordserf.py @@ -1,8 +1,7 @@ import asyncio import logging from typing import Type, Optional, List, Union -from royalnet.commands import Command, CommandInterface, CommandData, CommandArgs, CommandError, InvalidInputError, \ - UnsupportedError +from royalnet.commands import * from royalnet.utils import asyncify from .escape import escape from ..serf import Serf @@ -124,25 +123,11 @@ class DiscordSerf(Serf): session = None # Prepare data data = self.Data(interface=command.interface, session=session, loop=self.loop, message=message) - try: - # Run the command - await command.run(CommandArgs(parameters), data) - except InvalidInputError as e: - await data.reply(f":warning: {e.message}\n" - f"Syntax: [c]/{command.name} {command.syntax}[/c]") - except UnsupportedError as e: - await data.reply(f":warning: {e.message}") - except CommandError as e: - await data.reply(f":warning: {e.message}") - except Exception as e: - self.sentry_exc(e) - error_message = f"🦀 [b]{e.__class__.__name__}[/b] 🦀\n" \ - '\n'.join(e.args) - await data.reply(error_message) - finally: - # Close the alchemy session - if session is not None: - await asyncify(session.close) + # Call the command + self.call(command, data, parameters) + # Close the alchemy session + if session is not None: + await asyncify(session.close) def bot_factory(self) -> Type[discord.Client]: """Create a custom class inheriting from :py:class:`discord.Client`.""" @@ -172,7 +157,7 @@ class DiscordSerf(Serf): def find_channel(cli, name: str, guild: Optional[discord.Guild] = None) -> List[discord.abc.GuildChannel]: - """Find the :class:`TextChannel`, :class:`VoiceChannel` or :class:`CategoryChannel` with the + """Find the :class:`TextChannel`s, :class:`VoiceChannel`s or :class:`CategoryChannel`s with the specified name (case insensitive). You can specify a guild to only search in that specific guild.""" diff --git a/royalnet/serf/serf.py b/royalnet/serf/serf.py index 3529664f..bbf9ec24 100644 --- a/royalnet/serf/serf.py +++ b/royalnet/serf/serf.py @@ -4,7 +4,7 @@ from typing import Type, Optional, Awaitable, Dict, List, Any, Callable, Union, from keyring import get_password from sqlalchemy.schema import Table from royalnet import __version__ as version -from royalnet.commands import Command, CommandInterface, CommandData, CommandError, UnsupportedError +from royalnet.commands import * from .alchemyconfig import AlchemyConfig try: @@ -284,6 +284,29 @@ class Serf: username: the name of the secret that should be retrieved.""" return get_password(f"Royalnet/{self.secrets_name}", username) + def call(self, command: Command, data: CommandData, parameters: List[str]): + try: + # Run the command + await command.run(CommandArgs(parameters), data) + except InvalidInputError as e: + await data.reply(f"⚠️ {e.message}\n" + f"Syntax: [c]{command.interface.prefix}{command.name} {command.syntax}[/c]") + except UserError as e: + await data.reply(f"⚠️ {e.message}") + except UnsupportedError as e: + await data.reply(f"🚫 {e.message}") + except ExternalError as e: + await data.reply(f"🚫 {e.message}") + except ConfigurationError as e: + await data.reply(f"⛔️ {e.message}") + except CommandError as e: + await data.reply(f"⛔️ {e.message}") + except Exception as e: + self.sentry_exc(e) + error_message = f"🦀 [b]{e.__class__.__name__}[/b] 🦀\n" \ + '\n'.join(e.args) + await data.reply(error_message) + async def run(self): """A coroutine that starts the event loop and handles command calls.""" self.herald_task = self.loop.create_task(self.herald.run()) diff --git a/royalnet/serf/telegram/telegramserf.py b/royalnet/serf/telegram/telegramserf.py index 84908346..d152d526 100644 --- a/royalnet/serf/telegram/telegramserf.py +++ b/royalnet/serf/telegram/telegramserf.py @@ -194,27 +194,13 @@ class TelegramSerf(Serf): session = await asyncify(self.alchemy.Session) else: session = None - try: - # Create the command data - data = self.Data(interface=command.interface, session=session, loop=self.loop, update=update) - try: - # Run the command - await command.run(CommandArgs(parameters), data) - except InvalidInputError as e: - await data.reply(f"⚠️ {e.message}\n" - f"Syntax: [c]/{command.name} {command.syntax}[/c]") - except UnsupportedError as e: - await data.reply(f"⚠️ {e.message}") - except CommandError as e: - await data.reply(f"⚠️ {e.message}") - except Exception as e: - self.sentry_exc(e) - error_message = f"🦀 [b]{e.__class__.__name__}[/b] 🦀\n" \ - '\n'.join(e.args) - await data.reply(error_message) - finally: - if session is not None: - await asyncify(session.close) + # Prepare data + data = self.Data(interface=command.interface, session=session, loop=self.loop, message=message) + # Call the command + self.call(command, data, parameters) + # Close the alchemy session + if session is not None: + await asyncify(session.close) async def handle_edited_message(self, update: telegram.Update): pass