mirror of
https://github.com/RYGhub/royalnet.git
synced 2024-11-23 19:44:20 +00:00
Event API complete, other improvements
This commit is contained in:
parent
c2a04301ef
commit
ae522c5e2a
16 changed files with 233 additions and 186 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",
|
||||||
|
|
|
@ -29,14 +29,8 @@ class SummonCommand(Command):
|
||||||
member = None
|
member = None
|
||||||
guild = None
|
guild = None
|
||||||
try:
|
try:
|
||||||
await self.interface.call_herald_event("discord", "discordvoice", {
|
# TODO: do something!
|
||||||
"operation": "summon",
|
pass
|
||||||
"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,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
breakpoint()
|
breakpoint()
|
||||||
await data.reply(f"✅ Connesso alla chat vocale.")
|
await data.reply(f"✅ Connesso alla chat vocale.")
|
||||||
|
|
|
@ -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}")
|
|
|
@ -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",
|
|
||||||
]
|
]
|
||||||
|
|
|
@ -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,6 +36,7 @@ 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,
|
||||||
|
events: List[Type[Event]] = None,
|
||||||
herald_config: Optional[HeraldConfig] = None,
|
herald_config: Optional[HeraldConfig] = None,
|
||||||
secrets_name: str = "__default__"):
|
secrets_name: str = "__default__"):
|
||||||
if discord is None:
|
if discord is None:
|
||||||
|
@ -40,6 +44,7 @@ class DiscordSerf(Serf):
|
||||||
|
|
||||||
super().__init__(alchemy_config=alchemy_config,
|
super().__init__(alchemy_config=alchemy_config,
|
||||||
commands=commands,
|
commands=commands,
|
||||||
|
events=events,
|
||||||
herald_config=herald_config,
|
herald_config=herald_config,
|
||||||
secrets_name=secrets_name)
|
secrets_name=secrets_name)
|
||||||
|
|
||||||
|
@ -47,7 +52,10 @@ class DiscordSerf(Serf):
|
||||||
"""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
|
||||||
|
@ -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)
|
||||||
|
|
|
@ -103,7 +103,7 @@ class Serf:
|
||||||
log.info("Herald: disabled")
|
log.info("Herald: disabled")
|
||||||
else:
|
else:
|
||||||
self.init_herald(herald_config, events)
|
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."""
|
||||||
|
@ -316,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,6 +37,7 @@ 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,
|
||||||
|
events: List[Type[Event]] = None,
|
||||||
herald_config: Optional[HeraldConfig] = None,
|
herald_config: Optional[HeraldConfig] = None,
|
||||||
secrets_name: str = "__default__"):
|
secrets_name: str = "__default__"):
|
||||||
if telegram is None:
|
if telegram is None:
|
||||||
|
@ -45,6 +45,7 @@ class TelegramSerf(Serf):
|
||||||
|
|
||||||
super().__init__(alchemy_config=alchemy_config,
|
super().__init__(alchemy_config=alchemy_config,
|
||||||
commands=commands,
|
commands=commands,
|
||||||
|
events=events,
|
||||||
herald_config=herald_config,
|
herald_config=herald_config,
|
||||||
secrets_name=secrets_name)
|
secrets_name=secrets_name)
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue