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

Event API complete, other improvements

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

View file

@ -47,7 +47,7 @@ def run(telegram: typing.Optional[bool],
royalnet_log: Logger = getLogger("royalnet")
royalnet_log.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",

View file

@ -29,14 +29,8 @@ class SummonCommand(Command):
member = None
guild = None
try:
await self.interface.call_herald_event("discord", "discordvoice", {
"operation": "summon",
"data": {
"channel_name": args.joined(),
"member_id": member.id if member is not None else None,
"guild_id": guild.id if member is not None else None,
}
})
# TODO: do something!
pass
except Exception as e:
breakpoint()
await data.reply(f"✅ Connesso alla chat vocale.")

View file

@ -1,118 +0,0 @@
import asyncio
from typing import Dict, List, Optional
from royalnet.commands import *
from royalnet.serf import Serf
from royalnet.serf.discord import DiscordSerf
from royalnet.bard import DiscordBard
from royalnet.bard.implementations import *
import weakref
try:
import discord
except ImportError:
discord = None
class DiscordvoiceEvent(Event):
name: str = "discordvoice"
def __init__(self, serf: Serf):
super().__init__(serf)
self.bards: weakref.WeakValueDictionary = weakref.WeakValueDictionary()
async def run(self,
operation: str,
data: dict):
if not isinstance(self.serf, DiscordSerf):
raise ValueError("`discordvoice` event cannot run on other serfs.")
if operation == "summon":
channel_name: str = data["channel_name"]
member_id: int = data.get("member_id")
guild_id: int = data.get("guild_id")
client: discord.Client = self.serf.client
# Get the guild, if it exists
if guild_id is not None:
guild: Optional[discord.Guild] = client.get_guild(guild_id)
else:
guild = None
# Get the member, if it exists
if member_id is not None and guild is not None:
member: Optional[discord.Member] = guild.get_member(member_id)
else:
member = None
# Try to find all possible channels
channels: List[discord.VoiceChannel] = []
for ch in client.get_all_channels():
guild: discord.Guild = ch.guild
# Ensure the channel is a voice channel
if not isinstance(ch, discord.VoiceChannel):
continue
# Ensure the channel starts with the requested name
ch_name: str = ch.name
if not ch_name.startswith(channel_name):
continue
# Ensure that the command author can access the channel
if member is not None:
member_permissions: discord.Permissions = ch.permissions_for(member)
if not (member_permissions.connect and member_permissions.speak):
continue
# Ensure that the bot can access the channel
bot_member = guild.get_member(client.user.id)
bot_permissions: discord.Permissions = ch.permissions_for(bot_member)
if not (bot_permissions.connect and bot_permissions.speak):
continue
# Found one!
channels.append(ch)
# Ensure at least a single channel is returned
if len(channels) == 0:
raise InvalidInputError("Could not find any channel to connect to.")
else:
# Give priority to channels in the current guild
filter_by_guild = False
for ch in channels:
if ch.guild == guild:
filter_by_guild = True
break
if filter_by_guild:
new_channels = []
for ch in channels:
if ch.guild == guild:
new_channels.append(ch)
channels = new_channels
# Give priority to channels with the most people
def people_count(c: discord.VoiceChannel):
return len(c.members)
channels.sort(key=people_count, reverse=True)
channel = channels[0]
# Try to connect to the voice channel
try:
await channel.connect()
except asyncio.TimeoutError:
raise ExternalError("Timed out while trying to connect to the channel")
except discord.opus.OpusNotLoaded:
raise ConfigurationError("[c]libopus[/c] is not loaded in the serf")
except discord.ClientException:
# The bot is already connected to a voice channel
# TODO: safely move the bot somewhere else
raise CommandError("The bot is already connected in another channel.\n"
" Please disconnect it before resummoning!")
# Create a new bard, if it doesn't already exist
# TODO: does this work? are the voice clients correctly disposed of?
self.bards[channel.guild] = DBQueue()
return {
"connected": True
}
# TODO: play, skip, playmode, remove, something else?
else:
raise ValueError(f"Invalid operation received: {operation}")

View file

@ -3,8 +3,6 @@ from .ytdlfile import YtdlFile
from .ytdlmp3 import YtdlMp3
from .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

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

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,6 +36,7 @@ class DiscordSerf(Serf):
def __init__(self, *,
alchemy_config: Optional[AlchemyConfig] = None,
commands: List[Type[Command]] = None,
events: List[Type[Event]] = None,
herald_config: Optional[HeraldConfig] = None,
secrets_name: str = "__default__"):
if discord is None:
@ -40,6 +44,7 @@ class DiscordSerf(Serf):
super().__init__(alchemy_config=alchemy_config,
commands=commands,
events=events,
herald_config=herald_config,
secrets_name=secrets_name)
@ -47,7 +52,10 @@ class DiscordSerf(Serf):
"""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
@ -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

@ -103,7 +103,7 @@ class Serf:
log.info("Herald: disabled")
else:
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
"""The event loop this Serf is running on."""
@ -316,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)

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,6 +37,7 @@ class TelegramSerf(Serf):
def __init__(self, *,
alchemy_config: Optional[AlchemyConfig] = None,
commands: List[Type[Command]] = None,
events: List[Type[Event]] = None,
herald_config: Optional[HeraldConfig] = None,
secrets_name: str = "__default__"):
if telegram is None:
@ -45,6 +45,7 @@ class TelegramSerf(Serf):
super().__init__(alchemy_config=alchemy_config,
commands=commands,
events=events,
herald_config=herald_config,
secrets_name=secrets_name)