diff --git a/royalnet/backpack/__init__.py b/royalnet/backpack/__init__.py index 651cbe4e..b07133a6 100644 --- a/royalnet/backpack/__init__.py +++ b/royalnet/backpack/__init__.py @@ -1,16 +1,19 @@ """A Pack that is imported by default by all :mod:`royalnet` instances.""" -from . import commands, tables, stars +from . import commands, tables, stars, events from .commands import available_commands from .tables import available_tables from .stars import available_page_stars, available_exception_stars +from .events import available_events __all__ = [ "commands", "tables", "stars", + "events", "available_commands", "available_tables", "available_page_stars", "available_exception_stars", + "available_events", ] diff --git a/royalnet/backpack/commands/summon.py b/royalnet/backpack/commands/summon.py index decfc408..fa8d3767 100644 --- a/royalnet/backpack/commands/summon.py +++ b/royalnet/backpack/commands/summon.py @@ -12,6 +12,8 @@ if TYPE_CHECKING: class SummonCommand(Command): + # TODO: possibly move this in another pack + name: str = "summon" description = "Connect the bot to a Discord voice channel." @@ -19,89 +21,13 @@ class SummonCommand(Command): syntax = "[channelname]" async def run(self, args: CommandArgs, data: CommandData) -> None: - # This command only runs on Discord! - if self.interface.name != "discord": - # TODO: use a Herald Event to remotely connect the bot - raise UnsupportedError() - if discord is None: - raise ConfigurationError("'discord' extra is not installed.") - # noinspection PyUnresolvedReferences - message: discord.Message = data.message - member: Union[discord.User, discord.Member] = message.author - serf: DiscordSerf = self.interface.serf - client: discord.Client = serf.client - channel_name: Optional[str] = args.joined() - - # If the channel name was passed as an argument... - if channel_name != "": - # Try to find all possible channels - channels: List[discord.VoiceChannel] = [] - for ch in client.get_all_channels(): - guild: discord.Guild = ch.guild - # Ensure the channel is a voice channel - if not isinstance(ch, discord.VoiceChannel): - continue - # Ensure the channel starts with the requested name - ch_name: str = ch.name - if not ch_name.startswith(channel_name): - continue - # Ensure that the command author can access the channel - if guild.get_member(member.id) is None: - continue - member_permissions: discord.Permissions = ch.permissions_for(member) - if not (member_permissions.connect and member_permissions.speak): - continue - # Ensure that the bot can access the channel - bot_member = guild.get_member(client.user.id) - bot_permissions: discord.Permissions = ch.permissions_for(bot_member) - if not (bot_permissions.connect and bot_permissions.speak): - continue - # Found one! - channels.append(ch) - - # Ensure at least a single channel is returned - if len(channels) == 0: - raise InvalidInputError("Could not find any channel to connect to.") - elif len(channels) == 1: - channel = channels[0] - else: - # Give priority to channels in the current guild - filter_by_guild = False - for ch in channels: - if ch.guild == message.guild: - filter_by_guild = True - break - if filter_by_guild: - new_channels = [] - for ch in channels: - if ch.guild == message.guild: - new_channels.append(ch) - channels = new_channels - - # Give priority to channels with the most people - def people_count(c: discord.VoiceChannel): - return len(c.members) - channels.sort(key=people_count, reverse=True) - - 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 UserError("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: - # The bot is already connected to a voice channel - # TODO: safely move the bot somewhere else - raise CommandError("The bot is already connected in another channel.") - - await data.reply(f"✅ Connected to <#{channel.id}>!") + await self.interface.call_herald_action("discord", "discordvoice", { + "operation": "summon", + "data": { + "channel_name": args.joined() + } + }) + except Exception as e: + breakpoint() + await data.reply(f"✅ Connesso alla chat vocale.") \ No newline at end of file diff --git a/royalnet/backpack/events/__init__.py b/royalnet/backpack/events/__init__.py new file mode 100644 index 00000000..0c59d789 --- /dev/null +++ b/royalnet/backpack/events/__init__.py @@ -0,0 +1,10 @@ +# Imports go here! + + +# Enter the commands of your Pack here! +available_events = [ + +] + +# Don't change this, it should automatically generate __all__ +__all__ = [command.__name__ for command in available_events] diff --git a/royalnet/backpack/events/discordvoice.py b/royalnet/backpack/events/discordvoice.py new file mode 100644 index 00000000..67689cf0 --- /dev/null +++ b/royalnet/backpack/events/discordvoice.py @@ -0,0 +1,113 @@ +import asyncio +from typing import Dict, List, Optional +from royalnet.commands import * +from royalnet.serf import Serf +from royalnet.serf.discord import DiscordSerf +from royalnet.bard import DiscordBard +from royalnet.bard.implementations import * + +try: + import discord +except ImportError: + discord = None + + +class DiscordvoiceEvent(Event): + name: str = "discordvoice" + + def __init__(self, serf: Serf): + super().__init__(serf) + self.bards: Dict["discord.Guild", DiscordBard] = {} + + async def run(self, data: dict): + if not isinstance(self.serf, DiscordSerf): + raise ValueError("`discordvoice` event cannot run on other serfs.") + + operation = data["operation"] + + if operation == "summon": + channel_name: str = data["data"]["channel_name"] + member_id: int = data["data"].get("member_id") + guild_id: int = data["data"].get("guild_id") + client: discord.Client = self.serf.client + + # Get the guild, if it exists + if guild_id is not None: + guild: Optional[discord.Guild] = client.get_guild(guild_id) + else: + guild = None + + # Get the member, if it exists + if member_id is not None and guild is not None: + member: Optional[discord.Member] = guild.get_member(member_id) + else: + member = None + + # Try to find all possible channels + channels: List[discord.VoiceChannel] = [] + for ch in client.get_all_channels(): + guild: discord.Guild = ch.guild + # Ensure the channel is a voice channel + if not isinstance(ch, discord.VoiceChannel): + continue + # Ensure the channel starts with the requested name + ch_name: str = ch.name + if not ch_name.startswith(channel_name): + continue + # Ensure that the command author can access the channel + if member is not None: + member_permissions: discord.Permissions = ch.permissions_for(member) + if not (member_permissions.connect and member_permissions.speak): + continue + # Ensure that the bot can access the channel + bot_member = guild.get_member(client.user.id) + bot_permissions: discord.Permissions = ch.permissions_for(bot_member) + if not (bot_permissions.connect and bot_permissions.speak): + continue + # Found one! + channels.append(ch) + + # Ensure at least a single channel is returned + if len(channels) == 0: + raise InvalidInputError("Could not find any channel to connect to.") + else: + # Give priority to channels in the current guild + filter_by_guild = False + for ch in channels: + if ch.guild == guild: + filter_by_guild = True + break + if filter_by_guild: + new_channels = [] + for ch in channels: + if ch.guild == guild: + new_channels.append(ch) + channels = new_channels + + # Give priority to channels with the most people + def people_count(c: discord.VoiceChannel): + return len(c.members) + + channels.sort(key=people_count, reverse=True) + + channel = channels[0] + + # 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: + # The bot is already connected to a voice channel + # TODO: safely move the bot somewhere else + raise CommandError("The bot is already connected in another channel.\n" + " Please disconnect it before resummoning!") + + + return { + "connected": True + } + else: + raise ValueError(f"Invalid operation received: {operation}") diff --git a/royalnet/backpack/utils/__init__.py b/royalnet/backpack/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/royalnet/commands/__init__.py b/royalnet/commands/__init__.py index 5cb361d0..eaef11cd 100644 --- a/royalnet/commands/__init__.py +++ b/royalnet/commands/__init__.py @@ -2,6 +2,7 @@ from .commandinterface import CommandInterface from .command import Command from .commanddata import CommandData from .commandargs import CommandArgs +from .event import Event from .errors import CommandError, \ InvalidInputError, \ UnsupportedError, \ @@ -20,4 +21,5 @@ __all__ = [ "ConfigurationError", "ExternalError", "UserError", + "Event" ] diff --git a/royalnet/commands/event.py b/royalnet/commands/event.py new file mode 100644 index 00000000..cb7cf635 --- /dev/null +++ b/royalnet/commands/event.py @@ -0,0 +1,30 @@ +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from serf import Serf + + +class Event: + """A remote procedure call triggered by a :mod:`royalnet.herald` request.""" + + name = NotImplemented + """The event_name that will trigger this event.""" + + tables: set = set() + """A set of :mod:`royalnet.alchemy` tables that must exist for this event to work.""" + + def __init__(self, serf: Serf): + """Bind the event to a :class:`~royalnet.serf.Serf`.""" + self.serf: Serf = serf + + @property + def alchemy(self): + """A shortcut for :attr:`.serf.alchemy`.""" + return self.serf.alchemy + + @property + def loop(self): + """A shortcut for :attr:`.serf.loop`""" + return self.serf.loop + + async def run(self, data: dict): + raise NotImplementedError() diff --git a/royalnet/serf/discord/discordserf.py b/royalnet/serf/discord/discordserf.py index d0f51dab..4a9891a5 100644 --- a/royalnet/serf/discord/discordserf.py +++ b/royalnet/serf/discord/discordserf.py @@ -43,7 +43,7 @@ class DiscordSerf(Serf): network_config=network_config, secrets_name=secrets_name) - self.Client = self.bot_factory() + self.Client = self.client_factory() """The custom :class:`discord.Client` class that will be instantiated later.""" self.client = self.Client() @@ -129,7 +129,7 @@ class DiscordSerf(Serf): if session is not None: await asyncify(session.close) - def bot_factory(self) -> Type["discord.Client"]: + def client_factory(self) -> Type["discord.Client"]: """Create a custom class inheriting from :py:class:`discord.Client`.""" # noinspection PyMethodParameters class DiscordClient(discord.Client): diff --git a/royalnet/serf/serf.py b/royalnet/serf/serf.py index c35cafdf..02b34db1 100644 --- a/royalnet/serf/serf.py +++ b/royalnet/serf/serf.py @@ -235,27 +235,27 @@ class Serf: async def network_handler(self, message: Union[Request, Broadcast]) -> Response: try: - network_handler = self.herald_handlers[message.handler] + herald_handler = self.herald_handlers[message.handler] except KeyError: log.warning(f"Missing network_handler for {message.handler}") return ResponseFailure("no_handler", f"This bot is missing a network handler for {message.handler}.") else: - log.debug(f"Using {network_handler} as handler for {message.handler}") + log.debug(f"Using {herald_handler} as handler for {message.handler}") if isinstance(message, Request): try: - response_data = await network_handler(self, **message.data) + response_data = await herald_handler(self, **message.data) return ResponseSuccess(data=response_data) except Exception as e: sentry_sdk.capture_exception(e) - log.error(f"Exception {e} in {network_handler}") + log.error(f"Exception {e} in {herald_handler}") return ResponseFailure("exception_in_handler", - f"An exception was raised in {network_handler} for {message.handler}.", + f"An exception was raised in {herald_handler} for {message.handler}.", extra_info={ "type": e.__class__.__name__, "message": str(e) }) elif isinstance(message, Broadcast): - await network_handler(self, **message.data) + await herald_handler(self, **message.data) @staticmethod def init_sentry(dsn):