1
Fork 0
mirror of https://github.com/RYGhub/royalnet.git synced 2024-11-23 19:44:20 +00:00

Merge branch '5.1-events' into 5.1

This commit is contained in:
Steffo 2019-11-19 16:50:52 +01:00
commit 48974967e8
21 changed files with 323 additions and 201 deletions

View file

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

View file

@ -1,16 +1,19 @@
"""A Pack that is imported by default by all :mod:`royalnet` instances."""
from . import commands, tables, stars
from . import commands, tables, stars, events
from .commands import available_commands
from .tables import available_tables
from .stars import available_page_stars, available_exception_stars
from .events import available_events
__all__ = [
"commands",
"tables",
"stars",
"events",
"available_commands",
"available_tables",
"available_page_stars",
"available_exception_stars",
"available_events",
]

View file

@ -12,6 +12,8 @@ if TYPE_CHECKING:
class SummonCommand(Command):
# TODO: possibly move this in another pack
name: str = "summon"
description = "Connect the bot to a Discord voice channel."
@ -19,89 +21,16 @@ class SummonCommand(Command):
syntax = "[channelname]"
async def run(self, args: CommandArgs, data: CommandData) -> None:
# This command only runs on Discord!
if self.interface.name != "discord":
# TODO: use a Herald Event to remotely connect the bot
raise UnsupportedError()
if discord is None:
raise ConfigurationError("'discord' extra is not installed.")
# noinspection PyUnresolvedReferences
message: discord.Message = data.message
member: Union[discord.User, discord.Member] = message.author
serf: DiscordSerf = self.interface.serf
client: discord.Client = serf.client
channel_name: Optional[str] = args.joined()
# If the channel name was passed as an argument...
if channel_name != "":
# Try to find all possible channels
channels: List[discord.VoiceChannel] = []
for ch in client.get_all_channels():
guild: discord.Guild = ch.guild
# Ensure the channel is a voice channel
if not isinstance(ch, discord.VoiceChannel):
continue
# Ensure the channel starts with the requested name
ch_name: str = ch.name
if not ch_name.startswith(channel_name):
continue
# Ensure that the command author can access the channel
if guild.get_member(member.id) is None:
continue
member_permissions: discord.Permissions = ch.permissions_for(member)
if not (member_permissions.connect and member_permissions.speak):
continue
# Ensure that the bot can access the channel
bot_member = guild.get_member(client.user.id)
bot_permissions: discord.Permissions = ch.permissions_for(bot_member)
if not (bot_permissions.connect and bot_permissions.speak):
continue
# Found one!
channels.append(ch)
# Ensure at least a single channel is returned
if len(channels) == 0:
raise InvalidInputError("Could not find any channel to connect to.")
elif len(channels) == 1:
channel = channels[0]
if self.interface.name == "discord":
msg: Optional["discord.Message"] = data.message
member: Optional["discord.Member"] = msg.author
guild: Optional["discord.Guild"] = msg.guild
else:
# Give priority to channels in the current guild
filter_by_guild = False
for ch in channels:
if ch.guild == message.guild:
filter_by_guild = True
break
if filter_by_guild:
new_channels = []
for ch in channels:
if ch.guild == message.guild:
new_channels.append(ch)
channels = new_channels
# Give priority to channels with the most people
def people_count(c: discord.VoiceChannel):
return len(c.members)
channels.sort(key=people_count, reverse=True)
channel = channels[0]
else:
# Try to use the channel in which the command author is in
voice: Optional[discord.VoiceState] = message.author.voice
if voice is None:
raise UserError("You must be connected to a voice channel to summon the bot without any arguments.")
channel: discord.VoiceChannel = voice.channel
# Try to connect to the voice channel
member = None
guild = None
try:
await channel.connect()
except asyncio.TimeoutError:
raise ExternalError("Timed out while trying to connect to the channel")
except discord.opus.OpusNotLoaded:
raise ConfigurationError("[c]libopus[/c] is not loaded in the serf")
except discord.ClientException as e:
# The bot is already connected to a voice channel
# TODO: safely move the bot somewhere else
raise CommandError("The bot is already connected in another channel.")
await data.reply(f"✅ Connected to <#{channel.id}>!")
# TODO: do something!
pass
except Exception as e:
breakpoint()
await data.reply(f"✅ Connesso alla chat vocale.")

View file

@ -0,0 +1,10 @@
# Imports go here!
# Enter the commands of your Pack here!
available_events = [
]
# Don't change this, it should automatically generate __all__
__all__ = [command.__name__ for command in available_events]

View file

View file

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

View file

