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:
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: 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",
|
||||||
|
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
|
@ -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}>!")
|
|
||||||
|
|
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 .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",
|
|
||||||
]
|
]
|
||||||
|
|
|
@ -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"
|
||||||
]
|
]
|
||||||
|
|
|
@ -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")
|
|
||||||
|
|
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__)
|
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
|
||||||
|
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
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 .dbstack import DBStack
|
||||||
from .dbqueue import DBQueue
|
from .dbqueue import DBQueue
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DBStack",
|
"DBStack",
|
||||||
"DBQueue",
|
"DBQueue",
|
||||||
|
"DiscordBard",
|
||||||
]
|
]
|
|
@ -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]]]:
|
|
@ -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]]]:
|
|
@ -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
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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))
|
||||||
|
|
Loading…
Reference in a new issue