1
Fork 0
mirror of https://github.com/RYGhub/royalnet.git synced 2024-11-27 13:34:28 +00:00

Event API complete, other improvements

This commit is contained in:
Steffo 2019-11-19 16:49:34 +01:00
parent c2a04301ef
commit ae522c5e2a
16 changed files with 233 additions and 186 deletions

View file

@ -47,7 +47,7 @@ def run(telegram: typing.Optional[bool],
royalnet_log: Logger = getLogger("royalnet") royalnet_log: Logger = getLogger("royalnet")
royalnet_log.setLevel(log_level) royalnet_log.setLevel(log_level)
stream_handler = StreamHandler() 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) royalnet_log.addHandler(stream_handler)
def get_secret(username: str): def get_secret(username: str):
@ -85,7 +85,7 @@ def run(telegram: typing.Optional[bool],
secret=get_secret("herald"), secret=get_secret("herald"),
secure=False, secure=False,
path="/") path="/")
herald_process = multiprocessing.Process(name="Herald", herald_process = multiprocessing.Process(name="Herald Server",
target=r.herald.Server(config=herald_config).run_blocking, target=r.herald.Server(config=herald_config).run_blocking,
daemon=True) daemon=True)
herald_process.start() herald_process.start()
@ -103,6 +103,7 @@ def run(telegram: typing.Optional[bool],
enabled_commands = [] enabled_commands = []
enabled_page_stars = [] enabled_page_stars = []
enabled_exception_stars = [] enabled_exception_stars = []
enabled_events = []
for pack in packs: for pack in packs:
imported = importlib.import_module(pack) imported = importlib.import_module(pack)
try: try:
@ -117,20 +118,29 @@ def run(telegram: typing.Optional[bool],
imported_exception_stars = imported.available_exception_stars imported_exception_stars = imported.available_exception_stars
except AttributeError: except AttributeError:
raise click.ClickException(f"{pack} isn't a Royalnet Pack as it is missing available_exception_stars.") 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_commands = [*enabled_commands, *imported_commands]
enabled_page_stars = [*enabled_page_stars, *imported_page_stars] enabled_page_stars = [*enabled_page_stars, *imported_page_stars]
enabled_exception_stars = [*enabled_exception_stars, *imported_exception_stars] enabled_exception_stars = [*enabled_exception_stars, *imported_exception_stars]
enabled_events = [*enabled_events, *imported_events]
telegram_process: typing.Optional[multiprocessing.Process] = None telegram_process: typing.Optional[multiprocessing.Process] = None
if interfaces["telegram"]: if interfaces["telegram"]:
telegram_db_config = r.serf.AlchemyConfig(database_url=alchemy_url, if alchemy_url is not None:
master_table=r.backpack.tables.User, telegram_db_config = r.serf.AlchemyConfig(database_url=alchemy_url,
identity_table=r.backpack.tables.Telegram, master_table=r.backpack.tables.User,
identity_column="tg_id") identity_table=r.backpack.tables.Telegram,
identity_column="tg_id")
else:
telegram_db_config = None
telegram_serf_kwargs = { telegram_serf_kwargs = {
'alchemy_config': telegram_db_config, 'alchemy_config': telegram_db_config,
'commands': enabled_commands, 'commands': enabled_commands,
'network_config': herald_config.copy(name="telegram"), 'events': enabled_events,
'herald_config': herald_config.copy(name="telegram"),
'secrets_name': secrets_name 'secrets_name': secrets_name
} }
telegram_process = multiprocessing.Process(name="Telegram Serf", telegram_process = multiprocessing.Process(name="Telegram Serf",
@ -141,14 +151,18 @@ def run(telegram: typing.Optional[bool],
discord_process: typing.Optional[multiprocessing.Process] = None discord_process: typing.Optional[multiprocessing.Process] = None
if interfaces["discord"]: if interfaces["discord"]:
discord_db_config = r.serf.AlchemyConfig(database_url=alchemy_url, if alchemy_url is not None:
master_table=r.backpack.tables.User, discord_db_config = r.serf.AlchemyConfig(database_url=alchemy_url,
identity_table=r.backpack.tables.Discord, master_table=r.backpack.tables.User,
identity_column="discord_id") identity_table=r.backpack.tables.Discord,
identity_column="discord_id")
else:
discord_db_config = None
discord_serf_kwargs = { discord_serf_kwargs = {
'alchemy_config': discord_db_config, 'alchemy_config': discord_db_config,
'commands': enabled_commands, 'commands': enabled_commands,
'network_config': herald_config.copy(name="discord"), 'events': enabled_events,
'herald_config': herald_config.copy(name="discord"),
'secrets_name': secrets_name 'secrets_name': secrets_name
} }
discord_process = multiprocessing.Process(name="Discord Serf", discord_process = multiprocessing.Process(name="Discord Serf",

View file

@ -29,14 +29,8 @@ class SummonCommand(Command):
member = None member = None
guild = None guild = None
try: try:
await self.interface.call_herald_event("discord", "discordvoice", { # TODO: do something!
"operation": "summon", pass
"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,
}
})
except Exception as e: except Exception as e:
breakpoint() breakpoint()
await data.reply(f"✅ Connesso alla chat vocale.") await data.reply(f"✅ Connesso alla chat vocale.")

View file

@ -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}")

View file

@ -3,8 +3,6 @@ from .ytdlfile import YtdlFile
from .ytdlmp3 import YtdlMp3 from .ytdlmp3 import YtdlMp3
from .ytdldiscord import YtdlDiscord from .ytdldiscord import YtdlDiscord
from .errors import * from .errors import *
from .discordbard import DiscordBard
from . import implementations
try: try:
from .fileaudiosource import FileAudioSource from .fileaudiosource import FileAudioSource
@ -21,6 +19,4 @@ __all__ = [
"NotFoundError", "NotFoundError",
"MultipleFilesError", "MultipleFilesError",
"FileAudioSource", "FileAudioSource",
"implementations",
"DiscordBard",
] ]

View file

@ -25,6 +25,14 @@ except ImportError:
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
UVICORN_LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": True,
"formatters": {},
"handlers": {},
"loggers": {},
}
class Constellation: class Constellation:
"""The class that represents the webserver. """The class that represents the webserver.
@ -66,13 +74,16 @@ class Constellation:
tables = tables.union(SelectedExcStar.tables) tables = tables.union(SelectedExcStar.tables)
log.debug(f"Found Tables: {' '.join([table.__name__ for table in tables])}") log.debug(f"Found Tables: {' '.join([table.__name__ for table in tables])}")
log.info(f"Creating Alchemy...") self.alchemy = None
self.alchemy: royalnet.alchemy.Alchemy = royalnet.alchemy.Alchemy(database_uri=database_uri, tables=tables)
"""The :class:`Alchemy` of this Constellation.""" """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...") log.info("Registering PageStars...")
for SelectedPageStar in page_stars: for SelectedPageStar in page_stars:
log.info(f"Registering: {SelectedPageStar.path} -> {SelectedPageStar.__class__.__name__}") log.info(f"Registering: {SelectedPageStar.path} -> {SelectedPageStar.__qualname__}")
try: try:
page_star_instance = SelectedPageStar(constellation=self) page_star_instance = SelectedPageStar(constellation=self)
except Exception as e: except Exception as e:
@ -82,7 +93,7 @@ class Constellation:
log.info("Registering ExceptionStars...") log.info("Registering ExceptionStars...")
for SelectedExcStar in exc_stars: for SelectedExcStar in exc_stars:
log.info(f"Registering: {SelectedExcStar.error} -> {SelectedExcStar.__class__.__name__}") log.info(f"Registering: {SelectedExcStar.error} -> {SelectedExcStar.__name__}")
try: try:
exc_star_instance = SelectedExcStar(constellation=self) exc_star_instance = SelectedExcStar(constellation=self)
except Exception as e: except Exception as e:
@ -141,10 +152,10 @@ class Constellation:
release=release) release=release)
log.info(f"Sentry: enabled (Royalnet {release})") log.info(f"Sentry: enabled (Royalnet {release})")
# Run the server # Run the server
log.info(f"Running Constellation on {address}:{port}...") log.info(f"Running Constellation on https://{address}:{port}/ ...")
constellation.running = True constellation.running = True
try: try:
uvicorn.run(constellation.starlette, host=address, port=port) uvicorn.run(constellation.starlette, host=address, port=port, log_config=UVICORN_LOGGING_CONFIG)
finally: finally:
constellation.running = False constellation.running = False

View file

@ -85,10 +85,10 @@ class Link:
async def connect(self): async def connect(self):
"""Connect to the :class:`Server` at :attr:`.master_uri`.""" """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.websocket = await websockets.connect(self.config.url, loop=self._loop)
self.connect_event.set() self.connect_event.set()
log.info(f"Connected!") log.debug(f"Connected!")
@requires_connection @requires_connection
async def receive(self) -> Package: async def receive(self) -> Package:
@ -103,7 +103,7 @@ class Link:
self.error_event.set() self.error_event.set()
self.connect_event.clear() self.connect_event.clear()
self.identify_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. # What to do now? Let's just reraise.
raise ConnectionClosedError() raise ConnectionClosedError()
if self.identify_event.is_set() and package.destination != self.nid: if self.identify_event.is_set() and package.destination != self.nid:
@ -113,7 +113,7 @@ class Link:
@requires_connection @requires_connection
async def identify(self) -> None: 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}") await self.websocket.send(f"Identify {self.nid}:{self.config.name}:{self.config.secret}")
response: Package = await self.receive() response: Package = await self.receive()
if not response.source == "<server>": if not response.source == "<server>":
@ -124,7 +124,7 @@ class Link:
raise ConnectionClosedError(f"Identification error: {response.data['type']}") raise ConnectionClosedError(f"Identification error: {response.data['type']}")
assert response.data["type"] == "success" assert response.data["type"] == "success"
self.identify_event.set() self.identify_event.set()
log.info(f"Identified successfully!") log.debug(f"Identified successfully!")
@requires_identification @requires_identification
async def send(self, package: Package): async def send(self, package: Package):

View file

@ -62,28 +62,30 @@ class Server:
return matching or [] return matching or []
async def listener(self, websocket: "websockets.server.WebSocketServerProtocol", path): async def listener(self, websocket: "websockets.server.WebSocketServerProtocol", path):
log.info(f"{websocket.remote_address} connected to the server.")
connected_client = ConnectedClient(websocket) connected_client = ConnectedClient(websocket)
# Wait for identification # Wait for identification
identify_msg = await websocket.recv() identify_msg = await websocket.recv()
log.debug(f"{websocket.remote_address} identified itself with: {identify_msg}.") log.debug(f"{websocket.remote_address} identified itself with: {identify_msg}.")
if not isinstance(identify_msg, str): 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)") await connected_client.send_service("error", "Invalid identification message (not a str)")
return return
identification = re.match(r"Identify ([^:\s]+):([^:\s]+):([^:\s]+)", identify_msg) identification = re.match(r"Identify ([^:\s]+):([^:\s]+):([^:\s]+)", identify_msg)
if identification is None: 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)") await connected_client.send_service("error", "Invalid identification message (regex failed)")
return return
secret = identification.group(3) secret = identification.group(3)
if secret != self.config.secret: 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") await connected_client.send_service("error", "Invalid secret")
return return
# Identification successful # Identification successful
connected_client.nid = identification.group(1) connected_client.nid = identification.group(1)
connected_client.link_type = identification.group(2) 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) 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!") await connected_client.send_service("success", "Identification successful!")
log.debug(f"{connected_client.nid}'s identification confirmed.") log.debug(f"{connected_client.nid}'s identification confirmed.")
# Main loop # Main loop

