1
Fork 0
mirror of https://github.com/RYGhub/royalnet.git synced 2024-11-27 13:34:28 +00:00
This commit is contained in:
Steffo 2019-11-22 16:00:44 +01:00
parent 71076a39a9
commit bf70ceafe7
19 changed files with 121 additions and 234 deletions

View file

@ -6,8 +6,10 @@ Here are some things that were found out while developing the bot.
Discord websocket undocumented error codes Discord websocket undocumented error codes
------------------------------------------ ------------------------------------------
====== =================== ====== =====================
Code Reason Code Reason
====== =================== ====== =====================
1006 Heartbeat stopped 1006 Heartbeat stopped
====== =================== ------ ---------------------
1006 Failed authentication
====== =====================

View file

@ -12,7 +12,3 @@ class NotFoundError(YtdlError):
class MultipleFilesError(YtdlError): class MultipleFilesError(YtdlError):
"""The resource contains multiple media files.""" """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."""

View file

@ -3,9 +3,8 @@ import re
import os import os
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from royalnet.utils import asyncify, MultiLock from royalnet.utils import asyncify, MultiLock, FileAudioSource
from royalnet.bard import YtdlInfo, YtdlFile from royalnet.bard import YtdlInfo, YtdlFile
from .fileaudiosource import FileAudioSource
try: try:
import ffmpeg import ffmpeg

View file

@ -39,6 +39,8 @@ class CommandInterface:
def __init__(self): def __init__(self):
self.command: Optional[Command] = None # Will be bound after the command has been created 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: async def call_herald_event(self, destination: str, event_name: str, **kwargs) -> dict:
# TODO: document this """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") raise UnsupportedError(f"{self.call_herald_event.__name__} is not supported on this platform")

View file

@ -1,10 +1,12 @@
from .serf import Serf from .serf import Serf
from .alchemyconfig import AlchemyConfig from .alchemyconfig import AlchemyConfig
from .errors import SerfError
from . import telegram, discord from . import telegram, discord
__all__ = [ __all__ = [
"Serf", "Serf",
"AlchemyConfig", "AlchemyConfig",
"SerfError",
"telegram", "telegram",
"discord", "discord",
] ]

View file

@ -1,6 +1,3 @@
from typing import TYPE_CHECKING
class AlchemyConfig: class AlchemyConfig:
"""A helper class to configure :class:`Alchemy` in a :class:`Serf`.""" """A helper class to configure :class:`Alchemy` in a :class:`Serf`."""
def __init__(self, def __init__(self,

View file

@ -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

View file

@ -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",
]

View file

@ -1,4 +0,0 @@
from discord import Embed, Colour
from discord.embeds import EmptyEmbed
from royalnet.bard import YtdlInfo

View file

@ -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()

View file

@ -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())

View file

@ -1,12 +1,10 @@
import asyncio import asyncio
import logging import logging
from typing import Type, Optional, List, Union from typing import Type, Optional, List, Union, Dict
from royalnet.commands import * from royalnet.commands import *
from royalnet.utils import asyncify from royalnet.utils import asyncify
from royalnet.serf import Serf from royalnet.serf import Serf
from .escape import escape from .escape import escape
from .discordbard import *
from .barddict import BardsDict
try: try:
@ -54,9 +52,6 @@ class DiscordSerf(Serf):
self.client = self.Client() self.client = self.Client()
"""The custom :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]: def interface_factory(self) -> Type[CommandInterface]:
# noinspection PyPep8Naming # noinspection PyPep8Naming
GenericInterface = super().interface_factory() GenericInterface = super().interface_factory()
@ -201,7 +196,7 @@ class DiscordSerf(Serf):
pass pass
ch_guild: "discord.Guild" = ch.guild ch_guild: "discord.Guild" = ch.guild
if ch.guild != ch_guild: if guild is not None and guild != ch_guild:
continue continue
for user in accessible_to: for user in accessible_to:
@ -230,25 +225,6 @@ class DiscordSerf(Serf):
return channels[0] 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"): async def voice_run(self, guild: "discord.Guild"):
"""Send the data from the bard to the voice websocket for a specific client.""" """Send the data from the bard to the voice websocket for a specific client."""
bard: Optional[DiscordBard] = self.bards.get(guild) bard: Optional[DiscordBard] = self.bards.get(guild)

View file

@ -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
<https://discordpy.readthedocs.io/en/latest/api.html#discord.VoiceClient>` 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!"""

View file

@ -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 """
...

2
royalnet/serf/errors.py Normal file
View file

@ -0,0 +1,2 @@
class SerfError(Exception):
"""Base class for all :mod:`royalnet.serf` errors."""

View file

@ -148,12 +148,12 @@ class Serf:
alchemy: Alchemy = self.alchemy alchemy: Alchemy = self.alchemy
serf: "Serf" = self 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 """Send a :class:`royalherald.Request` to a specific destination, and wait for a
:class:`royalherald.Response`.""" :class:`royalherald.Response`."""
if self.herald is None: if self.herald is None:
raise UnsupportedError("`royalherald` is not enabled on this bot.") 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) response: Response = await self.herald.request(destination=destination, request=request)
if isinstance(response, ResponseFailure): if isinstance(response, ResponseFailure):
# TODO: pretty sure there's a better way to do this # TODO: pretty sure there's a better way to do this

View file

@ -49,7 +49,7 @@ class TelegramSerf(Serf):
herald_config=herald_config, herald_config=herald_config,
secrets_name=secrets_name) 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.""" """The :class:`telegram.Bot` instance that will be used from the Serf."""
self.update_offset: int = -100 self.update_offset: int = -100

View file

@ -4,6 +4,7 @@ from .sleep_until import sleep_until
from .formatters import andformat, underscorize, ytdldateformat, numberemojiformat, ordinalformat from .formatters import andformat, underscorize, ytdldateformat, numberemojiformat, ordinalformat
from .urluuid import to_urluuid, from_urluuid from .urluuid import to_urluuid, from_urluuid
from .multilock import MultiLock from .multilock import MultiLock
from .fileaudiosource import FileAudioSource
__all__ = [ __all__ = [
"asyncify", "asyncify",
@ -17,4 +18,5 @@ __all__ = [
"to_urluuid", "to_urluuid",
"from_urluuid", "from_urluuid",
"MultiLock", "MultiLock",
"FileAudioSource",
] ]