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/commands/summon.py b/royalnet/backpack/commands/summon.py index 4d3868cf..a99b5228 100644 --- a/royalnet/backpack/commands/summon.py +++ b/royalnet/backpack/commands/summon.py @@ -29,14 +29,8 @@ class SummonCommand(Command): member = None guild = None try: - await self.interface.call_herald_event("discord", "discordvoice", { - "operation": "summon", - "data": { - "channel_name": args.joined(), - "member_id": member.id if member is not None else None, - "guild_id": guild.id if member is not None else None, - } - }) + # TODO: do something! + pass except Exception as e: breakpoint() await data.reply(f"✅ Connesso alla chat vocale.") diff --git a/royalnet/backpack/events/discordvoice.py b/royalnet/backpack/events/discordvoice.py deleted file mode 100644 index cc535216..00000000 --- a/royalnet/backpack/events/discordvoice.py +++ /dev/null @@ -1,118 +0,0 @@ -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 * -import weakref - -try: - import discord -except ImportError: - discord = None - - -class DiscordvoiceEvent(Event): - name: str = "discordvoice" - - def __init__(self, serf: Serf): - super().__init__(serf) - self.bards: weakref.WeakValueDictionary = weakref.WeakValueDictionary() - - async def run(self, - operation: str, - data: dict): - if not isinstance(self.serf, DiscordSerf): - raise ValueError("`discordvoice` event cannot run on other serfs.") - - if operation == "summon": - channel_name: str = data["channel_name"] - member_id: int = data.get("member_id") - guild_id: int = 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!") - - # Create a new bard, if it doesn't already exist - # TODO: does this work? are the voice clients correctly disposed of? - self.bards[channel.guild] = DBQueue() - - return { - "connected": True - } - # TODO: play, skip, playmode, remove, something else? - else: - raise ValueError(f"Invalid operation received: {operation}") 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/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 8e87712f..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,6 +36,7 @@ class DiscordSerf(Serf): def __init__(self, *, alchemy_config: Optional[AlchemyConfig] = None, commands: List[Type[Command]] = None, + events: List[Type[Event]] = None, herald_config: Optional[HeraldConfig] = None, secrets_name: str = "__default__"): if discord is None: @@ -40,6 +44,7 @@ class DiscordSerf(Serf): super().__init__(alchemy_config=alchemy_config, commands=commands, + events=events, herald_config=herald_config, secrets_name=secrets_name) @@ -47,7 +52,10 @@ class DiscordSerf(Serf): """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 @@ -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 ac1a4ba6..b7f7c392 100644 --- a/royalnet/serf/serf.py +++ b/royalnet/serf/serf.py @@ -103,7 +103,7 @@ class Serf: log.info("Herald: disabled") else: self.init_herald(herald_config, events) - log.info(f"Herald: {self.herald}") + log.info(f"Herald: {len(self.events)} events bound") self.loop: Optional[AbstractEventLoop] = None """The event loop this Serf is running on.""" @@ -316,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 4ebee45a..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,6 +37,7 @@ class TelegramSerf(Serf): def __init__(self, *, alchemy_config: Optional[AlchemyConfig] = None, commands: List[Type[Command]] = None, + events: List[Type[Event]] = None, herald_config: Optional[HeraldConfig] = None, secrets_name: str = "__default__"): if telegram is None: @@ -45,6 +45,7 @@ class TelegramSerf(Serf): super().__init__(alchemy_config=alchemy_config, commands=commands, + events=events, herald_config=herald_config, secrets_name=secrets_name)