1
Fork 0
mirror of https://github.com/RYGhub/royalnet.git synced 2024-11-27 13:34:28 +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: 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"]:
if alchemy_url is not None:
telegram_db_config = r.serf.AlchemyConfig(database_url=alchemy_url, telegram_db_config = r.serf.AlchemyConfig(database_url=alchemy_url,
master_table=r.backpack.tables.User, master_table=r.backpack.tables.User,
identity_table=r.backpack.tables.Telegram, identity_table=r.backpack.tables.Telegram,
identity_column="tg_id") 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"]:
if alchemy_url is not None:
discord_db_config = r.serf.AlchemyConfig(database_url=alchemy_url, discord_db_config = r.serf.AlchemyConfig(database_url=alchemy_url,
master_table=r.backpack.tables.User, master_table=r.backpack.tables.User,
identity_table=r.backpack.tables.Discord, identity_table=r.backpack.tables.Discord,
identity_column="discord_id") 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

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

View file

@ -12,6 +12,8 @@ if TYPE_CHECKING:
class SummonCommand(Command): class SummonCommand(Command):
# TODO: possibly move this in another pack
name: str = "summon" name: str = "summon"
description = "Connect the bot to a Discord voice channel." description = "Connect the bot to a Discord voice channel."
@ -19,89 +21,16 @@ class SummonCommand(Command):
syntax = "[channelname]" syntax = "[channelname]"
async def run(self, args: CommandArgs, data: CommandData) -> None: async def run(self, args: CommandArgs, data: CommandData) -> None:
# This command only runs on Discord! if self.interface.name == "discord":
if self.interface.name != "discord": msg: Optional["discord.Message"] = data.message
# TODO: use a Herald Event to remotely connect the bot member: Optional["discord.Member"] = msg.author
raise UnsupportedError() guild: Optional["discord.Guild"] = msg.guild
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]
else: else:
# Give priority to channels in the current guild member = None
filter_by_guild = False guild = None
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
try: try:
await channel.connect() # TODO: do something!
except asyncio.TimeoutError: pass
raise ExternalError("Timed out while trying to connect to the channel") except Exception as e:
except discord.opus.OpusNotLoaded: breakpoint()
raise ConfigurationError("[c]libopus[/c] is not loaded in the serf") await data.reply(f"✅ Connesso alla chat vocale.")
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}>!")

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

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

View file