View file

@ -1,9 +1,11 @@
from .createrichembed import create_rich_embed from .createrichembed import create_rich_embed
from .escape import escape from .escape import escape
from .discordserf import DiscordSerf from .discordserf import DiscordSerf
from . import discordbard
__all__ = [ __all__ = [
"create_rich_embed", "create_rich_embed",
"escape", "escape",
"DiscordSerf", "DiscordSerf",
"discordbard",
] ]

View file

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

View file

@ -1,7 +1,9 @@
from .discordbard import DiscordBard
from .dbstack import DBStack from .dbstack import DBStack
from .dbqueue import DBQueue from .dbqueue import DBQueue
__all__ = [ __all__ = [
"DBStack", "DBStack",
"DBQueue", "DBQueue",
"DiscordBard",
] ]

View file

@ -1,15 +1,19 @@
from ..fileaudiosource import FileAudioSource from royalnet.bard import FileAudioSource, YtdlDiscord
from ..discordbard import DiscordBard
from ..ytdldiscord import YtdlDiscord
from typing import List, AsyncGenerator, Tuple, Any, Dict, Optional from typing import List, AsyncGenerator, Tuple, Any, Dict, Optional
from .discordbard import DiscordBard
try:
import discord
except ImportError:
discord = None
class DBQueue(DiscordBard): class DBQueue(DiscordBard):
"""A First-In-First-Out music queue. """A First-In-First-Out music queue.
It is what was once called a ``playlist``.""" It is what was once called a ``playlist``."""
def __init__(self): def __init__(self, voice_client: "discord.VoiceClient"):
super().__init__() super().__init__(voice_client)
self.list: List[YtdlDiscord] = [] self.list: List[YtdlDiscord] = []
async def _generator(self) -> AsyncGenerator[Optional[FileAudioSource], Tuple[Tuple[Any, ...], Dict[str, Any]]]: async def _generator(self) -> AsyncGenerator[Optional[FileAudioSource], Tuple[Tuple[Any, ...], Dict[str, Any]]]:

