diff --git a/docs_source/randomdiscoveries.rst b/docs_source/randomdiscoveries.rst index c73eedc5..e104e7cb 100644 --- a/docs_source/randomdiscoveries.rst +++ b/docs_source/randomdiscoveries.rst @@ -6,8 +6,10 @@ Here are some things that were found out while developing the bot. Discord websocket undocumented error codes ------------------------------------------ -====== =================== +====== ===================== Code Reason -====== =================== +====== ===================== 1006 Heartbeat stopped -====== =================== +------ --------------------- +1006 Failed authentication +====== ===================== diff --git a/royalnet/bard/errors.py b/royalnet/bard/errors.py index 88b89e72..1368a7cb 100644 --- a/royalnet/bard/errors.py +++ b/royalnet/bard/errors.py @@ -12,7 +12,3 @@ class NotFoundError(YtdlError): class MultipleFilesError(YtdlError): """The resource contains multiple media files.""" - - -class UnsupportedError(BardError): - """The method you tried to call on a :class:`DiscordBard` is not supported on that particular Bard.""" diff --git a/royalnet/serf/discord/discordbard/ytdldiscord.py b/royalnet/bard/ytdldiscord.py similarity index 97% rename from royalnet/serf/discord/discordbard/ytdldiscord.py rename to royalnet/bard/ytdldiscord.py index f79b3e02..99f4a481 100644 --- a/royalnet/serf/discord/discordbard/ytdldiscord.py +++ b/royalnet/bard/ytdldiscord.py @@ -3,9 +3,8 @@ import re import os import logging from contextlib import asynccontextmanager -from royalnet.utils import asyncify, MultiLock +from royalnet.utils import asyncify, MultiLock, FileAudioSource from royalnet.bard import YtdlInfo, YtdlFile -from .fileaudiosource import FileAudioSource try: import ffmpeg diff --git a/royalnet/commands/commandinterface.py b/royalnet/commands/commandinterface.py index 4f6519d8..ed31ddeb 100644 --- a/royalnet/commands/commandinterface.py +++ b/royalnet/commands/commandinterface.py @@ -39,6 +39,8 @@ class CommandInterface: def __init__(self): self.command: Optional[Command] = None # Will be bound after the command has been created - async def call_herald_event(self, destination: str, event_name: str, args: dict) -> dict: - # TODO: document this + async def call_herald_event(self, destination: str, event_name: str, **kwargs) -> dict: + """Call an event function on a different :class:`Serf`. + + For example, you can run a function on a :class:`DiscordSerf` from a :class:`TelegramSerf`.""" raise UnsupportedError(f"{self.call_herald_event.__name__} is not supported on this platform") diff --git a/royalnet/serf/__init__.py b/royalnet/serf/__init__.py index a82f8f5a..6ad8d834 100644 --- a/royalnet/serf/__init__.py +++ b/royalnet/serf/__init__.py @@ -1,10 +1,12 @@ from .serf import Serf from .alchemyconfig import AlchemyConfig +from .errors import SerfError from . import telegram, discord __all__ = [ "Serf", "AlchemyConfig", + "SerfError", "telegram", "discord", ] diff --git a/royalnet/serf/alchemyconfig.py b/royalnet/serf/alchemyconfig.py index 6be4774d..211df9b1 100644 --- a/royalnet/serf/alchemyconfig.py +++ b/royalnet/serf/alchemyconfig.py @@ -1,6 +1,3 @@ -from typing import TYPE_CHECKING - - class AlchemyConfig: """A helper class to configure :class:`Alchemy` in a :class:`Serf`.""" def __init__(self, diff --git a/royalnet/serf/discord/barddict.py b/royalnet/serf/discord/barddict.py deleted file mode 100644 index 8681a394..00000000 --- a/royalnet/serf/discord/barddict.py +++ /dev/null @@ -1,33 +0,0 @@ -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/serf/discord/discordbard/__init__.py b/royalnet/serf/discord/discordbard/__init__.py deleted file mode 100644 index f8ae05d0..00000000 --- a/royalnet/serf/discord/discordbard/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from .discordbard import DiscordBard -from .dbqueue import DBQueue -from .fileaudiosource import FileAudioSource -from .ytdldiscord import YtdlDiscord - -__all__ = [ - "DBQueue", - "DiscordBard", - "FileAudioSource", - "YtdlDiscord", -] diff --git a/royalnet/serf/discord/discordbard/createrichembed.py b/royalnet/serf/discord/discordbard/createrichembed.py deleted file mode 100644 index ba56df56..00000000 --- a/royalnet/serf/discord/discordbard/createrichembed.py +++ /dev/null @@ -1,4 +0,0 @@ -from discord import Embed, Colour -from discord.embeds import EmptyEmbed -from royalnet.bard import YtdlInfo - diff --git a/royalnet/serf/discord/discordbard/dbqueue.py b/royalnet/serf/discord/discordbard/dbqueue.py deleted file mode 100644 index 10272d3d..00000000 --- a/royalnet/serf/discord/discordbard/dbqueue.py +++ /dev/null @@ -1,46 +0,0 @@ -from royalnet.bard import FileAudioSource -from typing import List, AsyncGenerator, Tuple, Any, Dict, Optional -from .discordbard import DiscordBard -from .ytdldiscord import YtdlDiscord - -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, 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]]]: - yield - while True: - try: - ytd = self.list.pop(0) - except IndexError: - yield None - else: - try: - async with ytd.spawn_audiosource() as fas: - yield fas - finally: - await ytd.delete_asap() - - async def add(self, ytd: YtdlDiscord): - self.list.append(ytd) - - async def peek(self) -> List[YtdlDiscord]: - return self.list - - async def remove(self, ytd: YtdlDiscord): - self.list.remove(ytd) - - async def cleanup(self) -> None: - for ytd in self.list: - await ytd.delete_asap() - await self.stop() diff --git a/royalnet/serf/discord/discordbard/discordbard.py b/royalnet/serf/discord/discordbard/discordbard.py deleted file mode 100644 index e9850a56..00000000 --- a/royalnet/serf/discord/discordbard/discordbard.py +++ /dev/null @@ -1,97 +0,0 @@ -from typing import Optional, AsyncGenerator, List, Tuple, Any, Dict -from royalnet.bard import UnsupportedError -from .fileaudiosource import FileAudioSource -from .ytdldiscord import YtdlDiscord - -try: - import discord -except ImportError: - discord = None - - -class DiscordBard: - """An abstract representation of a music sequence. - - Possible implementation may be playlist, song pools, multilayered tracks, and so on.""" - - 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[FileAudioSource] = None - """The :class:`YtdlDiscord` that's currently being played.""" - - self.generator: \ - AsyncGenerator[FileAudioSource, Tuple[Tuple[Any, ...], Dict[str, Any]]] = self._generator() - """The AsyncGenerator responsible for deciding the next song that should be played.""" - - async def _generator(self) -> AsyncGenerator[Optional[FileAudioSource], Tuple[Tuple[Any, ...], Dict[str, Any]]]: - """Create an async generator that returns the next source to be played; - it can take a args+kwargs tuple in input to optionally select a different source. - - The generator should ``yield`` once before doing anything else.""" - yield - raise NotImplementedError() - - @classmethod - async def create(cls, voice_client: "discord.VoiceClient") -> "DiscordBard": - """Create an instance of the :class:`DiscordBard`, and initialize its async generator.""" - bard = cls(voice_client=voice_client) - # noinspection PyTypeChecker - none = await bard.generator.asend(None) - assert none is None - return bard - - async def next(self, *args, **kwargs) -> Optional[FileAudioSource]: - """Get the next :class:`FileAudioSource` that should be played, and change :attr:`.now_playing`. - - Args and kwargs can be passed to the generator to select differently.""" - fas: Optional[FileAudioSource] = await self.generator.asend((args, kwargs,)) - self.now_playing = fas - return fas - - async def stop(self): - """Stop the playback of the current song.""" - if self.now_playing is not None: - self.now_playing.stop() - - async def add(self, ytd: YtdlDiscord) -> None: - """Add a new :class:`YtdlDiscord` to the :class:`DiscordBard`, if possible. - - Raises: - UnsupportedError: If it isn't possible to add new :class:`YtdlDiscord` to the :class:`DiscordBard`. - """ - raise UnsupportedError() - - async def peek(self) -> Optional[List[YtdlDiscord]]: - """Return the contents of the :class:`DiscordBard` as a :class:`list`, if possible. - - Raises: - UnsupportedError: If it isn't possible to display the :class:`DiscordBard` state as a :class:`list`. - """ - raise UnsupportedError() - - async def remove(self, ytd: YtdlDiscord) -> None: - """Remove a :class:`YtdlDiscord` from the :class:`DiscordBard`, if possible. - - Raises: - UnsupportedError: If it isn't possible to remove the :class:`YtdlDiscord` from the :class:`DiscordBard`. - """ - raise UnsupportedError() - - async def cleanup(self) -> None: - """Enqueue the deletion of all :class:`YtdlDiscord` contained in the :class:`DiscordBard`, and return only once - all deletions are complete.""" - raise NotImplementedError() - - async def length(self) -> int: - """Return the length of the :class:`DiscordBard`. - - Raises: - UnsupportedError: If :meth:`.peek` is unsupported.""" - return len(await self.peek()) - diff --git a/royalnet/serf/discord/discordserf.py b/royalnet/serf/discord/discordserf.py index 01c4fc38..bbcb1f74 100644 --- a/royalnet/serf/discord/discordserf.py +++ b/royalnet/serf/discord/discordserf.py @@ -1,12 +1,10 @@ import asyncio import logging -from typing import Type, Optional, List, Union +from typing import Type, Optional, List, Union, Dict from royalnet.commands import * from royalnet.utils import asyncify from royalnet.serf import Serf from .escape import escape -from .discordbard import * -from .barddict import BardsDict try: @@ -54,9 +52,6 @@ class DiscordSerf(Serf): self.client = self.Client() """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 GenericInterface = super().interface_factory() @@ -201,7 +196,7 @@ class DiscordSerf(Serf): pass ch_guild: "discord.Guild" = ch.guild - if ch.guild != ch_guild: + if guild is not None and guild != ch_guild: continue for user in accessible_to: @@ -230,25 +225,6 @@ class DiscordSerf(Serf): 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] = await DBQueue.create(voice_client=voice_client) - async def voice_run(self, guild: "discord.Guild"): """Send the data from the bard to the voice websocket for a specific client.""" bard: Optional[DiscordBard] = self.bards.get(guild) diff --git a/royalnet/serf/discord/errors.py b/royalnet/serf/discord/errors.py new file mode 100644 index 00000000..c25c2855 --- /dev/null +++ b/royalnet/serf/discord/errors.py @@ -0,0 +1,38 @@ +from ..errors import SerfError + + +class DiscordSerfError(SerfError): + """Base class for all :mod:`royalnet.serf.discord` errors.""" + + +class VoicePlayerError(DiscordSerfError): + """Base class for all :class:`VoicePlayer` errors.""" + + +class AlreadyConnectedError(VoicePlayerError): + """Base class for the "Already Connected" errors.""" + + +class PlayerAlreadyConnectedError(AlreadyConnectedError): + """The :class:`VoicePlayer` is already connected to voice. + + Access the :class:`discord.VoiceClient` through :attr:`VoicePlayer.voice_client`!""" + + +class GuildAlreadyConnectedError(AlreadyConnectedError): + """The :class:`discord.Client` is already connected to voice in a channel of this guild.""" + + +class OpusNotLoadedError(VoicePlayerError): + """The Opus library hasn't been loaded `as required + ` by :mod:`discord`.""" + + +class DiscordTimeoutError(VoicePlayerError): + """The websocket didn't get a response from the Discord voice servers in time.""" + + +class PlayerNotConnectedError(VoicePlayerError): + """The :class:`VoicePlayer` isn't connected to the Discord voice servers. + + Use :meth:`VoicePlayer.connect` first!""" diff --git a/royalnet/serf/discord/voiceplayer.py b/royalnet/serf/discord/voiceplayer.py new file mode 100644 index 00000000..ed335bfc --- /dev/null +++ b/royalnet/serf/discord/voiceplayer.py @@ -0,0 +1,62 @@ +import asyncio +from typing import Optional +from .errors import * +try: + import discord +except ImportError: + discord = None + + +class VoicePlayer: + def __init__(self): + self.voice_client: Optional["discord.VoiceClient"] = None + ... + + async def connect(self, channel: "discord.VoiceChannel") -> "discord.VoiceClient": + """Connect the :class:`VoicePlayer` to a :class:`discord.VoiceChannel`, creating a :class:`discord.VoiceClient` + that handles the connection. + + Args: + channel: The :class:`discord.VoiceChannel` to connect into. + + Returns: + The created :class:`discord.VoiceClient`. + (It will be stored in :attr:`VoicePlayer.voice_client` anyways!) + + Raises: + PlayerAlreadyConnectedError: + DiscordTimeoutError: + GuildAlreadyConnectedError: + OpusNotLoadedError: + """ + if self.voice_client is not None: + raise PlayerAlreadyConnectedError() + try: + self.voice_client = await channel.connect() + except asyncio.TimeoutError: + raise DiscordTimeoutError() + except discord.ClientException: + raise GuildAlreadyConnectedError() + except discord.opus.OpusNotLoaded: + raise OpusNotLoadedError() + return self.voice_client + + async def disconnect(self) -> None: + """Disconnect the :class:`VoicePlayer` from the channel where it is currently connected, and set + :attr:`.voice_client` to :const:`None`. + + Raises: + PlayerNotConnectedError: + """ + if self.voice_client is None: + raise PlayerNotConnectedError() + await self.voice_client.disconnect(force=True) + self.voice_client = None + + async def move(self, channel: "discord.VoiceChannel"): + """Move the :class:`VoicePlayer` to a different channel. + + This requires the :class:`VoicePlayer` to already be connected, and for the passed :class:`discord.VoiceChannel` + to be in the same :class:`discord.Guild` as """ + + ... diff --git a/royalnet/serf/errors.py b/royalnet/serf/errors.py new file mode 100644 index 00000000..6a07c623 --- /dev/null +++ b/royalnet/serf/errors.py @@ -0,0 +1,2 @@ +class SerfError(Exception): + """Base class for all :mod:`royalnet.serf` errors.""" diff --git a/royalnet/serf/serf.py b/royalnet/serf/serf.py index 1b93e64c..106177c7 100644 --- a/royalnet/serf/serf.py +++ b/royalnet/serf/serf.py @@ -148,12 +148,12 @@ class Serf: alchemy: Alchemy = self.alchemy serf: "Serf" = self - async def call_herald_event(ci, destination: str, event_name: str, args: Dict) -> Dict: + async def call_herald_event(ci, destination: str, event_name: str, **kwargs) -> Dict: """Send a :class:`royalherald.Request` to a specific destination, and wait for a :class:`royalherald.Response`.""" if self.herald is None: raise UnsupportedError("`royalherald` is not enabled on this bot.") - request: Request = Request(handler=event_name, data=args) + request: Request = Request(handler=event_name, data=kwargs) response: Response = await self.herald.request(destination=destination, request=request) if isinstance(response, ResponseFailure): # TODO: pretty sure there's a better way to do this diff --git a/royalnet/serf/telegram/telegramserf.py b/royalnet/serf/telegram/telegramserf.py index 44b42a48..682231a5 100644 --- a/royalnet/serf/telegram/telegramserf.py +++ b/royalnet/serf/telegram/telegramserf.py @@ -49,7 +49,7 @@ class TelegramSerf(Serf): herald_config=herald_config, secrets_name=secrets_name) - self.client = telegram.Bot(self.get_secret("telegram"), request=TRequest(5, read_timeout=30)) + self.client = telegram.Bot(self.get_secret("telegram"), request=TRequest(50, read_timeout=30)) """The :class:`telegram.Bot` instance that will be used from the Serf.""" self.update_offset: int = -100 diff --git a/royalnet/utils/__init__.py b/royalnet/utils/__init__.py index de1d360d..f63c88ff 100644 --- a/royalnet/utils/__init__.py +++ b/royalnet/utils/__init__.py @@ -4,6 +4,7 @@ from .sleep_until import sleep_until from .formatters import andformat, underscorize, ytdldateformat, numberemojiformat, ordinalformat from .urluuid import to_urluuid, from_urluuid from .multilock import MultiLock +from .fileaudiosource import FileAudioSource __all__ = [ "asyncify", @@ -17,4 +18,5 @@ __all__ = [ "to_urluuid", "from_urluuid", "MultiLock", + "FileAudioSource", ] diff --git a/royalnet/serf/discord/discordbard/fileaudiosource.py b/royalnet/utils/fileaudiosource.py similarity index 100% rename from royalnet/serf/discord/discordbard/fileaudiosource.py rename to royalnet/utils/fileaudiosource.py