@ -27,28 +27,18 @@ class CommandInterface:
A reference to a :class:`~royalnet.serf.telegram.TelegramSerf`.""" A reference to a :class:`~royalnet.serf.telegram.TelegramSerf`."""
@property @property
def alchemy(self): def alchemy(self) -> "Alchemy":
"""A shortcut for :attr:`serf.alchemy`.""" """A shortcut for :attr:`serf.alchemy`."""
return self.serf.alchemy return self.serf.alchemy
@property @property
def loop(self): def loop(self) -> AbstractEventLoop:
"""A shortcut for :attr:`serf.loop`.""" """A shortcut for :attr:`serf.loop`."""
return self.serf.loop return self.serf.loop
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
def register_herald_action(self, async def call_herald_event(self, destination: str, event_name: str, args: dict) -> dict:
event_name: str,
coroutine: Callable[[Any], Awaitable[dict]]):
# TODO: document this # TODO: document this
raise UnsupportedError(f"{self.register_herald_action.__name__} is not supported on this platform") raise UnsupportedError(f"{self.call_herald_event.__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")

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__) 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])}")
self.alchemy = None
"""The :class:`Alchemy` of this Constellation."""
if database_uri is not None:
log.info(f"Creating Alchemy...") log.info(f"Creating Alchemy...")
self.alchemy: royalnet.alchemy.Alchemy = royalnet.alchemy.Alchemy(database_uri=database_uri, tables=tables) self.alchemy: royalnet.alchemy.Alchemy = royalnet.alchemy.Alchemy(database_uri=database_uri, tables=tables)
"""The :class:`Alchemy` of this Constellation."""
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,21 +36,26 @@ 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,
network_config: Optional[HeraldConfig] = None, events: List[Type[Event]] = None,
herald_config: Optional[HeraldConfig] = None,
secrets_name: str = "__default__"): secrets_name: str = "__default__"):
if discord is None: if discord is None:
raise ImportError("'discord' extra is not installed") raise ImportError("'discord' extra is not installed")
super().__init__(alchemy_config=alchemy_config, super().__init__(alchemy_config=alchemy_config,
commands=commands, commands=commands,
network_config=network_config, events=events,
herald_config=herald_config,
secrets_name=secrets_name) 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.""" """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
@ -129,7 +137,7 @@ class DiscordSerf(Serf):
if session is not None: if session is not None:
await asyncify(session.close) 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`.""" """Create a custom class inheriting from :py:class:`discord.Client`."""
# noinspection PyMethodParameters # noinspection PyMethodParameters
class DiscordClient(discord.Client): class DiscordClient(discord.Client):
@ -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

@ -47,7 +47,8 @@ class 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,
network_config: Optional[HeraldConfig] = None, events: List[Type[Event]] = None,
herald_config: Optional[HeraldConfig] = None,
secrets_name: str = "__default__"): secrets_name: str = "__default__"):
self.secrets_name = secrets_name self.secrets_name = secrets_name
@ -87,23 +88,22 @@ class Serf:
self.register_commands(commands) self.register_commands(commands)
log.info(f"Commands: total {len(self.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 self.herald: Optional[Link] = None
"""The :class:`Link` object connecting the Serf to the rest of the herald network.""" """The :class:`Link` object connecting the Serf to the rest of the herald network."""
self.herald_task: Optional[Task] = None self.herald_task: Optional[Task] = None
"""A reference to the :class:`asyncio.Task` that runs the :class:`Link`.""" """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: if Link is None:
log.info("Herald: not installed") log.info("Herald: not installed")
elif network_config is None: elif herald_config is None:
log.info("Herald: disabled") log.info("Herald: disabled")
else: else:
self.init_network(network_config) 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."""
@ -148,22 +148,7 @@ class Serf:
alchemy: Alchemy = self.alchemy alchemy: Alchemy = self.alchemy
serf: "Serf" = self serf: "Serf" = self
def register_herald_action(ci, async def call_herald_event(ci, destination: str, event_name: str, args: Dict) -> Dict:
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:
"""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:
@ -228,34 +213,36 @@ class Serf:
else: else:
log.warning(f"Ignoring (already defined): {SelectedCommand.__qualname__} -> {interface.prefix}{alias}") 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`.""" """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) 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: async def network_handler(self, message: Union[Request, Broadcast]) -> Response:
try: try:
network_handler = self.herald_handlers[message.handler] event: Event = self.events[message.handler]
except KeyError: except KeyError:
log.warning(f"Missing network_handler for {message.handler}") log.warning(f"No event for '{message.handler}'")
return ResponseFailure("no_handler", f"This bot is missing a network handler for {message.handler}.") return ResponseFailure("no_event", f"This serf does not have any event for {message.handler}.")
else: log.debug(f"Event called: {event.name}")
log.debug(f"Using {network_handler} as handler for {message.handler}")
if isinstance(message, Request): if isinstance(message, Request):
try: try:
response_data = await network_handler(self, **message.data) response_data = await event.run(**message.data)
return ResponseSuccess(data=response_data) return ResponseSuccess(data=response_data)
except Exception as e: except Exception as e:
sentry_sdk.capture_exception(e) sentry_sdk.capture_exception(e)
log.error(f"Exception {e} in {network_handler}") log.error(f"Event error: {e.__class__.__qualname__} in {event.name}")
return ResponseFailure("exception_in_handler", return ResponseFailure("exception_in_event",
f"An exception was raised in {network_handler} for {message.handler}.", f"An exception was raised in the event for '{message.handler}'.",
extra_info={ extra_info={
"type": e.__class__.__name__, "type": e.__class__.__qualname__,
"message": str(e) "message": str(e)
}) })
elif isinstance(message, Broadcast): elif isinstance(message, Broadcast):
await network_handler(self, **message.data) await event.run(**message.data)
@staticmethod @staticmethod
def init_sentry(dsn): def init_sentry(dsn):
@ -329,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()
try:
serf.loop.run_until_complete(serf.run()) 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,14 +37,16 @@ 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,
network_config: Optional[HeraldConfig] = None, events: List[Type[Event]] = None,
herald_config: Optional[HeraldConfig] = None,
secrets_name: str = "__default__"): secrets_name: str = "__default__"):
if telegram is None: if telegram is None:
raise ImportError("'telegram' extra is not installed") raise ImportError("'telegram' extra is not installed")
super().__init__(alchemy_config=alchemy_config, super().__init__(alchemy_config=alchemy_config,
commands=commands, commands=commands,
network_config=network_config, events=events,
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(5, read_timeout=30))