View file

@ -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 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): class DBStack(DiscordBard):
"""A First-In-Last-Out music queue. """A First-In-Last-Out music queue.
Not really sure if it is going to be useful...""" Not really sure if it is going to be useful..."""
def __init__(self): def __init__(self, voice_client: "discord.VoiceClient"):
super().__init__() super().__init__(voice_client)
self.list: List[YtdlDiscord] = [] self.list: List[YtdlDiscord] = []
async def _generator(self) -> AsyncGenerator[Optional[FileAudioSource], Tuple[Tuple[Any, ...], Dict[str, Any]]]: async def _generator(self) -> AsyncGenerator[Optional[FileAudioSource], Tuple[Tuple[Any, ...], Dict[str, Any]]]:

View file

@ -1,7 +1,10 @@
from typing import Optional, AsyncGenerator, List, Tuple, Any, Dict from typing import Optional, AsyncGenerator, List, Tuple, Any, Dict
from .ytdldiscord import YtdlDiscord from royalnet.bard import YtdlDiscord, FileAudioSource, UnsupportedError
from .fileaudiosource import FileAudioSource
from .errors import UnsupportedError try:
import discord
except ImportError:
discord = None
class DiscordBard: class DiscordBard:
@ -9,11 +12,14 @@ class DiscordBard:
Possible implementation may be playlist, song pools, multilayered tracks, and so on.""" 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`. """Create manually a :class:`DiscordBard`.
Warning: Warning:
Avoid calling this method, please use :meth:`create` instead!""" 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 self.now_playing: Optional[YtdlDiscord] = None
"""The :class:`YtdlDiscord` that's currently being played.""" """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. it can take a args+kwargs tuple in input to optionally select a different source.
The generator should ``yield`` once before doing anything else.""" The generator should ``yield`` once before doing anything else."""
args, kwargs = yield yield
raise NotImplementedError() raise NotImplementedError()
@classmethod @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.""" """Create an instance of the :class:`DiscordBard`, and initialize its async generator."""
bard = cls() bard = cls(voice_client=voice_client)
# noinspection PyTypeChecker # noinspection PyTypeChecker
none = bard.generator.asend(None) none = bard.generator.asend(None)
assert none is None assert none is None

View file

@ -3,8 +3,11 @@ import logging
from typing import Type, Optional, List, Union from typing import Type, Optional, List, Union
from royalnet.commands import * from royalnet.commands import *
from royalnet.utils import asyncify from royalnet.utils import asyncify
from royalnet.serf import Serf
from .escape import escape from .escape import escape
from ..serf import Serf from .discordbard import *
from .barddict import BardsDict
try: try:
import discord import discord
@ -33,6 +36,7 @@ class DiscordSerf(Serf):
def __init__(self, *, def __init__(self, *,
alchemy_config: Optional[AlchemyConfig] = None, alchemy_config: Optional[AlchemyConfig] = None,
commands: List[Type[Command]] = None, commands: List[Type[Command]] = None,
events: List[Type[Event]] = None,
herald_config: Optional[HeraldConfig] = None, herald_config: Optional[HeraldConfig] = None,
secrets_name: str = "__default__"): secrets_name: str = "__default__"):
if discord is None: if discord is None:
@ -40,6 +44,7 @@ class DiscordSerf(Serf):
super().__init__(alchemy_config=alchemy_config, super().__init__(alchemy_config=alchemy_config,
commands=commands, commands=commands,
events=events,
herald_config=herald_config, herald_config=herald_config,
secrets_name=secrets_name) secrets_name=secrets_name)
@ -47,7 +52,10 @@ class DiscordSerf(Serf):
"""The custom :class:`discord.Client` class that will be instantiated later.""" """The custom :class:`discord.Client` class that will be instantiated later."""
self.client = self.Client() 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]: def interface_factory(self) -> Type[CommandInterface]:
# noinspection PyPep8Naming # noinspection PyPep8Naming
@ -144,15 +152,99 @@ class DiscordSerf(Serf):
return DiscordClient 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): async def run(self):
await super().run() await super().run()
token = self.get_secret("discord") token = self.get_secret("discord")
if token is None:
raise ValueError("Missing discord token")
await self.client.login(token) await self.client.login(token)
await self.client.connect() 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)

View file

@ -103,7 +103,7 @@ class Serf:
log.info("Herald: disabled") log.info("Herald: disabled")
else: else:
self.init_herald(herald_config, events) 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 self.loop: Optional[AbstractEventLoop] = None
"""The event loop this Serf is running on.""" """The event loop this Serf is running on."""
@ -316,4 +316,8 @@ class Serf:
serf.init_sentry(sentry_dsn) serf.init_sentry(sentry_dsn)
serf.loop = get_event_loop() 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)

View file

@ -1,8 +1,7 @@
import logging import logging
import asyncio import asyncio
from typing import Type, Optional, List, Callable from typing import Type, Optional, List, Callable
from royalnet.commands import Command, CommandInterface, CommandData, CommandArgs, CommandError, InvalidInputError, \ from royalnet.commands import *
UnsupportedError
from royalnet.utils import asyncify from royalnet.utils import asyncify
from .escape import escape from .escape import escape
from ..serf import Serf from ..serf import Serf
@ -38,6 +37,7 @@ class TelegramSerf(Serf):
def __init__(self, *, def __init__(self, *,
alchemy_config: Optional[AlchemyConfig] = None, alchemy_config: Optional[AlchemyConfig] = None,
commands: List[Type[Command]] = None, commands: List[Type[Command]] = None,
events: List[Type[Event]] = None,
herald_config: Optional[HeraldConfig] = None, herald_config: Optional[HeraldConfig] = None,
secrets_name: str = "__default__"): secrets_name: str = "__default__"):
if telegram is None: if telegram is None:
@ -45,6 +45,7 @@ class TelegramSerf(Serf):
super().__init__(alchemy_config=alchemy_config, super().__init__(alchemy_config=alchemy_config,
commands=commands, commands=commands,
events=events,
herald_config=herald_config, herald_config=herald_config,
secrets_name=secrets_name) secrets_name=secrets_name)