diff --git a/royalnet/__main__.py b/royalnet/__main__.py index 1f9f761d..e6660e4c 100644 --- a/royalnet/__main__.py +++ b/royalnet/__main__.py @@ -47,7 +47,7 @@ def run(telegram: typing.Optional[bool], royalnet_log: Logger = getLogger("royalnet") royalnet_log.setLevel(log_level) stream_handler = StreamHandler() - stream_handler.formatter = Formatter("{asctime}\t{name}\t{levelname}\t{message}", style="{") + stream_handler.formatter = Formatter("{asctime}\t| {processName}\t| {levelname}\t| {message}", style="{") royalnet_log.addHandler(stream_handler) def get_secret(username: str): @@ -85,7 +85,7 @@ def run(telegram: typing.Optional[bool], secret=get_secret("herald"), secure=False, path="/") - herald_process = multiprocessing.Process(name="Herald", + herald_process = multiprocessing.Process(name="Herald Server", target=r.herald.Server(config=herald_config).run_blocking, daemon=True) herald_process.start() @@ -103,6 +103,7 @@ def run(telegram: typing.Optional[bool], enabled_commands = [] enabled_page_stars = [] enabled_exception_stars = [] + enabled_events = [] for pack in packs: imported = importlib.import_module(pack) try: @@ -117,20 +118,29 @@ def run(telegram: typing.Optional[bool], imported_exception_stars = imported.available_exception_stars except AttributeError: raise click.ClickException(f"{pack} isn't a Royalnet Pack as it is missing available_exception_stars.") + try: + imported_events = imported.available_events + except AttributeError: + raise click.ClickException(f"{pack} isn't a Royalnet Pack as it is missing available_events.") enabled_commands = [*enabled_commands, *imported_commands] enabled_page_stars = [*enabled_page_stars, *imported_page_stars] enabled_exception_stars = [*enabled_exception_stars, *imported_exception_stars] + enabled_events = [*enabled_events, *imported_events] telegram_process: typing.Optional[multiprocessing.Process] = None if interfaces["telegram"]: - telegram_db_config = r.serf.AlchemyConfig(database_url=alchemy_url, - master_table=r.backpack.tables.User, - identity_table=r.backpack.tables.Telegram, - identity_column="tg_id") + if alchemy_url is not None: + telegram_db_config = r.serf.AlchemyConfig(database_url=alchemy_url, + master_table=r.backpack.tables.User, + identity_table=r.backpack.tables.Telegram, + identity_column="tg_id") + else: + telegram_db_config = None telegram_serf_kwargs = { 'alchemy_config': telegram_db_config, 'commands': enabled_commands, - 'network_config': herald_config.copy(name="telegram"), + 'events': enabled_events, + 'herald_config': herald_config.copy(name="telegram"), 'secrets_name': secrets_name } telegram_process = multiprocessing.Process(name="Telegram Serf", @@ -141,14 +151,18 @@ def run(telegram: typing.Optional[bool], discord_process: typing.Optional[multiprocessing.Process] = None if interfaces["discord"]: - discord_db_config = r.serf.AlchemyConfig(database_url=alchemy_url, - master_table=r.backpack.tables.User, - identity_table=r.backpack.tables.Discord, - identity_column="discord_id") + if alchemy_url is not None: + discord_db_config = r.serf.AlchemyConfig(database_url=alchemy_url, + master_table=r.backpack.tables.User, + identity_table=r.backpack.tables.Discord, + identity_column="discord_id") + else: + discord_db_config = None discord_serf_kwargs = { 'alchemy_config': discord_db_config, 'commands': enabled_commands, - 'network_config': herald_config.copy(name="discord"), + 'events': enabled_events, + 'herald_config': herald_config.copy(name="discord"), 'secrets_name': secrets_name } discord_process = multiprocessing.Process(name="Discord Serf", 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..a99b5228 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,16 @@ 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] - + if self.interface.name == "discord": + msg: Optional["discord.Message"] = data.message + member: Optional["discord.Member"] = msg.author + guild: Optional["discord.Guild"] = msg.guild 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 + member = None + guild = None 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}>!") + # TODO: do something! + pass + except Exception as e: + breakpoint() + await data.reply(f"✅ Connesso alla chat vocale.") 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/utils/__init__.py b/royalnet/backpack/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/royalnet/bard/__init__.py b/royalnet/bard/__init__.py index ad74da52..f934d0e8 100644 --- a/royalnet/bard/__init__.py +++ b/royalnet/bard/__init__.py @@ -3,8 +3,6 @@ from .ytdlfile import YtdlFile from .ytdlmp3 import YtdlMp3 from .ytdldiscord import YtdlDiscord from .errors import * -from .discordbard import DiscordBard -from . import implementations try: from .fileaudiosource import FileAudioSource @@ -21,6 +19,4 @@ __all__ = [ "NotFoundError", "MultipleFilesError", "FileAudioSource", - "implementations", - "DiscordBard", ] 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/commandinterface.py b/royalnet/commands/commandinterface.py index c2a41e12..4f6519d8 100644 --- a/royalnet/commands/commandinterface.py +++ b/royalnet/commands/commandinterface.py @@ -27,28 +27,18 @@ class CommandInterface: A reference to a :class:`~royalnet.serf.telegram.TelegramSerf`.""" @property - def alchemy(self): + def alchemy(self) -> "Alchemy": """A shortcut for :attr:`serf.alchemy`.""" return self.serf.alchemy @property - def loop(self): + def loop(self) -> AbstractEventLoop: """A shortcut for :attr:`serf.loop`.""" return self.serf.loop def __init__(self): self.command: Optional[Command] = None # Will be bound after the command has been created - def register_herald_action(self, - event_name: str, - coroutine: Callable[[Any], Awaitable[dict]]): + async def call_herald_event(self, destination: str, event_name: str, args: dict) -> dict: # TODO: document this - raise UnsupportedError(f"{self.register_herald_action.__name__} is not supported on this platform") - - def unregister_herald_action(self, event_name: str): - # TODO: document this - raise UnsupportedError(f"{self.unregister_herald_action.__name__} is not supported on this platform") - - async def call_herald_action(self, destination: str, event_name: str, args: dict) -> dict: - # TODO: document this - raise UnsupportedError(f"{self.call_herald_action.__name__} is not supported on this platform") + raise UnsupportedError(f"{self.call_herald_event.__name__} is not supported on this platform") diff --git a/royalnet/commands/event.py b/royalnet/commands/event.py new file mode 100644 index 00000000..a923f612 --- /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, **kwargs): + raise NotImplementedError() diff --git a/royalnet/constellation/constellation.py b/royalnet/constellation/constellation.py index 6ff32553..a3cff73a 100644 --- a/royalnet/constellation/constellation.py +++ b/royalnet/constellation/constellation.py @@ -25,6 +25,14 @@ except ImportError: log = logging.getLogger(__name__) +UVICORN_LOGGING_CONFIG = { + "version": 1, + "disable_existing_loggers": True, + "formatters": {}, + "handlers": {}, + "loggers": {}, +} + class Constellation: """The class that represents the webserver. @@ -66,13 +74,16 @@ class Constellation: tables = tables.union(SelectedExcStar.tables) log.debug(f"Found Tables: {' '.join([table.__name__ for table in tables])}") - log.info(f"Creating Alchemy...") - self.alchemy: royalnet.alchemy.Alchemy = royalnet.alchemy.Alchemy(database_uri=database_uri, tables=tables) + self.alchemy = None """The :class:`Alchemy` of this Constellation.""" + if database_uri is not None: + log.info(f"Creating Alchemy...") + self.alchemy: royalnet.alchemy.Alchemy = royalnet.alchemy.Alchemy(database_uri=database_uri, tables=tables) + log.info("Registering PageStars...") for SelectedPageStar in page_stars: - log.info(f"Registering: {SelectedPageStar.path} -> {SelectedPageStar.__class__.__name__}") + log.info(f"Registering: {SelectedPageStar.path} -> {SelectedPageStar.__qualname__}") try: page_star_instance = SelectedPageStar(constellation=self) except Exception as e: @@ -82,7 +93,7 @@ class Constellation: log.info("Registering ExceptionStars...") for SelectedExcStar in exc_stars: - log.info(f"Registering: {SelectedExcStar.error} -> {SelectedExcStar.__class__.__name__}") + log.info(f"Registering: {SelectedExcStar.error} -> {SelectedExcStar.__name__}") try: exc_star_instance = SelectedExcStar(constellation=self) except Exception as e: @@ -141,10 +152,10 @@ class Constellation: release=release) log.info(f"Sentry: enabled (Royalnet {release})") # Run the server - log.info(f"Running Constellation on {address}:{port}...") + log.info(f"Running Constellation on https://{address}:{port}/ ...") constellation.running = True try: - uvicorn.run(constellation.starlette, host=address, port=port) + uvicorn.run(constellation.starlette, host=address, port=port, log_config=UVICORN_LOGGING_CONFIG) finally: constellation.running = False diff --git a/royalnet/herald/link.py b/royalnet/herald/link.py index 1baa6e02..bd8269c8 100644 --- a/royalnet/herald/link.py +++ b/royalnet/herald/link.py @@ -85,10 +85,10 @@ class Link: async def connect(self): """Connect to the :class:`Server` at :attr:`.master_uri`.""" - log.info(f"Connecting to {self.config.url}...") + log.debug(f"Connecting to Herald Server at {self.config.url}...") self.websocket = await websockets.connect(self.config.url, loop=self._loop) self.connect_event.set() - log.info(f"Connected!") + log.debug(f"Connected!") @requires_connection async def receive(self) -> Package: @@ -103,7 +103,7 @@ class Link: self.error_event.set() self.connect_event.clear() self.identify_event.clear() - log.info(f"Connection to {self.config.url} was closed.") + log.warning(f"Herald Server connection closed: {self.config.url}") # What to do now? Let's just reraise. raise ConnectionClosedError() if self.identify_event.is_set() and package.destination != self.nid: @@ -113,7 +113,7 @@ class Link: @requires_connection async def identify(self) -> None: - log.info(f"Identifying...") + log.debug(f"Identifying...") await self.websocket.send(f"Identify {self.nid}:{self.config.name}:{self.config.secret}") response: Package = await self.receive() if not response.source == "": @@ -124,7 +124,7 @@ class Link: raise ConnectionClosedError(f"Identification error: {response.data['type']}") assert response.data["type"] == "success" self.identify_event.set() - log.info(f"Identified successfully!") + log.debug(f"Identified successfully!") @requires_identification async def send(self, package: Package): diff --git a/royalnet/herald/server.py b/royalnet/herald/server.py index d43cbe8b..34da2efe 100644 --- a/royalnet/herald/server.py +++ b/royalnet/herald/server.py @@ -62,28 +62,30 @@ class Server: return matching or [] async def listener(self, websocket: "websockets.server.WebSocketServerProtocol", path): - log.info(f"{websocket.remote_address} connected to the server.") connected_client = ConnectedClient(websocket) # Wait for identification identify_msg = await websocket.recv() log.debug(f"{websocket.remote_address} identified itself with: {identify_msg}.") if not isinstance(identify_msg, str): + log.warning(f"Failed Herald identification: {websocket.remote_address[0]}:{websocket.remote_address[1]}") await connected_client.send_service("error", "Invalid identification message (not a str)") return identification = re.match(r"Identify ([^:\s]+):([^:\s]+):([^:\s]+)", identify_msg) if identification is None: + log.warning(f"Failed Herald identification: {websocket.remote_address[0]}:{websocket.remote_address[1]}") await connected_client.send_service("error", "Invalid identification message (regex failed)") return secret = identification.group(3) if secret != self.config.secret: + log.warning(f"Invalid Herald secret: {websocket.remote_address[0]}:{websocket.remote_address[1]}") await connected_client.send_service("error", "Invalid secret") return # Identification successful connected_client.nid = identification.group(1) connected_client.link_type = identification.group(2) + log.info(f"Joined the Herald: {websocket.remote_address[0]}:{websocket.remote_address[1]}" + f" ({connected_client.link_type})") self.identified_clients.append(connected_client) - log.debug(f"{websocket.remote_address} identified successfully as {connected_client.nid}" - f" ({connected_client.link_type}).") await connected_client.send_service("success", "Identification successful!") log.debug(f"{connected_client.nid}'s identification confirmed.") # Main loop diff --git a/royalnet/serf/discord/__init__.py b/royalnet/serf/discord/__init__.py index c9d9facc..6fe4e67b 100644 --- a/royalnet/serf/discord/__init__.py +++ b/royalnet/serf/discord/__init__.py @@ -1,9 +1,11 @@ from .createrichembed import create_rich_embed from .escape import escape from .discordserf import DiscordSerf +from . import discordbard __all__ = [ "create_rich_embed", "escape", "DiscordSerf", + "discordbard", ] diff --git a/royalnet/serf/discord/barddict.py b/royalnet/serf/discord/barddict.py new file mode 100644 index 00000000..8681a394 --- /dev/null +++ b/royalnet/serf/discord/barddict.py @@ -0,0 +1,33 @@ +from typing import Dict, Any +from .discordbard import DiscordBard + +try: + import discord +except ImportError: + discord = None + + +class BardsDict: + def __init__(self, client: "discord.Client"): + if discord is None: + raise ImportError("'discord' extra is not installed.") + self.client: "discord.Client" = client + self._dict: Dict["discord.Guild", DiscordBard] = dict() + + def __getitem__(self, item: "discord.Guild") -> DiscordBard: + bard = self._dict[item] + if bard.voice_client not in self.client.voice_clients: + del self._dict[item] + raise KeyError("Requested bard is disconnected and was removed from the dict.") + return bard + + def __setitem__(self, key: "discord.Guild", value): + if not isinstance(value, DiscordBard): + raise TypeError(f"Cannot __setitem__ with {value.__class__.__name__}.") + self._dict[key] = value + + def get(self, item: "discord.Guild", default: Any = None) -> Any: + try: + return self[item] + except KeyError: + return default diff --git a/royalnet/bard/implementations/__init__.py b/royalnet/serf/discord/discordbard/__init__.py similarity index 64% rename from royalnet/bard/implementations/__init__.py rename to royalnet/serf/discord/discordbard/__init__.py index 918b26ed..77f03ff6 100644 --- a/royalnet/bard/implementations/__init__.py +++ b/royalnet/serf/discord/discordbard/__init__.py @@ -1,7 +1,9 @@ +from .discordbard import DiscordBard from .dbstack import DBStack from .dbqueue import DBQueue __all__ = [ "DBStack", "DBQueue", + "DiscordBard", ] diff --git a/royalnet/bard/implementations/dbqueue.py b/royalnet/serf/discord/discordbard/dbqueue.py similarity index 78% rename from royalnet/bard/implementations/dbqueue.py rename to royalnet/serf/discord/discordbard/dbqueue.py index 5bd18532..ef322ff2 100644 --- a/royalnet/bard/implementations/dbqueue.py +++ b/royalnet/serf/discord/discordbard/dbqueue.py @@ -1,15 +1,19 @@ -from ..fileaudiosource import FileAudioSource -from ..discordbard import DiscordBard -from ..ytdldiscord import YtdlDiscord +from royalnet.bard import FileAudioSource, YtdlDiscord from typing import List, AsyncGenerator, Tuple, Any, Dict, Optional +from .discordbard import DiscordBard + +try: + import discord +except ImportError: + discord = None class DBQueue(DiscordBard): """A First-In-First-Out music queue. It is what was once called a ``playlist``.""" - def __init__(self): - super().__init__() + def __init__(self, voice_client: "discord.VoiceClient"): + super().__init__(voice_client) self.list: List[YtdlDiscord] = [] async def _generator(self) -> AsyncGenerator[Optional[FileAudioSource], Tuple[Tuple[Any, ...], Dict[str, Any]]]: diff --git a/royalnet/bard/implementations/dbstack.py b/royalnet/serf/discord/discordbard/dbstack.py similarity index 78% rename from royalnet/bard/implementations/dbstack.py rename to royalnet/serf/discord/discordbard/dbstack.py index 426c4be1..f7355b50 100644 --- a/royalnet/bard/implementations/dbstack.py +++ b/royalnet/serf/discord/discordbard/dbstack.py @@ -1,15 +1,19 @@ -from ..fileaudiosource import FileAudioSource -from ..discordbard import DiscordBard -from ..ytdldiscord import YtdlDiscord from typing import List, AsyncGenerator, Tuple, Any, Dict, Optional +from royalnet.bard import FileAudioSource, YtdlDiscord +from .discordbard import DiscordBard + +try: + import discord +except ImportError: + discord = None class DBStack(DiscordBard): """A First-In-Last-Out music queue. Not really sure if it is going to be useful...""" - def __init__(self): - super().__init__() + def __init__(self, voice_client: "discord.VoiceClient"): + super().__init__(voice_client) self.list: List[YtdlDiscord] = [] async def _generator(self) -> AsyncGenerator[Optional[FileAudioSource], Tuple[Tuple[Any, ...], Dict[str, Any]]]: diff --git a/royalnet/bard/discordbard.py b/royalnet/serf/discord/discordbard/discordbard.py similarity index 86% rename from royalnet/bard/discordbard.py rename to royalnet/serf/discord/discordbard/discordbard.py index fb8c9d77..f2645dd8 100644 --- a/royalnet/bard/discordbard.py +++ b/royalnet/serf/discord/discordbard/discordbard.py @@ -1,7 +1,10 @@ from typing import Optional, AsyncGenerator, List, Tuple, Any, Dict -from .ytdldiscord import YtdlDiscord -from .fileaudiosource import FileAudioSource -from .errors import UnsupportedError +from royalnet.bard import YtdlDiscord, FileAudioSource, UnsupportedError + +try: + import discord +except ImportError: + discord = None class DiscordBard: @@ -9,11 +12,14 @@ class DiscordBard: Possible implementation may be playlist, song pools, multilayered tracks, and so on.""" - def __init__(self): + def __init__(self, voice_client: "discord.VoiceClient"): """Create manually a :class:`DiscordBard`. Warning: Avoid calling this method, please use :meth:`create` instead!""" + self.voice_client: "discord.VoiceClient" = voice_client + """The voice client that this :class:`DiscordBard` refers to.""" + self.now_playing: Optional[YtdlDiscord] = None """The :class:`YtdlDiscord` that's currently being played.""" @@ -26,13 +32,13 @@ class DiscordBard: it can take a args+kwargs tuple in input to optionally select a different source. The generator should ``yield`` once before doing anything else.""" - args, kwargs = yield + yield raise NotImplementedError() @classmethod - async def create(cls) -> "DiscordBard": + async def create(cls, voice_client: "discord.VoiceClient") -> "DiscordBard": """Create an instance of the :class:`DiscordBard`, and initialize its async generator.""" - bard = cls() + bard = cls(voice_client=voice_client) # noinspection PyTypeChecker none = bard.generator.asend(None) assert none is None diff --git a/royalnet/serf/discord/discordserf.py b/royalnet/serf/discord/discordserf.py index d0f51dab..51c840c6 100644 --- a/royalnet/serf/discord/discordserf.py +++ b/royalnet/serf/discord/discordserf.py @@ -3,8 +3,11 @@ import logging from typing import Type, Optional, List, Union from royalnet.commands import * from royalnet.utils import asyncify +from royalnet.serf import Serf from .escape import escape -from ..serf import Serf +from .discordbard import * +from .barddict import BardsDict + try: import discord @@ -33,21 +36,26 @@ class DiscordSerf(Serf): def __init__(self, *, alchemy_config: Optional[AlchemyConfig] = None, commands: List[Type[Command]] = None, - network_config: Optional[HeraldConfig] = None, + events: List[Type[Event]] = None, + herald_config: Optional[HeraldConfig] = None, secrets_name: str = "__default__"): if discord is None: raise ImportError("'discord' extra is not installed") super().__init__(alchemy_config=alchemy_config, commands=commands, - network_config=network_config, + events=events, + herald_config=herald_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() - """The custo :class:`discord.Client` instance.""" + """The custom :class:`discord.Client` instance.""" + + self.bards: BardsDict = BardsDict(self.client) + """A dictionary containing all bards spawned by this :class:`DiscordSerf`.""" def interface_factory(self) -> Type[CommandInterface]: # noinspection PyPep8Naming @@ -129,7 +137,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): @@ -144,15 +152,99 @@ class DiscordSerf(Serf): return DiscordClient - def get_voice_client(self, guild: "discord.Guild") -> Optional["discord.VoiceClient"]: - voice_clients: List["discord.VoiceClient"] = self.client.voice_clients - for voice_client in voice_clients: - if voice_client.guild == guild: - return voice_client - return None - async def run(self): await super().run() token = self.get_secret("discord") + if token is None: + raise ValueError("Missing discord token") await self.client.login(token) await self.client.connect() + + async def find_channel(self, + channel_type: Optional[Type["discord.abc.GuildChannel"]] = None, + name: Optional[str] = None, + guild: Optional["discord.Guild"] = None, + accessible_to: List["discord.User"] = None, + required_permissions: List[str] = None) -> Optional["discord.abc.GuildChannel"]: + """Find the best channel matching all requests. + + In case multiple channels match all requests, return the one with the most members connected. + + Args: + channel_type: Filter channels by type (select only :class:`discord.VoiceChannel`, + :class:`discord.TextChannel`, ...). + name: Filter channels by name starting with ``name`` (using :meth:`str.startswith`). + Note that some channel types don't have names; this check will be skipped for them. + guild: Filter channels by guild, keep only channels inside this one. + accessible_to: Filter channels by permissions, keeping only channels where *all* these users have + the required permissions. + required_permissions: Filter channels by permissions, keeping only channels where the users have *all* these + :class:`discord.Permissions`. + + Returns: + Either a :class:`~discord.abc.GuildChannel`, or :const:`None` if no channels were found.""" + if accessible_to is None: + accessible_to = [] + if required_permissions is None: + required_permissions = [] + channels: List[discord.abc.GuildChannel] = [] + for ch in self.client.get_all_channels(): + if channel_type is not None and not isinstance(ch, channel_type): + continue + + if name is not None: + try: + ch_name: str = ch.name + if not ch_name.startswith(name): + continue + except AttributeError: + pass + + ch_guild: "discord.Guild" = ch.guild + if ch.guild == ch_guild: + continue + + for user in accessible_to: + member: "discord.Member" = guild.get_member(user.id) + if member is None: + continue + permissions: "discord.Permissions" = ch.permissions_for(member) + missing_perms = False + for permission in required_permissions: + if not permissions.__getattribute__(permission): + missing_perms = True + break + if missing_perms: + continue + + channels.append(ch) + + if len(channels) == 0: + return None + else: + # 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) + + return channels[0] + + async def voice_connect(self, channel: "discord.VoiceChannel"): + """Try to connect to a :class:`discord.VoiceChannel` and to create the corresponing :class:`DiscordBard`. + + Info: + Command-compatible! This method will raise :exc:`CommandError`s for all its errors, so it can be called + inside a command!""" + try: + voice_client = 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!") + self.bards[channel.guild] = DBQueue(voice_client=voice_client) diff --git a/royalnet/serf/serf.py b/royalnet/serf/serf.py index c35cafdf..b7f7c392 100644 --- a/royalnet/serf/serf.py +++ b/royalnet/serf/serf.py @@ -47,7 +47,8 @@ class Serf: def __init__(self, *, alchemy_config: Optional[AlchemyConfig] = None, commands: List[Type[Command]] = None, - network_config: Optional[HeraldConfig] = None, + events: List[Type[Event]] = None, + herald_config: Optional[HeraldConfig] = None, secrets_name: str = "__default__"): self.secrets_name = secrets_name @@ -87,23 +88,22 @@ class Serf: self.register_commands(commands) log.info(f"Commands: total {len(self.commands)}") - self.herald_handlers: Dict[str, Callable[["Serf", Any], Awaitable[Optional[dict]]]] = {} - """A :class:`dict` linking :class:`Request` event names to coroutines returning a :class:`dict` that will be - sent as :class:`Response` to the event.""" - self.herald: Optional[Link] = None """The :class:`Link` object connecting the Serf to the rest of the herald network.""" self.herald_task: Optional[Task] = None """A reference to the :class:`asyncio.Task` that runs the :class:`Link`.""" + self.events: Dict[str, Event] = {} + """A dictionary containing all :class:`Event` that can be handled by this :class:`Serf`.""" + if Link is None: log.info("Herald: not installed") - elif network_config is None: + elif herald_config is None: log.info("Herald: disabled") else: - self.init_network(network_config) - log.info(f"Herald: {self.herald}") + self.init_herald(herald_config, events) + log.info(f"Herald: {len(self.events)} events bound") self.loop: Optional[AbstractEventLoop] = None """The event loop this Serf is running on.""" @@ -148,22 +148,7 @@ class Serf: alchemy: Alchemy = self.alchemy serf: "Serf" = self - def register_herald_action(ci, - event_name: str, - coroutine: Callable[[Any], Awaitable[Dict]]) -> None: - """Allow a coroutine to be called when a :class:`royalherald.Request` is received.""" - if self.herald is None: - raise UnsupportedError("`royalherald` is not enabled on this bot.") - self.herald_handlers[event_name] = coroutine - - def unregister_herald_action(ci, event_name: str): - """Disable a previously registered coroutine from being called on reception of a - :class:`royalherald.Request`.""" - if self.herald is None: - raise UnsupportedError("`royalherald` is not enabled on this bot.") - del self.herald_handlers[event_name] - - async def call_herald_action(ci, destination: str, event_name: str, args: Dict) -> Dict: + async def call_herald_event(ci, destination: str, event_name: str, args: Dict) -> Dict: """Send a :class:`royalherald.Request` to a specific destination, and wait for a :class:`royalherald.Response`.""" if self.herald is None: @@ -228,34 +213,36 @@ class Serf: else: log.warning(f"Ignoring (already defined): {SelectedCommand.__qualname__} -> {interface.prefix}{alias}") - def init_network(self, config: HeraldConfig): + def init_herald(self, config: HeraldConfig, events: List[Type[Event]]): """Create a :py:class:`Link`, and run it as a :py:class:`asyncio.Task`.""" - log.debug(f"Initializing herald...") self.herald: Link = Link(config, self.network_handler) + log.debug(f"Binding events...") + for SelectedEvent in events: + log.debug(f"Binding event: {SelectedEvent.name}.") + self.events[SelectedEvent.name] = SelectedEvent(self) async def network_handler(self, message: Union[Request, Broadcast]) -> Response: try: - network_handler = self.herald_handlers[message.handler] + event: Event = self.events[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.warning(f"No event for '{message.handler}'") + return ResponseFailure("no_event", f"This serf does not have any event for {message.handler}.") + log.debug(f"Event called: {event.name}") if isinstance(message, Request): try: - response_data = await network_handler(self, **message.data) + response_data = await event.run(**message.data) return ResponseSuccess(data=response_data) except Exception as e: sentry_sdk.capture_exception(e) - log.error(f"Exception {e} in {network_handler}") - return ResponseFailure("exception_in_handler", - f"An exception was raised in {network_handler} for {message.handler}.", + log.error(f"Event error: {e.__class__.__qualname__} in {event.name}") + return ResponseFailure("exception_in_event", + f"An exception was raised in the event for '{message.handler}'.", extra_info={ - "type": e.__class__.__name__, + "type": e.__class__.__qualname__, "message": str(e) }) elif isinstance(message, Broadcast): - await network_handler(self, **message.data) + await event.run(**message.data) @staticmethod def init_sentry(dsn): @@ -329,4 +316,8 @@ class Serf: serf.init_sentry(sentry_dsn) serf.loop = get_event_loop() - serf.loop.run_until_complete(serf.run()) + try: + serf.loop.run_until_complete(serf.run()) + except Exception as e: + log.error(f"Uncaught exception: {e}") + serf.sentry_exc(e) diff --git a/royalnet/serf/telegram/telegramserf.py b/royalnet/serf/telegram/telegramserf.py index b57cfac1..4a053ab6 100644 --- a/royalnet/serf/telegram/telegramserf.py +++ b/royalnet/serf/telegram/telegramserf.py @@ -1,8 +1,7 @@ import logging import asyncio from typing import Type, Optional, List, Callable -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 @@ -38,14 +37,16 @@ class TelegramSerf(Serf): def __init__(self, *, alchemy_config: Optional[AlchemyConfig] = None, commands: List[Type[Command]] = None, - network_config: Optional[HeraldConfig] = None, + events: List[Type[Event]] = None, + herald_config: Optional[HeraldConfig] = None, secrets_name: str = "__default__"): if telegram is None: raise ImportError("'telegram' extra is not installed") super().__init__(alchemy_config=alchemy_config, commands=commands, - network_config=network_config, + events=events, + herald_config=herald_config, secrets_name=secrets_name) self.client = telegram.Bot(self.get_secret("telegram"), request=TRequest(5, read_timeout=30))