@ -2,6 +2,7 @@ from .commandinterface import CommandInterface
from .command import Command
from .commanddata import CommandData
from .commandargs import CommandArgs
from .event import Event
from .errors import CommandError, \
InvalidInputError, \
UnsupportedError, \
@ -20,4 +21,5 @@ __all__ = [
"ConfigurationError",
"ExternalError",
"UserError",
"Event"
]

View file

@ -27,28 +27,18 @@ class CommandInterface:
A reference to a :class:`~royalnet.serf.telegram.TelegramSerf`."""
@property
def alchemy(self):
def alchemy(self) -> "Alchemy":
"""A shortcut for :attr:`serf.alchemy`."""
return self.serf.alchemy
@property
def loop(self):
def loop(self) -> AbstractEventLoop:
"""A shortcut for :attr:`serf.loop`."""
return self.serf.loop
def __init__(self):
self.command: Optional[Command] = None # Will be bound after the command has been created
def register_herald_action(self,
event_name: str,
coroutine: Callable[[Any], Awaitable[dict]]):
async def call_herald_event(self, destination: str, event_name: str, args: dict) -> dict:
# TODO: document this
raise UnsupportedError(f"{self.register_herald_action.__name__} is not supported on this platform")
def unregister_herald_action(self, event_name: str):
# TODO: document this
raise UnsupportedError(f"{self.unregister_herald_action.__name__} is not supported on this platform")
async def call_herald_action(self, destination: str, event_name: str, args: dict) -> dict:
# TODO: document this
raise UnsupportedError(f"{self.call_herald_action.__name__} is not supported on this platform")
raise UnsupportedError(f"{self.call_herald_event.__name__} is not supported on this platform")

View file

@ -0,0 +1,30 @@
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from serf import Serf
class Event:
"""A remote procedure call triggered by a :mod:`royalnet.herald` request."""
name = NotImplemented
"""The event_name that will trigger this event."""
tables: set = set()
"""A set of :mod:`royalnet.alchemy` tables that must exist for this event to work."""
def __init__(self, serf: "Serf"):
"""Bind the event to a :class:`~royalnet.serf.Serf`."""
self.serf: "Serf" = serf
@property
def alchemy(self):
"""A shortcut for :attr:`.serf.alchemy`."""
return self.serf.alchemy
@property
def loop(self):
"""A shortcut for :attr:`.serf.loop`"""
return self.serf.loop
async def run(self, **kwargs):
raise NotImplementedError()

View file

@ -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])}")
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)
"""The :class:`Alchemy` of this Constellation."""
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

View file

@ -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 == "<server>":
@ -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):

View file

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

View file

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

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 .dbqueue import DBQueue
__all__ = [
"DBStack",
"DBQueue",
"DiscordBard",
]

View file

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

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 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]]]:

View file

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

View file

@ -3,8 +3,11 @@ import logging
from typing import Type, Optional, List, Union
from royalnet.commands import *
from royalnet.utils import asyncify
from royalnet.serf import Serf
from .escape import escape
from ..serf import Serf
from .discordbard import *
from .barddict import BardsDict
try:
import discord
@ -33,21 +36,26 @@ class DiscordSerf(Serf):
def __init__(self, *,
alchemy_config: Optional[AlchemyConfig] = None,
commands: List[Type[Command]] = None,
network_config: Optional[HeraldConfig] = None,
events: List[Type[Event]] = None,
herald_config: Optional[HeraldConfig] = None,
secrets_name: str = "__default__"):
if discord is None:
raise ImportError("'discord' extra is not installed")
super().__init__(alchemy_config=alchemy_config,
commands=commands,
network_config=network_config,
events=events,
herald_config=herald_config,
secrets_name=secrets_name)
self.Client = self.bot_factory()
self.Client = self.client_factory()
"""The custom :class:`discord.Client` class that will be instantiated later."""
self.client = self.Client()
"""The custo :class:`discord.Client` instance."""
"""The custom :class:`discord.Client` instance."""
self.bards: BardsDict = BardsDict(self.client)
"""A dictionary containing all bards spawned by this :class:`DiscordSerf`."""
def interface_factory(self) -> Type[CommandInterface]:
# noinspection PyPep8Naming
@ -129,7 +137,7 @@ class DiscordSerf(Serf):
if session is not None:
await asyncify(session.close)
def bot_factory(self) -> Type["discord.Client"]:
def client_factory(self) -> Type["discord.Client"]:
"""Create a custom class inheriting from :py:class:`discord.Client`."""
# noinspection PyMethodParameters
class DiscordClient(discord.Client):
@ -144,15 +152,99 @@ class DiscordSerf(Serf):
return DiscordClient
def get_voice_client(self, guild: "discord.Guild") -> Optional["discord.VoiceClient"]:
voice_clients: List["discord.VoiceClient"] = self.client.voice_clients
for voice_client in voice_clients:
if voice_client.guild == guild:
return voice_client
return None
async def run(self):
await super().run()
token = self.get_secret("discord")
if token is None:
raise ValueError("Missing discord token")
await self.client.login(token)
await self.client.connect()
async def find_channel(self,
channel_type: Optional[Type["discord.abc.GuildChannel"]] = None,
name: Optional[str] = None,
guild: Optional["discord.Guild"] = None,
accessible_to: List["discord.User"] = None,
required_permissions: List[str] = None) -> Optional["discord.abc.GuildChannel"]:
"""Find the best channel matching all requests.
In case multiple channels match all requests, return the one with the most members connected.
Args:
channel_type: Filter channels by type (select only :class:`discord.VoiceChannel`,
:class:`discord.TextChannel`, ...).
name: Filter channels by name starting with ``name`` (using :meth:`str.startswith`).
Note that some channel types don't have names; this check will be skipped for them.
guild: Filter channels by guild, keep only channels inside this one.
accessible_to: Filter channels by permissions, keeping only channels where *all* these users have
the required permissions.
required_permissions: Filter channels by permissions, keeping only channels where the users have *all* these
:class:`discord.Permissions`.
Returns:
Either a :class:`~discord.abc.GuildChannel`, or :const:`None` if no channels were found."""
if accessible_to is None:
accessible_to = []
if required_permissions is None:
required_permissions = []
channels: List[discord.abc.GuildChannel] = []
for ch in self.client.get_all_channels():
if channel_type is not None and not isinstance(ch, channel_type):
continue
if name is not None:
try:
ch_name: str = ch.name
if not ch_name.startswith(name):
continue
except AttributeError:
pass
ch_guild: "discord.Guild" = ch.guild
if ch.guild == ch_guild:
continue
for user in accessible_to:
member: "discord.Member" = guild.get_member(user.id)
if member is None:
continue
permissions: "discord.Permissions" = ch.permissions_for(member)
missing_perms = False
for permission in required_permissions:
if not permissions.__getattribute__(permission):
missing_perms = True
break
if missing_perms:
continue
channels.append(ch)
if len(channels) == 0:
return None
else:
# Give priority to channels with the most people
def people_count(c: discord.VoiceChannel):
return len(c.members)
channels.sort(key=people_count, reverse=True)
return channels[0]
async def voice_connect(self, channel: "discord.VoiceChannel"):
"""Try to connect to a :class:`discord.VoiceChannel` and to create the corresponing :class:`DiscordBard`.
Info:
Command-compatible! This method will raise :exc:`CommandError`s for all its errors, so it can be called
inside a command!"""
try:
voice_client = await channel.connect()
except asyncio.TimeoutError:
raise ExternalError("Timed out while trying to connect to the channel")
except discord.opus.OpusNotLoaded:
raise ConfigurationError("[c]libopus[/c] is not loaded in the serf")
except discord.ClientException:
# The bot is already connected to a voice channel
# TODO: safely move the bot somewhere else
raise CommandError("The bot is already connected in another channel.\n"
" Please disconnect it before resummoning!")
self.bards[channel.guild] = DBQueue(voice_client=voice_client)

View file

@ -47,7 +47,8 @@ class Serf:
def __init__(self, *,
alchemy_config: Optional[AlchemyConfig] = None,
commands: List[Type[Command]] = None,
network_config: Optional[HeraldConfig] = None,
events: List[Type[Event]] = None,
herald_config: Optional[HeraldConfig] = None,
secrets_name: str = "__default__"):
self.secrets_name = secrets_name
@ -87,23 +88,22 @@ class Serf:
self.register_commands(commands)
log.info(f"Commands: total {len(self.commands)}")
self.herald_handlers: Dict[str, Callable[["Serf", Any], Awaitable[Optional[dict]]]] = {}
"""A :class:`dict` linking :class:`Request` event names to coroutines returning a :class:`dict` that will be
sent as :class:`Response` to the event."""
self.herald: Optional[Link] = None
"""The :class:`Link` object connecting the Serf to the rest of the herald network."""
self.herald_task: Optional[Task] = None
"""A reference to the :class:`asyncio.Task` that runs the :class:`Link`."""
self.events: Dict[str, Event] = {}
"""A dictionary containing all :class:`Event` that can be handled by this :class:`Serf`."""
if Link is None:
log.info("Herald: not installed")
elif network_config is None:
elif herald_config is None:
log.info("Herald: disabled")
else:
self.init_network(network_config)
log.info(f"Herald: {self.herald}")
self.init_herald(herald_config, events)
log.info(f"Herald: {len(self.events)} events bound")
self.loop: Optional[AbstractEventLoop] = None
"""The event loop this Serf is running on."""
@ -148,22 +148,7 @@ class Serf:
alchemy: Alchemy = self.alchemy
serf: "Serf" = self
def register_herald_action(ci,
event_name: str,
coroutine: Callable[[Any], Awaitable[Dict]]) -> None:
"""Allow a coroutine to be called when a :class:`royalherald.Request` is received."""
if self.herald is None:
raise UnsupportedError("`royalherald` is not enabled on this bot.")
self.herald_handlers[event_name] = coroutine
def unregister_herald_action(ci, event_name: str):
"""Disable a previously registered coroutine from being called on reception of a
:class:`royalherald.Request`."""
if self.herald is None:
raise UnsupportedError("`royalherald` is not enabled on this bot.")
del self.herald_handlers[event_name]
async def call_herald_action(ci, destination: str, event_name: str, args: Dict) -> Dict:
async def call_herald_event(ci, destination: str, event_name: str, args: Dict) -> Dict:
"""Send a :class:`royalherald.Request` to a specific destination, and wait for a
:class:`royalherald.Response`."""
if self.herald is None:
@ -228,34 +213,36 @@ class Serf:
else:
log.warning(f"Ignoring (already defined): {SelectedCommand.__qualname__} -> {interface.prefix}{alias}")
def init_network(self, config: HeraldConfig):
def init_herald(self, config: HeraldConfig, events: List[Type[Event]]):
"""Create a :py:class:`Link`, and run it as a :py:class:`asyncio.Task`."""
log.debug(f"Initializing herald...")
self.herald: Link = Link(config, self.network_handler)
log.debug(f"Binding events...")
for SelectedEvent in events:
log.debug(f"Binding event: {SelectedEvent.name}.")
self.events[SelectedEvent.name] = SelectedEvent(self)
async def network_handler(self, message: Union[Request, Broadcast]) -> Response:
try:
network_handler = self.herald_handlers[message.handler]
event: Event = self.events[message.handler]
except KeyError:
log.warning(f"Missing network_handler for {message.handler}")
return ResponseFailure("no_handler", f"This bot is missing a network handler for {message.handler}.")
else:
log.debug(f"Using {network_handler} as handler for {message.handler}")
log.warning(f"No event for '{message.handler}'")
return ResponseFailure("no_event", f"This serf does not have any event for {message.handler}.")
log.debug(f"Event called: {event.name}")
if isinstance(message, Request):
try:
response_data = await network_handler(self, **message.data)
response_data = await event.run(**message.data)
return ResponseSuccess(data=response_data)
except Exception as e:
sentry_sdk.capture_exception(e)
log.error(f"Exception {e} in {network_handler}")
return ResponseFailure("exception_in_handler",
f"An exception was raised in {network_handler} for {message.handler}.",
log.error(f"Event error: {e.__class__.__qualname__} in {event.name}")
return ResponseFailure("exception_in_event",
f"An exception was raised in the event for '{message.handler}'.",
extra_info={
"type": e.__class__.__name__,
"type": e.__class__.__qualname__,
"message": str(e)
})
elif isinstance(message, Broadcast):
await network_handler(self, **message.data)
await event.run(**message.data)
@staticmethod
def init_sentry(dsn):
@ -329,4 +316,8 @@ class Serf:
serf.init_sentry(sentry_dsn)
serf.loop = get_event_loop()
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 asyncio
from typing import Type, Optional, List, Callable
from royalnet.commands import Command, CommandInterface, CommandData, CommandArgs, CommandError, InvalidInputError, \
UnsupportedError
from royalnet.commands import *
from royalnet.utils import asyncify
from .escape import escape
from ..serf import Serf
@ -38,14 +37,16 @@ class TelegramSerf(Serf):
def __init__(self, *,
alchemy_config: Optional[AlchemyConfig] = None,
commands: List[Type[Command]] = None,
network_config: Optional[HeraldConfig] = None,
events: List[Type[Event]] = None,
herald_config: Optional[HeraldConfig] = None,
secrets_name: str = "__default__"):
if telegram is None:
raise ImportError("'telegram' extra is not installed")
super().__init__(alchemy_config=alchemy_config,
commands=commands,
network_config=network_config,
events=events,
herald_config=herald_config,
secrets_name=secrets_name)
self.client = telegram.Bot(self.get_secret("telegram"), request=TRequest(5, read_timeout=30))