mirror of
https://github.com/RYGhub/royalnet.git
synced 2024-11-23 11:34:18 +00:00
Merge branch '5.1-events' into 5.1
This commit is contained in:
commit
48974967e8
21 changed files with 323 additions and 201 deletions
|
@ -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"]:
|
||||
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")
|
||||
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"]:
|
||||
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")
|
||||
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",
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
|
|
|
@ -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]
|
||||
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]
|
||||
|
||||
if self.interface.name == "discord":
|
||||
msg: Optional["discord.Message"] = data.message
|
||||
member: Optional["discord.Member"] = msg.author
|
||||
guild: Optional["discord.Guild"] = msg.guild
|
||||
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.")
|
||||
|
|
10
royalnet/backpack/events/__init__.py
Normal file
10
royalnet/backpack/events/__init__.py
Normal 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]
|
0
royalnet/backpack/utils/__init__.py
Normal file
0
royalnet/backpack/utils/__init__.py
Normal 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",
|
||||
]
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
|
|
|
@ -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")
|
||||
|
|
30
royalnet/commands/event.py
Normal file
30
royalnet/commands/event.py
Normal 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()
|
|
@ -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])}")
|
||||
|
||||
log.info(f"Creating Alchemy...")
|
||||
self.alchemy: royalnet.alchemy.Alchemy = royalnet.alchemy.Alchemy(database_uri=database_uri, tables=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)
|
||||
|
||||
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
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
|
|
33
royalnet/serf/discord/barddict.py
Normal file
33
royalnet/serf/discord/barddict.py
Normal 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
|
|
@ -1,7 +1,9 @@
|
|||
from .discordbard import DiscordBard
|
||||
from .dbstack import DBStack
|
||||
from .dbqueue import DBQueue
|
||||
|
||||
__all__ = [
|
||||
"DBStack",
|
||||
"DBQueue",
|
||||
"DiscordBard",
|
||||
]
|
|
@ -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]]]:
|
|
@ -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]]]:
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
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)
|
||||
|
|
|
@ -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))
|
||||
|
|
Loading…
Reference in a new issue