diff --git a/royalnet/__init__.py b/royalnet/__init__.py
index 1977c838..71fbe7d4 100644
--- a/royalnet/__init__.py
+++ b/royalnet/__init__.py
@@ -1,3 +1,4 @@
-from . import audio, bots, commands, database, network, utils, error, web, version
+from . import audio, bots, database, network, utils, error, web, version
+from royalnet import commands
__all__ = ["audio", "bots", "commands", "database", "network", "utils", "error", "web", "version"]
diff --git a/royalnet/audio/__init__.py b/royalnet/audio/__init__.py
index 6e5471c7..3cbe9192 100644
--- a/royalnet/audio/__init__.py
+++ b/royalnet/audio/__init__.py
@@ -5,6 +5,6 @@ from .ytdlinfo import YtdlInfo
from .ytdlfile import YtdlFile
from .fileaudiosource import FileAudioSource
from .ytdldiscord import YtdlDiscord
-from .ytdlvorbis import YtdlVorbis
+from .ytdlmp3 import YtdlMp3
-__all__ = ["playmodes", "YtdlInfo", "YtdlFile", "FileAudioSource", "YtdlDiscord", "YtdlVorbis"]
+__all__ = ["playmodes", "YtdlInfo", "YtdlFile", "FileAudioSource", "YtdlDiscord", "YtdlMp3"]
diff --git a/royalnet/audio/ytdlvorbis.py b/royalnet/audio/ytdlmp3.py
similarity index 79%
rename from royalnet/audio/ytdlvorbis.py
rename to royalnet/audio/ytdlmp3.py
index f1c9088b..81e86b7f 100644
--- a/royalnet/audio/ytdlvorbis.py
+++ b/royalnet/audio/ytdlmp3.py
@@ -7,16 +7,16 @@ from .ytdlfile import YtdlFile
from .fileaudiosource import FileAudioSource
-class YtdlVorbis:
+class YtdlMp3:
def __init__(self, ytdl_file: YtdlFile):
self.ytdl_file: YtdlFile = ytdl_file
- self.vorbis_filename: typing.Optional[str] = None
+ self.mp3_filename: typing.Optional[str] = None
self._fas_spawned: typing.List[FileAudioSource] = []
def pcm_available(self):
- return self.vorbis_filename is not None and os.path.exists(self.vorbis_filename)
+ return self.mp3_filename is not None and os.path.exists(self.mp3_filename)
- def convert_to_vorbis(self) -> None:
+ def convert_to_mp3(self) -> None:
if not self.ytdl_file.is_downloaded():
raise FileNotFoundError("File hasn't been downloaded yet")
destination_filename = re.sub(r"\.[^.]+$", ".mp3", self.ytdl_file.filename)
@@ -26,7 +26,7 @@ class YtdlVorbis:
.overwrite_output()
.run()
)
- self.vorbis_filename = destination_filename
+ self.mp3_filename = destination_filename
def ready_up(self):
if not self.ytdl_file.has_info():
@@ -34,28 +34,28 @@ class YtdlVorbis:
if not self.ytdl_file.is_downloaded():
self.ytdl_file.download_file()
if not self.pcm_available():
- self.convert_to_vorbis()
+ self.convert_to_mp3()
def delete(self) -> None:
if self.pcm_available():
for source in self._fas_spawned:
if not source.file.closed:
source.file.close()
- os.remove(self.vorbis_filename)
- self.vorbis_filename = None
+ os.remove(self.mp3_filename)
+ self.mp3_filename = None
self.ytdl_file.delete()
@classmethod
- def create_from_url(cls, url, **ytdl_args) -> typing.List["YtdlVorbis"]:
+ def create_from_url(cls, url, **ytdl_args) -> typing.List["YtdlMp3"]:
files = YtdlFile.download_from_url(url, **ytdl_args)
dfiles = []
for file in files:
- dfile = YtdlVorbis(file)
+ dfile = YtdlMp3(file)
dfiles.append(dfile)
return dfiles
@classmethod
- def create_and_ready_from_url(cls, url, **ytdl_args) -> typing.List["YtdlVorbis"]:
+ def create_and_ready_from_url(cls, url, **ytdl_args) -> typing.List["YtdlMp3"]:
dfiles = cls.create_from_url(url, **ytdl_args)
for dfile in dfiles:
dfile.ready_up()
diff --git a/royalnet/bots/discord.py b/royalnet/bots/discord.py
index 2c73b29d..e5e4f27c 100644
--- a/royalnet/bots/discord.py
+++ b/royalnet/bots/discord.py
@@ -1,14 +1,13 @@
import discord
-import asyncio
import typing
import logging as _logging
from .generic import GenericBot
-from ..commands import NullCommand
-from ..utils import asyncify, Call, Command, discord_escape
-from ..error import UnregisteredError, NoneFoundError, TooManyFoundError, InvalidConfigError, RoyalnetResponseError
-from ..network import RoyalnetConfig, Request, ResponseSuccess, ResponseError
-from ..database import DatabaseConfig
-from ..audio import playmodes, YtdlDiscord
+from ..utils import *
+from ..error import *
+from ..network import *
+from ..database import *
+from ..audio import *
+from ..commands import *
log = _logging.getLogger(__name__)
@@ -25,7 +24,6 @@ class DiscordConfig:
class DiscordBot(GenericBot):
"""A bot that connects to `Discord `_."""
-
interface_name = "discord"
def _init_voice(self):
@@ -33,40 +31,30 @@ class DiscordBot(GenericBot):
log.debug(f"Creating music_data dict")
self.music_data: typing.Dict[discord.Guild, playmodes.PlayMode] = {}
- def _call_factory(self) -> typing.Type[Call]:
- log.debug(f"Creating DiscordCall")
+ def _interface_factory(self) -> typing.Type[CommandInterface]:
+ # noinspection PyPep8Naming
+ GenericInterface = super()._interface_factory()
- # noinspection PyMethodParameters
- class DiscordCall(Call):
- interface_name = self.interface_name
- interface_obj = self
- interface_prefix = "!"
+ # noinspection PyMethodParameters,PyAbstractClass
+ class DiscordInterface(GenericInterface):
+ name = self.interface_name
+ prefix = "!"
- alchemy = self.alchemy
+ return DiscordInterface
- async def reply(call, text: str):
- # TODO: don't escape characters inside [c][/c] blocks
- await call.channel.send(discord_escape(text))
+ def _data_factory(self) -> typing.Type[CommandData]:
+ # noinspection PyMethodParameters,PyAbstractClass
+ class DiscordData(CommandData):
+ def __init__(data, interface: CommandInterface, message: discord.Message):
+ data._interface = interface
+ data.message = message
- async def net_request(call, request: Request, destination: str) -> dict:
- if self.network is None:
- raise InvalidConfigError("Royalnet is not enabled on this bot")
- response_dict: dict = await self.network.request(request.to_dict(), destination)
- if "type" not in response_dict:
- raise RoyalnetResponseError("Response is missing a type")
- elif response_dict["type"] == "ResponseSuccess":
- response: typing.Union[ResponseSuccess, ResponseError] = ResponseSuccess.from_dict(response_dict)
- elif response_dict["type"] == "ResponseError":
- response = ResponseError.from_dict(response_dict)
- else:
- raise RoyalnetResponseError("Response type is unknown")
- response.raise_on_error()
- return response.data
+ async def reply(data, text: str):
+ await data.message.channel.send(discord_escape(text))
- async def get_author(call, error_if_none=False):
- message: discord.Message = call.kwargs["message"]
- user: discord.Member = message.author
- query = call.session.query(self.master_table)
+ async def get_author(data, error_if_none=False):
+ user: discord.Member = data.message.author
+ query = data._interface.session.query(self.master_table)
for link in self.identity_chain:
query = query.join(link.mapper.class_)
query = query.filter(self.identity_column == user.id)
@@ -75,7 +63,7 @@ class DiscordBot(GenericBot):
raise UnregisteredError("Author is not registered")
return result
- return DiscordCall
+ return DiscordData
def _bot_factory(self) -> typing.Type[discord.Client]:
"""Create a custom DiscordClient class inheriting from :py:class:`discord.Client`."""
@@ -109,20 +97,25 @@ class DiscordBot(GenericBot):
if not text:
return
# Skip non-command updates
- if not text.startswith(self.command_prefix):
+ if not text.startswith("!"):
return
# Skip bot messages
author: typing.Union[discord.User] = message.author
if author.bot:
return
- # Start typing
+ # Find and clean parameters
+ command_text, *parameters = text.split(" ")
+ # Don't use a case-sensitive command name
+ command_name = command_text.lower()
+ # Find the command
+ try:
+ command = self.commands[command_name]
+ except KeyError:
+ # Skip the message
+ return
+ # Call the command
with message.channel.typing():
- # Find and clean parameters
- command_text, *parameters = text.split(" ")
- # Don't use a case-sensitive command name
- command_name = command_text.lower()
- # Call the command
- await self.call(command_name, message.channel, parameters, message=message)
+ await command.run(CommandArgs(parameters), self._Data(interface=command.interface, message=message))
async def on_ready(cli):
log.debug("Connection successful, client is ready")
@@ -148,12 +141,14 @@ class DiscordBot(GenericBot):
def find_channel_by_name(cli,
name: str,
guild: typing.Optional[discord.Guild] = None) -> discord.abc.GuildChannel:
- """Find the :py:class:`TextChannel`, :py:class:`VoiceChannel` or :py:class:`CategoryChannel` with the specified name.
+ """Find the :py:class:`TextChannel`, :py:class:`VoiceChannel` or :py:class:`CategoryChannel` with the
+ specified name.
Case-insensitive.
- Guild is optional, but the method will raise a :py:exc:`TooManyFoundError` if none is specified and there is more than one channel with the same name.
- Will also raise a :py:exc:`NoneFoundError` if no channels are found."""
+ Guild is optional, but the method will raise a :py:exc:`TooManyFoundError` if none is specified and
+ there is more than one channel with the same name. Will also raise a :py:exc:`NoneFoundError` if no
+ channels are found. """
if guild is not None:
all_channels = guild.channels
else:
@@ -194,16 +189,10 @@ class DiscordBot(GenericBot):
discord_config: DiscordConfig,
royalnet_config: typing.Optional[RoyalnetConfig] = None,
database_config: typing.Optional[DatabaseConfig] = None,
- command_prefix: str = "!",
- commands: typing.List[typing.Type[Command]] = None,
- missing_command: typing.Type[Command] = NullCommand,
- error_command: typing.Type[Command] = NullCommand):
+ commands: typing.List[typing.Type[Command]] = None):
super().__init__(royalnet_config=royalnet_config,
database_config=database_config,
- command_prefix=command_prefix,
- commands=commands,
- missing_command=missing_command,
- error_command=error_command)
+ commands=commands)
self._discord_config = discord_config
self._init_client()
self._init_voice()
diff --git a/royalnet/bots/generic.py b/royalnet/bots/generic.py
index 05aaf66d..18df4b8d 100644
--- a/royalnet/bots/generic.py
+++ b/royalnet/bots/generic.py
@@ -2,48 +2,71 @@ import sys
import typing
import asyncio
import logging
-from ..utils import Command, NetworkHandler, Call
-from ..commands import NullCommand
-from ..network import RoyalnetLink, Request, Response, ResponseError, RoyalnetConfig
-from ..database import Alchemy, DatabaseConfig, relationshiplinkchain
+from ..utils import *
+from ..network import *
+from ..database import *
+from ..commands import *
+from ..error import *
log = logging.getLogger(__name__)
class GenericBot:
- """A generic bot class, to be used as base for the other more specific classes, such as :ref:`royalnet.bots.TelegramBot` and :ref:`royalnet.bots.DiscordBot`."""
+ """A generic bot class, to be used as base for the other more specific classes, such as
+ :ref:`royalnet.bots.TelegramBot` and :ref:`royalnet.bots.DiscordBot`. """
interface_name = NotImplemented
- def _init_commands(self,
- command_prefix: str,
- commands: typing.List[typing.Type[Command]],
- missing_command: typing.Type[Command],
- error_command: typing.Type[Command]) -> None:
- """Generate the ``commands`` dictionary required to handle incoming messages, and the ``network_handlers`` dictionary required to handle incoming requests."""
- log.debug(f"Now generating commands")
- self.command_prefix = command_prefix
- self.commands: typing.Dict[str, typing.Type[Command]] = {}
+ def _init_commands(self, commands: typing.List[typing.Type[Command]]) -> None:
+ """Generate the ``commands`` dictionary required to handle incoming messages, and the ``network_handlers``
+ dictionary required to handle incoming requests. """
+ log.debug(f"Now binding commands")
+ self._Interface = self._interface_factory()
+ self._Data = self._data_factory()
+ self.commands = {}
self.network_handlers: typing.Dict[str, typing.Type[NetworkHandler]] = {}
- for command in commands:
- lower_command_name = command.command_name.lower()
- self.commands[f"{command_prefix}{lower_command_name}"] = command
- self.network_handlers = {**self.network_handlers, **command.network_handler_dict()}
- self.missing_command: typing.Type[Command] = missing_command
- self.error_command: typing.Type[Command] = error_command
- log.debug(f"Successfully generated commands")
+ for SelectedCommand in commands:
+ interface = self._Interface()
+ self.commands[f"{interface.prefix}{SelectedCommand.name}"] = SelectedCommand(interface)
+ log.debug(f"Successfully bound commands")
- def _call_factory(self) -> typing.Type[Call]:
- """Create the TelegramCall class, representing a command call. It should inherit from :py:class:`royalnet.utils.Call`.
+ def _interface_factory(self) -> typing.Type[CommandInterface]:
+ # noinspection PyAbstractClass,PyMethodParameters
+ class GenericInterface(CommandInterface):
+ alchemy = self.alchemy
+ bot = self
+ loop = self.loop
- Returns:
- The created TelegramCall class."""
+ def register_net_handler(ci, message_type: str, network_handler: typing.Callable):
+ self.network_handlers[message_type] = network_handler
+
+ def unregister_net_handler(ci, message_type: str):
+ del self.network_handlers[message_type]
+
+ async def net_request(ci, request: Request, destination: str) -> dict:
+ if self.network is None:
+ raise InvalidConfigError("Royalnet is not enabled on this bot")
+ response_dict: dict = await self.network.request(request.to_dict(), destination)
+ if "type" not in response_dict:
+ raise RoyalnetResponseError("Response is missing a type")
+ elif response_dict["type"] == "ResponseSuccess":
+ response: typing.Union[ResponseSuccess, ResponseError] = ResponseSuccess.from_dict(response_dict)
+ elif response_dict["type"] == "ResponseError":
+ response = ResponseError.from_dict(response_dict)
+ else:
+ raise RoyalnetResponseError("Response type is unknown")
+ response.raise_on_error()
+ return response.data
+
+ return GenericInterface
+
+ def _data_factory(self) -> typing.Type[CommandData]:
raise NotImplementedError()
def _init_royalnet(self, royalnet_config: RoyalnetConfig):
"""Create a :py:class:`royalnet.network.RoyalnetLink`, and run it as a :py:class:`asyncio.Task`."""
- self.network: RoyalnetLink = RoyalnetLink(royalnet_config.master_uri, royalnet_config.master_secret, self.interface_name,
- self._network_handler)
+ self.network: RoyalnetLink = RoyalnetLink(royalnet_config.master_uri, royalnet_config.master_secret,
+ self.interface_name, self._network_handler)
log.debug(f"Running RoyalnetLink {self.network}")
self.loop.create_task(self.network.run())
@@ -74,16 +97,18 @@ class GenericBot:
_, exc, _ = sys.exc_info()
log.debug(f"Exception {exc} in {network_handler}")
return ResponseError("exception_in_handler",
- f"An exception was raised in {network_handler} for {request.handler}. Check extra_info for details.",
+ f"An exception was raised in {network_handler} for {request.handler}. Check "
+ f"extra_info for details.",
extra_info={
"type": exc.__class__.__name__,
"str": str(exc)
}).to_dict()
def _init_database(self, commands: typing.List[typing.Type[Command]], database_config: DatabaseConfig):
- """Create an :py:class:`royalnet.database.Alchemy` with the tables required by the commands. Then, find the chain that links the ``master_table`` to the ``identity_table``."""
+ """Create an :py:class:`royalnet.database.Alchemy` with the tables required by the commands. Then,
+ find the chain that links the ``master_table`` to the ``identity_table``. """
log.debug(f"Initializing database")
- required_tables = set()
+ required_tables = {database_config.master_table, database_config.identity_table}
for command in commands:
required_tables = required_tables.union(command.require_alchemy_tables)
log.debug(f"Found {len(required_tables)} required tables")
@@ -98,10 +123,7 @@ class GenericBot:
def __init__(self, *,
royalnet_config: typing.Optional[RoyalnetConfig] = None,
database_config: typing.Optional[DatabaseConfig] = None,
- command_prefix: str,
commands: typing.List[typing.Type[Command]] = None,
- missing_command: typing.Type[Command] = NullCommand,
- error_command: typing.Type[Command] = NullCommand,
loop: asyncio.AbstractEventLoop = None):
if loop is None:
self.loop = asyncio.get_event_loop()
@@ -116,35 +138,12 @@ class GenericBot:
self._init_database(commands=commands, database_config=database_config)
if commands is None:
commands = []
- self._init_commands(command_prefix, commands, missing_command=missing_command, error_command=error_command)
- self._Call = self._call_factory()
+ self._init_commands(commands)
if royalnet_config is None:
self.network = None
else:
self._init_royalnet(royalnet_config=royalnet_config)
- async def call(self, command_name: str, channel, parameters: typing.List[str] = None, **kwargs):
- """Call the command with the specified name.
-
- If it doesn't exist, call ``self.missing_command``.
-
- If an exception is raised during the execution of the command, call ``self.error_command``."""
- log.debug(f"Trying to call {command_name}")
- if parameters is None:
- parameters = []
- try:
- command: typing.Type[Command] = self.commands[command_name]
- except KeyError:
- log.debug(f"Calling missing_command because {command_name} does not exist")
- command = self.missing_command
- try:
- await self._Call(channel, command, parameters, **kwargs).run()
- except Exception as exc:
- log.debug(f"Calling error_command because of an error in {command_name}")
- await self._Call(channel, self.error_command,
- exception=exc,
- previous_command=command, **kwargs).run()
-
async def run(self):
"""A blocking coroutine that should make the bot start listening to commands and requests."""
raise NotImplementedError()
diff --git a/royalnet/bots/telegram.py b/royalnet/bots/telegram.py
index 7cccfe39..2247bea4 100644
--- a/royalnet/bots/telegram.py
+++ b/royalnet/bots/telegram.py
@@ -1,14 +1,13 @@
import telegram
import telegram.utils.request
-import asyncio
import typing
import logging as _logging
from .generic import GenericBot
-from ..commands import NullCommand
-from ..utils import asyncify, Call, Command, telegram_escape
-from ..error import UnregisteredError, InvalidConfigError, RoyalnetResponseError
-from ..network import RoyalnetConfig, Request, ResponseSuccess, ResponseError
-from ..database import DatabaseConfig
+from ..utils import *
+from ..error import *
+from ..network import *
+from ..database import *
+from ..commands import *
log = _logging.getLogger(__name__)
@@ -31,43 +30,36 @@ class TelegramBot(GenericBot):
self.client = telegram.Bot(self._telegram_config.token, request=request)
self._offset: int = -100
- def _call_factory(self) -> typing.Type[Call]:
- # noinspection PyMethodParameters
- class TelegramCall(Call):
- interface_name = self.interface_name
- interface_obj = self
- interface_prefix = "/"
+ def _interface_factory(self) -> typing.Type[CommandInterface]:
+ # noinspection PyPep8Naming
+ GenericInterface = super()._interface_factory()
- alchemy = self.alchemy
+ # noinspection PyMethodParameters,PyAbstractClass
+ class TelegramInterface(GenericInterface):
+ name = self.interface_name
+ prefix = "/"
- async def reply(call, text: str):
- await asyncify(call.channel.send_message, telegram_escape(text),
+ return TelegramInterface
+
+ def _data_factory(self) -> typing.Type[CommandData]:
+ # noinspection PyMethodParameters,PyAbstractClass
+ class TelegramData(CommandData):
+ def __init__(data, interface: CommandInterface, update: telegram.Update):
+ data._interface = interface
+ data.update = update
+
+ async def reply(data, text: str):
+ await asyncify(data.update.effective_chat.send_message, telegram_escape(text),
parse_mode="HTML",
disable_web_page_preview=True)
- async def net_request(call, request: Request, destination: str) -> dict:
- if self.network is None:
- raise InvalidConfigError("Royalnet is not enabled on this bot")
- response_dict: dict = await self.network.request(request.to_dict(), destination)
- if "type" not in response_dict:
- raise RoyalnetResponseError("Response is missing a type")
- elif response_dict["type"] == "ResponseSuccess":
- response: typing.Union[ResponseSuccess, ResponseError] = ResponseSuccess.from_dict(response_dict)
- elif response_dict["type"] == "ResponseError":
- response = ResponseError.from_dict(response_dict)
- else:
- raise RoyalnetResponseError("Response type is unknown")
- response.raise_on_error()
- return response.data
-
- async def get_author(call, error_if_none=False):
- update: telegram.Update = call.kwargs["update"]
- user: telegram.User = update.effective_user
+ async def get_author(data, error_if_none=False):
+ user: telegram.User = data.update.effective_user
if user is None:
if error_if_none:
raise UnregisteredError("No author for this message")
return None
- query = call.session.query(self.master_table)
+ query = data._interface.session.query(self.master_table)
for link in self.identity_chain:
query = query.join(link.mapper.class_)
query = query.filter(self.identity_column == user.id)
@@ -76,22 +68,16 @@ class TelegramBot(GenericBot):
raise UnregisteredError("Author is not registered")
return result
- return TelegramCall
+ return TelegramData
def __init__(self, *,
telegram_config: TelegramConfig,
royalnet_config: typing.Optional[RoyalnetConfig] = None,
database_config: typing.Optional[DatabaseConfig] = None,
- command_prefix: str = "/",
- commands: typing.List[typing.Type[Command]] = None,
- missing_command: typing.Type[Command] = NullCommand,
- error_command: typing.Type[Command] = NullCommand):
+ commands: typing.List[typing.Type[Command]] = None):
super().__init__(royalnet_config=royalnet_config,
database_config=database_config,
- command_prefix=command_prefix,
- commands=commands,
- missing_command=missing_command,
- error_command=error_command)
+ commands=commands)
self._telegram_config = telegram_config
self._init_client()
@@ -108,15 +94,21 @@ class TelegramBot(GenericBot):
if text is None:
return
# Skip non-command updates
- if not text.startswith(self.command_prefix):
+ if not text.startswith("/"):
return
# Find and clean parameters
command_text, *parameters = text.split(" ")
command_name = command_text.replace(f"@{self.client.username}", "").lower()
# Send a typing notification
- self.client.send_chat_action(update.message.chat, telegram.ChatAction.TYPING)
- # Call the command
- await self.call(command_name, update.message.chat, parameters, update=update)
+ update.message.chat.send_action(telegram.ChatAction.TYPING)
+ # Find the command
+ try:
+ command = self.commands[command_name]
+ except KeyError:
+ # Skip the message
+ return
+ # Run the command
+ await command.run(CommandArgs(parameters), self._Data(interface=command.interface, update=update))
async def run(self):
while True:
@@ -134,12 +126,3 @@ class TelegramBot(GenericBot):
self._offset = last_updates[-1].update_id + 1
except IndexError:
pass
-
- @property
- def botfather_command_string(self) -> str:
- """Generate a string to be pasted in the "Edit Commands" BotFather prompt."""
- string = ""
- for command_key in self.commands:
- command = self.commands[command_key]
- string += f"{command.command_name} - {command.command_description}\n"
- return string
diff --git a/royalnet/commands/__init__.py b/royalnet/commands/__init__.py
index f6712cdd..726c0206 100644
--- a/royalnet/commands/__init__.py
+++ b/royalnet/commands/__init__.py
@@ -1,39 +1,6 @@
-"""Commands that can be used in bots.
+from .commandinterface import CommandInterface
+from .command import Command
+from .commanddata import CommandData
+from .commandargs import CommandArgs
-These probably won't suit your needs, as they are tailored for the bots of the Royal Games gaming community, but they may be useful to develop new ones."""
-
-from .null import NullCommand
-from .ping import PingCommand
-from .ship import ShipCommand
-from .smecds import SmecdsCommand
-from .ciaoruozi import CiaoruoziCommand
-from .color import ColorCommand
-from .sync import SyncCommand
-from .diario import DiarioCommand
-from .rage import RageCommand
-from .dateparser import DateparserCommand
-from .author import AuthorCommand
-from .reminder import ReminderCommand
-from .kvactive import KvactiveCommand
-from .kv import KvCommand
-from .kvroll import KvrollCommand
-from .videoinfo import VideoinfoCommand
-from .summon import SummonCommand
-from .play import PlayCommand
-from .skip import SkipCommand
-from .playmode import PlaymodeCommand
-from .videochannel import VideochannelCommand
-from .missing import MissingCommand
-from .cv import CvCommand
-from .pause import PauseCommand
-from .queue import QueueCommand
-from .royalnetprofile import RoyalnetprofileCommand
-from .id import IdCommand
-from .dlmusic import DlmusicCommand
-
-
-__all__ = ["NullCommand", "PingCommand", "ShipCommand", "SmecdsCommand", "CiaoruoziCommand", "ColorCommand",
- "SyncCommand", "DiarioCommand", "RageCommand", "DateparserCommand", "AuthorCommand", "ReminderCommand",
- "KvactiveCommand", "KvCommand", "KvrollCommand", "VideoinfoCommand", "SummonCommand", "PlayCommand",
- "SkipCommand", "PlaymodeCommand", "VideochannelCommand", "MissingCommand", "CvCommand", "PauseCommand",
- "QueueCommand", "RoyalnetprofileCommand", "IdCommand", "DlmusicCommand"]
+__all__ = ["CommandInterface", "Command", "CommandData", "CommandArgs"]
diff --git a/royalnet/commands/ciaoruozi.py b/royalnet/commands/ciaoruozi.py
deleted file mode 100644
index 2929512e..00000000
--- a/royalnet/commands/ciaoruozi.py
+++ /dev/null
@@ -1,22 +0,0 @@
-from ..utils import Command, Call
-from telegram import Update, User
-
-
-class CiaoruoziCommand(Command):
-
- command_name = "ciaoruozi"
- command_description = "Saluta Ruozi, anche se non è più in RYG."
- command_syntax = ""
-
- @classmethod
- async def common(cls, call: "Call"):
- await call.reply("👋 Ciao Ruozi!")
-
- @classmethod
- async def telegram(cls, call: Call):
- update: Update = call.kwargs["update"]
- user: User = update.effective_user
- if user.id == 112437036:
- await call.reply("👋 Ciao me!")
- else:
- await call.reply("👋 Ciao Ruozi!")
diff --git a/royalnet/commands/color.py b/royalnet/commands/color.py
deleted file mode 100644
index ff9a458f..00000000
--- a/royalnet/commands/color.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from ..utils import Command, Call
-
-
-class ColorCommand(Command):
-
- command_name = "color"
- command_description = "Invia un colore in chat...?"
- command_syntax = ""
-
- @classmethod
- async def common(cls, call: Call):
- await call.reply("""
- [i]I am sorry, unknown error occured during working with your request, Admin were notified[/i]
- """)
diff --git a/royalnet/commands/command.py b/royalnet/commands/command.py
new file mode 100644
index 00000000..43897cdb
--- /dev/null
+++ b/royalnet/commands/command.py
@@ -0,0 +1,27 @@
+import typing
+from ..error import UnsupportedError
+from .commandinterface import CommandInterface
+from .commandargs import CommandArgs
+from .commanddata import CommandData
+
+
+class Command:
+ name: str = NotImplemented
+ """The main name of the command.
+ To have ``/example`` on Telegram, the name should be ``example``."""
+
+ description: str = NotImplemented
+ """A small description of the command, to be displayed when the command is being autocompleted."""
+
+ syntax: str = ""
+ """The syntax of the command, to be displayed when a :py:exc:`royalnet.error.InvalidInputError` is raised,
+ in the format ``(required_arg) [optional_arg]``."""
+
+ require_alchemy_tables: typing.Set = set()
+ """A set of :py:class:`royalnet.database` tables that must exist for this command to work."""
+
+ def __init__(self, interface: CommandInterface):
+ self.interface = interface
+
+ async def run(self, args: CommandArgs, data: CommandData) -> None:
+ raise UnsupportedError(f"Command {self.name} can't be called on {self.interface.name}.")
diff --git a/royalnet/utils/commandargs.py b/royalnet/commands/commandargs.py
similarity index 96%
rename from royalnet/utils/commandargs.py
rename to royalnet/commands/commandargs.py
index 575f2682..1ee27015 100644
--- a/royalnet/utils/commandargs.py
+++ b/royalnet/commands/commandargs.py
@@ -38,7 +38,7 @@ class CommandArgs(list):
raise InvalidInputError("Not enough arguments")
return " ".join(self)
- def match(self, pattern: typing.Pattern) -> typing.Sequence[typing.AnyStr]:
+ def match(self, pattern: typing.Union[str, typing.Pattern]) -> typing.Sequence[typing.AnyStr]:
"""Match the :py:func:`royalnet.utils.commandargs.joined` to a regex pattern.
Parameters:
diff --git a/royalnet/commands/commanddata.py b/royalnet/commands/commanddata.py
new file mode 100644
index 00000000..6e323e2c
--- /dev/null
+++ b/royalnet/commands/commanddata.py
@@ -0,0 +1,18 @@
+class CommandData:
+ async def reply(self, text: str) -> None:
+ """Send a text message to the channel where the call was made.
+
+ Parameters:
+ text: The text to be sent, possibly formatted in the weird undescribed markup that I'm using."""
+ raise NotImplementedError()
+
+ async def get_author(self, error_if_none: bool = False):
+ """Try to find the identifier of the user that sent the message.
+ That probably means, the database row identifying the user.
+
+ Parameters:
+ error_if_none: Raise a :py:exc:`royalnet.error.UnregisteredError` if this is True and the call has no author.
+
+ Raises:
+ :py:exc:`royalnet.error.UnregisteredError` if ``error_if_none`` is set to True and no author is found."""
+ raise NotImplementedError()
diff --git a/royalnet/commands/commandinterface.py b/royalnet/commands/commandinterface.py
new file mode 100644
index 00000000..81e263ae
--- /dev/null
+++ b/royalnet/commands/commandinterface.py
@@ -0,0 +1,33 @@
+import typing
+import asyncio
+if typing.TYPE_CHECKING:
+ from ..database import Alchemy
+ from ..bots import GenericBot
+
+
+class CommandInterface:
+ name: str = NotImplemented
+ prefix: str = NotImplemented
+ alchemy: "Alchemy" = NotImplemented
+ bot: "GenericBot" = NotImplemented
+ loop: asyncio.AbstractEventLoop = NotImplemented
+
+ def __init__(self):
+ self.session = self.alchemy.Session()
+
+ def register_net_handler(self, message_type: str, network_handler: typing.Callable):
+ """Register a new handler for messages received through Royalnet."""
+ raise NotImplementedError()
+
+ def unregister_net_handler(self, message_type: str):
+ """Remove a Royalnet handler."""
+ raise NotImplementedError()
+
+ async def net_request(self, message, destination: str) -> dict:
+ """Send data through a :py:class:`royalnet.network.RoyalnetLink` and wait for a
+ :py:class:`royalnet.network.Reply`.
+
+ Parameters:
+ message: The data to be sent. Must be :py:mod:`pickle`-able.
+ destination: The destination of the request, either in UUID format or node name."""
+ raise NotImplementedError()
diff --git a/royalnet/commands/diario.py b/royalnet/commands/diario.py
deleted file mode 100644
index a7cfd609..00000000
--- a/royalnet/commands/diario.py
+++ /dev/null
@@ -1,209 +0,0 @@
-import re
-import datetime
-import telegram
-import typing
-import os
-import aiohttp
-from ..utils import Command, Call
-from ..error import InvalidInputError, InvalidConfigError, ExternalError
-from ..database.tables import Royal, Diario, Alias
-from ..utils import asyncify
-
-
-# NOTE: Requires imgur api key for image upload, get one at https://apidocs.imgur.com
-class DiarioCommand(Command):
-
- command_name = "diario"
- command_description = "Aggiungi una citazione al Diario."
- command_syntax = "[!] \"(testo)\" --[autore], [contesto]"
-
- require_alchemy_tables = {Royal, Diario, Alias}
-
- @classmethod
- async def _telegram_to_imgur(cls, photosizes: typing.List[telegram.PhotoSize], caption="") -> str:
- # Select the largest photo
- largest_photo = sorted(photosizes, key=lambda p: p.width * p.height)[-1]
- # Get the photo url
- photo_file: telegram.File = await asyncify(largest_photo.get_file)
- # Forward the url to imgur, as an upload
- try:
- imgur_api_key = os.environ["IMGUR_CLIENT_ID"]
- except KeyError:
- raise InvalidConfigError("Missing IMGUR_CLIENT_ID envvar, can't upload images to imgur.")
- async with aiohttp.request("post", "https://api.imgur.com/3/upload", data={
- "image": photo_file.file_path,
- "type": "URL",
- "title": "Diario image",
- "description": caption
- }, headers={
- "Authorization": f"Client-ID {imgur_api_key}"
- }) as request:
- response = await request.json()
- if not response["success"]:
- raise ExternalError("imgur returned an error in the image upload.")
- return response["data"]["link"]
-
- @classmethod
- async def common(cls, call: Call):
- # Find the creator of the quotes
- creator = await call.get_author(error_if_none=True)
- # Recreate the full sentence
- raw_text = " ".join(call.args)
- # Pass the sentence through the diario regex
- match = re.match(r'(!)? *["«‘“‛‟❛❝〝"`]([^"]+)["»’”❜❞〞"`] *(?:(?:-{1,2}|—) *([\w ]+))?(?:, *([^ ].*))?', raw_text)
- # Find the corresponding matches
- if match is not None:
- spoiler = bool(match.group(1))
- text = match.group(2)
- quoted = match.group(3)
- context = match.group(4)
- # Otherwise, consider everything part of the text
- else:
- spoiler = False
- text = raw_text
- quoted = None
- context = None
- timestamp = datetime.datetime.now()
- # Ensure there is some text
- if not text:
- raise InvalidInputError("Missing text.")
- # Or a quoted
- if not quoted:
- quoted = None
- if not context:
- context = None
- # Find if there's a Royalnet account associated with the quoted name
- if quoted is not None:
- quoted_alias = await asyncify(call.session.query(call.alchemy.Alias).filter_by(alias=quoted.lower()).one_or_none)
- else:
- quoted_alias = None
- quoted_account = quoted_alias.royal if quoted_alias is not None else None
- if quoted_alias is not None and quoted_account is None:
- await call.reply("⚠️ Il nome dell'autore è ambiguo, quindi la riga non è stata aggiunta.\n"
- "Per piacere, ripeti il comando con un nome più specifico!")
- return
- # Create the diario quote
- diario = call.alchemy.Diario(creator=creator,
- quoted_account=quoted_account,
- quoted=quoted,
- text=text,
- context=context,
- timestamp=timestamp,
- media_url=None,
- spoiler=spoiler)
- call.session.add(diario)
- await asyncify(call.session.commit)
- await call.reply(f"✅ {str(diario)}")
-
- @classmethod
- async def telegram(cls, call: Call):
- update: telegram.Update = call.kwargs["update"]
- message: telegram.Message = update.message
- reply: telegram.Message = message.reply_to_message
- creator = await call.get_author()
- # noinspection PyUnusedLocal
- quoted_account: typing.Optional[call.alchemy.Telegram]
- # noinspection PyUnusedLocal
- quoted: typing.Optional[str]
- # noinspection PyUnusedLocal
- text: typing.Optional[str]
- # noinspection PyUnusedLocal
- context: typing.Optional[str]
- # noinspection PyUnusedLocal
- timestamp: datetime.datetime
- # noinspection PyUnusedLocal
- media_url: typing.Optional[str]
- # noinspection PyUnusedLocal
- spoiler: bool
- if creator is None:
- await call.reply("⚠️ Devi essere registrato a Royalnet per usare questo comando!")
- return
- if reply is not None:
- # Get the message text
- text = reply.text
- # Check if there's an image associated with the reply
- photosizes: typing.Optional[typing.List[telegram.PhotoSize]] = reply.photo
- if photosizes:
- # Text is a caption
- text = reply.caption
- media_url = await cls._telegram_to_imgur(photosizes, text if text is not None else "")
- else:
- media_url = None
- # Ensure there is a text or an image
- if not (text or media_url):
- raise InvalidInputError("Missing text.")
- # Find the Royalnet account associated with the sender
- quoted_tg = await asyncify(call.session.query(call.alchemy.Telegram)
- .filter_by(tg_id=reply.from_user.id)
- .one_or_none)
- quoted_account = quoted_tg.royal if quoted_tg is not None else None
- # Find the quoted name to assign
- quoted_user: telegram.User = reply.from_user
- quoted = quoted_user.full_name
- # Get the timestamp
- timestamp = reply.date
- # Set the other properties
- spoiler = False
- context = None
- else:
- # Get the current timestamp
- timestamp = datetime.datetime.now()
- # Get the message text
- raw_text = " ".join(call.args)
- # Check if there's an image associated with the reply
- photosizes: typing.Optional[typing.List[telegram.PhotoSize]] = message.photo
- if photosizes:
- media_url = await cls._telegram_to_imgur(photosizes, raw_text if raw_text is not None else "")
- else:
- media_url = None
- # Parse the text, if it exists
- if raw_text:
- # Pass the sentence through the diario regex
- match = re.match(r'(!)? *["«‘“‛‟❛❝〝"`]([^"]+)["»’”❜❞〞"`] *(?:(?:-{1,2}|—) *([\w ]+))?(?:, *([^ ].*))?',
- raw_text)
- # Find the corresponding matches
- if match is not None:
- spoiler = bool(match.group(1))
- text = match.group(2)
- quoted = match.group(3)
- context = match.group(4)
- # Otherwise, consider everything part of the text
- else:
- spoiler = False
- text = raw_text
- quoted = None
- context = None
- # Ensure there's a quoted
- if not quoted:
- quoted = None
- if not context:
- context = None
- # Find if there's a Royalnet account associated with the quoted name
- if quoted is not None:
- quoted_alias = await asyncify(
- call.session.query(call.alchemy.Alias)
- .filter_by(alias=quoted.lower()).one_or_none)
- else:
- quoted_alias = None
- quoted_account = quoted_alias.royal if quoted_alias is not None else None
- else:
- text = None
- quoted = None
- quoted_account = None
- spoiler = False
- context = None
- # Ensure there is a text or an image
- if not (text or media_url):
- raise InvalidInputError("Missing text.")
- # Create the diario quote
- diario = call.alchemy.Diario(creator=creator,
- quoted_account=quoted_account,
- quoted=quoted,
- text=text,
- context=context,
- timestamp=timestamp,
- media_url=media_url,
- spoiler=spoiler)
- call.session.add(diario)
- await asyncify(call.session.commit)
- await call.reply(f"✅ {str(diario)}")
diff --git a/royalnet/commands/dlmusic.py b/royalnet/commands/dlmusic.py
deleted file mode 100644
index 33348afe..00000000
--- a/royalnet/commands/dlmusic.py
+++ /dev/null
@@ -1,34 +0,0 @@
-import asyncio
-import typing
-import urllib.parse
-from ..utils import Command, Call, asyncify
-from ..audio import YtdlVorbis
-
-
-ytdl_args = {
- "format": "bestaudio",
- "outtmpl": f"./downloads/%(title)s.%(ext)s"
-}
-
-
-seconds_before_deletion = 15*60
-
-
-class DlmusicCommand(Command):
-
- command_name = "dlmusic"
- command_description = "Scarica un video."
- command_syntax = "(url)"
-
- @classmethod
- async def common(cls, call: Call):
- url = call.args.joined()
- if url.startswith("http://") or url.startswith("https://"):
- vfiles: typing.List[YtdlVorbis] = await asyncify(YtdlVorbis.create_and_ready_from_url, url, **ytdl_args)
- else:
- vfiles = await asyncify(YtdlVorbis.create_and_ready_from_url, f"ytsearch:{url}", **ytdl_args)
- for vfile in vfiles:
- await call.reply(f"⬇️ https://scaleway.steffo.eu/{urllib.parse.quote(vfile.vorbis_filename.replace('./downloads/', './musicbot_cache/'))}")
- await asyncio.sleep(seconds_before_deletion)
- for vfile in vfiles:
- vfile.delete()
diff --git a/royalnet/commands/ping.py b/royalnet/commands/ping.py
deleted file mode 100644
index 63ba7e37..00000000
--- a/royalnet/commands/ping.py
+++ /dev/null
@@ -1,21 +0,0 @@
-import asyncio
-from ..utils import Command, Call
-from ..error import InvalidInputError
-
-
-class PingCommand(Command):
-
- command_name = "ping"
- command_description = "Ping pong dopo un po' di tempo!"
- command_syntax = "[time_to_wait]"
-
- @classmethod
- async def common(cls, call: Call):
- try:
- time = int(call.args[0])
- except InvalidInputError:
- time = 0
- except ValueError:
- raise InvalidInputError("time_to_wait is not a number")
- await asyncio.sleep(time)
- await call.reply("🏓 Pong!")
diff --git a/royalnet/commands/play.py b/royalnet/commands/play.py
deleted file mode 100644
index 1537feaa..00000000
--- a/royalnet/commands/play.py
+++ /dev/null
@@ -1,84 +0,0 @@
-import typing
-import asyncio
-import pickle
-from ..utils import Command, Call, NetworkHandler, asyncify
-from ..network import Request, ResponseSuccess
-from ..error import TooManyFoundError, NoneFoundError
-from ..audio import YtdlDiscord
-if typing.TYPE_CHECKING:
- from ..bots import DiscordBot
-
-
-ytdl_args = {
- "format": "bestaudio",
- "outtmpl": f"./downloads/%(title)s.%(ext)s"
-}
-
-
-class PlayNH(NetworkHandler):
- message_type = "music_play"
-
- @classmethod
- async def discord(cls, bot: "DiscordBot", data: dict):
- """Handle a play Royalnet request. That is, add audio to a PlayMode."""
- # Find the matching guild
- if data["guild_name"]:
- guild = bot.client.find_guild(data["guild_name"])
- else:
- if len(bot.music_data) == 0:
- raise NoneFoundError("No voice clients active")
- if len(bot.music_data) > 1:
- raise TooManyFoundError("Multiple guilds found")
- guild = list(bot.music_data)[0]
- # Ensure the guild has a PlayMode before adding the file to it
- if not bot.music_data.get(guild):
- # TODO: change Exception
- raise Exception("No music_data for this guild")
- # Start downloading
- if data["url"].startswith("http://") or data["url"].startswith("https://"):
- dfiles: typing.List[YtdlDiscord] = await asyncify(YtdlDiscord.create_and_ready_from_url, data["url"], **ytdl_args)
- else:
- dfiles = await asyncify(YtdlDiscord.create_and_ready_from_url, f"ytsearch:{data['url']}", **ytdl_args)
- await bot.add_to_music_data(dfiles, guild)
- # Create response dictionary
- response = {
- "videos": [{
- "title": dfile.info.title,
- "discord_embed_pickle": str(pickle.dumps(dfile.info.to_discord_embed()))
- } for dfile in dfiles]
- }
- return ResponseSuccess(response)
-
-
-async def notify_on_timeout(call: Call, url: str, time: float, repeat: bool = False):
- """Send a message after a while to let the user know that the bot is still downloading the files and hasn't crashed."""
- while True:
- await asyncio.sleep(time)
- await call.reply(f"ℹ️ Il download di [c]{url}[/c] sta richiedendo più tempo del solito, ma è ancora in corso!")
- if not repeat:
- break
-
-
-class PlayCommand(Command):
- command_name = "play"
- command_description = "Riproduce una canzone in chat vocale."
- command_syntax = "[ [guild] ] (url)"
-
- network_handlers = [PlayNH]
-
- @classmethod
- async def common(cls, call: Call):
- guild_name, url = call.args.match(r"(?:\[(.+)])?\s*(.+)>?")
- download_task = call.loop.create_task(call.net_request(Request("music_play", {"url": url, "guild_name": guild_name}), "discord"))
- notify_task = call.loop.create_task(notify_on_timeout(call, url, time=30, repeat=True))
- try:
- data: dict = await download_task
- finally:
- notify_task.cancel()
- for video in data["videos"]:
- if call.interface_name == "discord":
- # This is one of the unsafest things ever
- embed = pickle.loads(eval(video["discord_embed_pickle"]))
- await call.channel.send(content="✅ Aggiunto alla coda:", embed=embed)
- else:
- await call.reply(f"✅ [i]{video['title']}[/i] scaricato e aggiunto alla coda.")
diff --git a/royalnet/commands/rage.py b/royalnet/commands/rage.py
deleted file mode 100644
index c9a36ee4..00000000
--- a/royalnet/commands/rage.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import random
-from ..utils import Command, Call
-
-
-MAD = ["MADDEN MADDEN MADDEN MADDEN",
- "EA bad, praise Geraldo!",
- "Stai sfogando la tua ira sul bot!",
- "Basta, io cambio gilda!",
- "Fondiamo la RRYG!"]
-
-
-class RageCommand(Command):
-
- command_name = "rage"
- command_description = "Arrabbiati con qualcosa, possibilmente una software house."
- command_syntax = ""
-
- @classmethod
- async def common(cls, call: Call):
- await call.reply(f"😠 {random.sample(MAD, 1)[0]}")
diff --git a/royalnet/commands/royalgames/__init__.py b/royalnet/commands/royalgames/__init__.py
new file mode 100644
index 00000000..96517eec
--- /dev/null
+++ b/royalnet/commands/royalgames/__init__.py
@@ -0,0 +1,30 @@
+"""Commands that can be used in bots.
+
+These probably won't suit your needs, as they are tailored for the bots of the Royal Games gaming community, but they
+ may be useful to develop new ones."""
+
+from .ping import PingCommand
+from .ciaoruozi import CiaoruoziCommand
+from .color import ColorCommand
+from .cv import CvCommand
+from .diario import DiarioCommand
+from .mp3 import Mp3Command
+from .summon import SummonCommand
+from .pause import PauseCommand
+from .play import PlayCommand
+from .playmode import PlaymodeCommand
+from .queue import QueueCommand
+from .reminder import ReminderCommand
+
+__all__ = ["PingCommand",
+ "CiaoruoziCommand",
+ "ColorCommand",
+ "CvCommand",
+ "DiarioCommand",
+ "Mp3Command",
+ "SummonCommand",
+ "PauseCommand",
+ "PlayCommand",
+ "PlaymodeCommand",
+ "QueueCommand",
+ "ReminderCommand"]
diff --git a/royalnet/commands/royalgames/ciaoruozi.py b/royalnet/commands/royalgames/ciaoruozi.py
new file mode 100644
index 00000000..5138f9a8
--- /dev/null
+++ b/royalnet/commands/royalgames/ciaoruozi.py
@@ -0,0 +1,24 @@
+import typing
+import telegram
+from ..command import Command
+from ..commandargs import CommandArgs
+from ..commanddata import CommandData
+
+
+class CiaoruoziCommand(Command):
+ name: str = "ciaoruozi"
+
+ description: str = "Saluta Ruozi, un leggendario essere che una volta era in Royal Games."
+
+ syntax: str = ""
+
+ require_alchemy_tables: typing.Set = set()
+
+ async def run(self, args: CommandArgs, data: CommandData) -> None:
+ if self.interface.name == "telegram":
+ update: telegram.Update = data.update
+ user: telegram.User = update.effective_user
+ if user.id == 112437036:
+ await data.reply("👋 Ciao me!")
+ return
+ await data.reply("👋 Ciao Ruozi!")
diff --git a/royalnet/commands/royalgames/color.py b/royalnet/commands/royalgames/color.py
new file mode 100644
index 00000000..871a0ee0
--- /dev/null
+++ b/royalnet/commands/royalgames/color.py
@@ -0,0 +1,15 @@
+import typing
+from ..command import Command
+from ..commandargs import CommandArgs
+from ..commanddata import CommandData
+
+
+class ColorCommand(Command):
+ name: str = "color"
+
+ description: str = "Invia un colore in chat...?"
+
+ async def run(self, args: CommandArgs, data: CommandData) -> None:
+ await data.reply("""
+ [i]I am sorry, unknown error occured during working with your request, Admin were notified[/i]
+ """)
diff --git a/royalnet/commands/cv.py b/royalnet/commands/royalgames/cv.py
similarity index 80%
rename from royalnet/commands/cv.py
rename to royalnet/commands/royalgames/cv.py
index cd4c37d2..79b22760 100644
--- a/royalnet/commands/cv.py
+++ b/royalnet/commands/royalgames/cv.py
@@ -1,11 +1,13 @@
-import typing
import discord
-import asyncio
-from ..utils import Command, Call, NetworkHandler, andformat
-from ..network import Request, ResponseSuccess
-from ..error import NoneFoundError, TooManyFoundError
-if typing.TYPE_CHECKING:
- from ..bots import DiscordBot
+import typing
+from ..command import Command
+from ..commandinterface import CommandInterface
+from ..commandargs import CommandArgs
+from ..commanddata import CommandData
+from ...network import Request, ResponseSuccess
+from ...utils import NetworkHandler, andformat
+from ...bots import DiscordBot
+from ...error import *
class CvNH(NetworkHandler):
@@ -41,14 +43,14 @@ class CvNH(NetworkHandler):
# Edit the message, sorted by channel
for channel in sorted(channels, key=lambda c: -c):
members_in_channels[channel].sort(key=lambda x: x.nick if x.nick is not None else x.name)
- if channel == 0:
+ if channel == 0 and len(members_in_channels[0]) > 0:
message += "[b]Non in chat vocale:[/b]\n"
else:
message += f"[b]In #{channels[channel].name}:[/b]\n"
for member in members_in_channels[channel]:
member: typing.Union[discord.User, discord.Member]
# Ignore not-connected non-notable members
- if not data["full"] and channel == 0 and len(member.roles) < 2:
+ if not data["everyone"] and channel == 0 and len(member.roles) < 2:
continue
# Ignore offline members
if member.status == discord.Status.offline and member.voice is None:
@@ -113,15 +115,20 @@ class CvNH(NetworkHandler):
class CvCommand(Command):
+ name: str = "cv"
- command_name = "cv"
- command_description = "Elenca le persone attualmente connesse alla chat vocale."
- command_syntax = "[guildname]"
+ description: str = "Elenca le persone attualmente connesse alla chat vocale."
- network_handlers = [CvNH]
+ syntax: str = "[guildname] "
- @classmethod
- async def common(cls, call: Call):
- guild_name = call.args.optional(0)
- response = await call.net_request(Request("discord_cv", {"guild_name": guild_name, "full": False}), "discord")
- await call.reply(response["response"])
+ def __init__(self, interface: CommandInterface):
+ super().__init__(interface)
+ interface.register_net_handler("discord_cv", CvNH)
+
+ async def run(self, args: CommandArgs, data: CommandData) -> None:
+ # noinspection PyTypeChecker
+ guild_name, everyone = args.match(r"(?:\[(.+)])?\s*(\S+)?\s*")
+ response = await self.interface.net_request(Request("discord_cv", {"guild_name": guild_name,
+ "everyone": bool(everyone)}),
+ destination="discord")
+ await data.reply(response["response"])
diff --git a/royalnet/commands/royalgames/diario.py b/royalnet/commands/royalgames/diario.py
new file mode 100644
index 00000000..5766d9d4
--- /dev/null
+++ b/royalnet/commands/royalgames/diario.py
@@ -0,0 +1,212 @@
+import typing
+import re
+import datetime
+import telegram
+import os
+import aiohttp
+from ..command import Command
+from ..commandargs import CommandArgs
+from ..commanddata import CommandData
+from ...database.tables import Royal, Diario, Alias
+from ...utils import asyncify
+from ...error import *
+
+
+async def to_imgur(photosizes: typing.List[telegram.PhotoSize], caption="") -> str:
+ # Select the largest photo
+ largest_photo = sorted(photosizes, key=lambda p: p.width * p.height)[-1]
+ # Get the photo url
+ photo_file: telegram.File = await asyncify(largest_photo.get_file)
+ # Forward the url to imgur, as an upload
+ try:
+ imgur_api_key = os.environ["IMGUR_CLIENT_ID"]
+ except KeyError:
+ raise InvalidConfigError("Missing IMGUR_CLIENT_ID envvar, can't upload images to imgur.")
+ async with aiohttp.request("post", "https://api.imgur.com/3/upload", data={
+ "image": photo_file.file_path,
+ "type": "URL",
+ "title": "Diario image",
+ "description": caption
+ }, headers={
+ "Authorization": f"Client-ID {imgur_api_key}"
+ }) as request:
+ response = await request.json()
+ if not response["success"]:
+ raise ExternalError("imgur returned an error in the image upload.")
+ return response["data"]["link"]
+
+
+class DiarioCommand(Command):
+ name: str = "diario"
+
+ description: str = "Aggiungi una citazione al Diario."
+
+ syntax = "[!] \"(testo)\" --[autore], [contesto]"
+
+ require_alchemy_tables = {Royal, Diario, Alias}
+
+ async def run(self, args: CommandArgs, data: CommandData) -> None:
+ if self.interface.name == "telegram":
+ update: telegram.Update = data.update
+ message: telegram.Message = update.message
+ reply: telegram.Message = message.reply_to_message
+ creator = await data.get_author()
+ # noinspection PyUnusedLocal
+ quoted: typing.Optional[str]
+ # noinspection PyUnusedLocal
+ text: typing.Optional[str]
+ # noinspection PyUnusedLocal
+ context: typing.Optional[str]
+ # noinspection PyUnusedLocal
+ timestamp: datetime.datetime
+ # noinspection PyUnusedLocal
+ media_url: typing.Optional[str]
+ # noinspection PyUnusedLocal
+ spoiler: bool
+ if creator is None:
+ await data.reply("⚠️ Devi essere registrato a Royalnet per usare questo comando!")
+ return
+ if reply is not None:
+ # Get the message text
+ text = reply.text
+ # Check if there's an image associated with the reply
+ photosizes: typing.Optional[typing.List[telegram.PhotoSize]] = reply.photo
+ if photosizes:
+ # Text is a caption
+ text = reply.caption
+ media_url = await to_imgur(photosizes, text if text is not None else "")
+ else:
+ media_url = None
+ # Ensure there is a text or an image
+ if not (text or media_url):
+ raise InvalidInputError("Missing text.")
+ # Find the Royalnet account associated with the sender
+ quoted_tg = await asyncify(self.interface.session.query(self.interface.alchemy.Telegram)
+ .filter_by(tg_id=reply.from_user.id)
+ .one_or_none)
+ quoted_account = quoted_tg.royal if quoted_tg is not None else None
+ # Find the quoted name to assign
+ quoted_user: telegram.User = reply.from_user
+ quoted = quoted_user.full_name
+ # Get the timestamp
+ timestamp = reply.date
+ # Set the other properties
+ spoiler = False
+ context = None
+ else:
+ # Get the current timestamp
+ timestamp = datetime.datetime.now()
+ # Get the message text
+ raw_text = " ".join(args)
+ # Check if there's an image associated with the reply
+ photosizes: typing.Optional[typing.List[telegram.PhotoSize]] = message.photo
+ if photosizes:
+ media_url = await to_imgur(photosizes, raw_text if raw_text is not None else "")
+ else:
+ media_url = None
+ # Parse the text, if it exists
+ if raw_text:
+ # Pass the sentence through the diario regex
+ match = re.match(
+ r'(!)? *["«‘“‛‟❛❝〝"`]([^"]+)["»’”❜❞〞"`] *(?:(?:-{1,2}|—) *([\w ]+))?(?:, *([^ ].*))?',
+ raw_text)
+ # Find the corresponding matches
+ if match is not None:
+ spoiler = bool(match.group(1))
+ text = match.group(2)
+ quoted = match.group(3)
+ context = match.group(4)
+ # Otherwise, consider everything part of the text
+ else:
+ spoiler = False
+ text = raw_text
+ quoted = None
+ context = None
+ # Ensure there's a quoted
+ if not quoted:
+ quoted = None
+ if not context:
+ context = None
+ # Find if there's a Royalnet account associated with the quoted name
+ if quoted is not None:
+ quoted_alias = await asyncify(
+ self.interface.session.query(self.interface.alchemy.Alias)
+ .filter_by(alias=quoted.lower()).one_or_none)
+ else:
+ quoted_alias = None
+ quoted_account = quoted_alias.royal if quoted_alias is not None else None
+ else:
+ text = None
+ quoted = None
+ quoted_account = None
+ spoiler = False
+ context = None
+ # Ensure there is a text or an image
+ if not (text or media_url):
+ raise InvalidInputError("Missing text.")
+ # Create the diario quote
+ diario = self.interface.alchemy.Diario(creator=creator,
+ quoted_account=quoted_account,
+ quoted=quoted,
+ text=text,
+ context=context,
+ timestamp=timestamp,
+ media_url=media_url,
+ spoiler=spoiler)
+ self.interface.session.add(diario)
+ await asyncify(self.interface.session.commit)
+ await data.reply(f"✅ {str(diario)}")
+ else:
+ # Find the creator of the quotes
+ creator = await data.get_author(error_if_none=True)
+ # Recreate the full sentence
+ raw_text = " ".join(args)
+ # Pass the sentence through the diario regex
+ match = re.match(r'(!)? *["«‘“‛‟❛❝〝"`]([^"]+)["»’”❜❞〞"`] *(?:(?:-{1,2}|—) *([\w ]+))?(?:, *([^ ].*))?',
+ raw_text)
+ # Find the corresponding matches
+ if match is not None:
+ spoiler = bool(match.group(1))
+ text = match.group(2)
+ quoted = match.group(3)
+ context = match.group(4)
+ # Otherwise, consider everything part of the text
+ else:
+ spoiler = False
+ text = raw_text
+ quoted = None
+ context = None
+ timestamp = datetime.datetime.now()
+ # Ensure there is some text
+ if not text:
+ raise InvalidInputError("Missing text.")
+ # Or a quoted
+ if not quoted:
+ quoted = None
+ if not context:
+ context = None
+ # Find if there's a Royalnet account associated with the quoted name
+ if quoted is not None:
+ quoted_alias = await asyncify(
+ self.interface.session.query(self.interface.alchemy.Alias)
+ .filter_by(alias=quoted.lower())
+ .one_or_none)
+ else:
+ quoted_alias = None
+ quoted_account = quoted_alias.royal if quoted_alias is not None else None
+ if quoted_alias is not None and quoted_account is None:
+ await data.reply("⚠️ Il nome dell'autore è ambiguo, quindi la riga non è stata aggiunta.\n"
+ "Per piacere, ripeti il comando con un nome più specifico!")
+ return
+ # Create the diario quote
+ diario = self.interface.alchemy.Diario(creator=creator,
+ quoted_account=quoted_account,
+ quoted=quoted,
+ text=text,
+ context=context,
+ timestamp=timestamp,
+ media_url=None,
+ spoiler=spoiler)
+ self.interface.session.add(diario)
+ await asyncify(self.interface.session.commit)
+ await data.reply(f"✅ {str(diario)}")
diff --git a/royalnet/commands/royalgames/mp3.py b/royalnet/commands/royalgames/mp3.py
new file mode 100644
index 00000000..e3734e59
--- /dev/null
+++ b/royalnet/commands/royalgames/mp3.py
@@ -0,0 +1,40 @@
+import typing
+import urllib.parse
+import asyncio
+from ..command import Command
+from ..commandargs import CommandArgs
+from ..commanddata import CommandData
+from ...utils import asyncify
+from ...audio import YtdlMp3
+
+
+class Mp3Command(Command):
+ name: str = "mp3"
+
+ description: str = "Scarica un video con youtube-dl e invialo in chat."
+
+ syntax = "(ytdlstring)"
+
+ ytdl_args = {
+ "format": "bestaudio",
+ "outtmpl": f"./downloads/%(title)s.%(ext)s"
+ }
+
+ seconds_before_deletion = 15 * 60
+
+ async def run(self, args: CommandArgs, data: CommandData) -> None:
+ url = args.joined()
+ if url.startswith("http://") or url.startswith("https://"):
+ vfiles: typing.List[YtdlMp3] = await asyncify(YtdlMp3.create_and_ready_from_url,
+ url,
+ **self.ytdl_args)
+ else:
+ vfiles = await asyncify(YtdlMp3.create_and_ready_from_url, f"ytsearch:{url}", **self.ytdl_args)
+ for vfile in vfiles:
+ await data.reply(f"⬇️ Il file richiesto può essere scaricato a:\n"
+ f"https://scaleway.steffo.eu/{urllib.parse.quote(vfile.mp3_filename.replace('./downloads/', './musicbot_cache/'))}\n"
+ f"Verrà eliminato tra {self.seconds_before_deletion} secondi.")
+ await asyncio.sleep(self.seconds_before_deletion)
+ for vfile in vfiles:
+ vfile.delete()
+ await data.reply(f"⏹ Il file {vfile.info.title} è scaduto ed è stato eliminato.")
diff --git a/royalnet/commands/author.py b/royalnet/commands/royalgames/old/author.py
similarity index 100%
rename from royalnet/commands/author.py
rename to royalnet/commands/royalgames/old/author.py
diff --git a/royalnet/commands/dateparser.py b/royalnet/commands/royalgames/old/dateparser.py
similarity index 100%
rename from royalnet/commands/dateparser.py
rename to royalnet/commands/royalgames/old/dateparser.py
diff --git a/royalnet/commands/debug_create.py b/royalnet/commands/royalgames/old/debug_create.py
similarity index 100%
rename from royalnet/commands/debug_create.py
rename to royalnet/commands/royalgames/old/debug_create.py
diff --git a/royalnet/commands/error_handler.py b/royalnet/commands/royalgames/old/error_handler.py
similarity index 100%
rename from royalnet/commands/error_handler.py
rename to royalnet/commands/royalgames/old/error_handler.py
diff --git a/royalnet/commands/id.py b/royalnet/commands/royalgames/old/id.py
similarity index 100%
rename from royalnet/commands/id.py
rename to royalnet/commands/royalgames/old/id.py
diff --git a/royalnet/commands/kv.py b/royalnet/commands/royalgames/old/kv.py
similarity index 100%
rename from royalnet/commands/kv.py
rename to royalnet/commands/royalgames/old/kv.py
diff --git a/royalnet/commands/kvactive.py b/royalnet/commands/royalgames/old/kvactive.py
similarity index 100%
rename from royalnet/commands/kvactive.py
rename to royalnet/commands/royalgames/old/kvactive.py
diff --git a/royalnet/commands/kvroll.py b/royalnet/commands/royalgames/old/kvroll.py
similarity index 100%
rename from royalnet/commands/kvroll.py
rename to royalnet/commands/royalgames/old/kvroll.py
diff --git a/royalnet/commands/missing.py b/royalnet/commands/royalgames/old/missing.py
similarity index 100%
rename from royalnet/commands/missing.py
rename to royalnet/commands/royalgames/old/missing.py
diff --git a/royalnet/commands/null.py b/royalnet/commands/royalgames/old/null.py
similarity index 100%
rename from royalnet/commands/null.py
rename to royalnet/commands/royalgames/old/null.py
diff --git a/royalnet/commands/reminder.py b/royalnet/commands/royalgames/old/reminder.py
similarity index 100%
rename from royalnet/commands/reminder.py
rename to royalnet/commands/royalgames/old/reminder.py
diff --git a/royalnet/commands/royalnetprofile.py b/royalnet/commands/royalgames/old/royalnetprofile.py
similarity index 100%
rename from royalnet/commands/royalnetprofile.py
rename to royalnet/commands/royalgames/old/royalnetprofile.py
diff --git a/royalnet/commands/sync.py b/royalnet/commands/royalgames/old/sync.py
similarity index 100%
rename from royalnet/commands/sync.py
rename to royalnet/commands/royalgames/old/sync.py
diff --git a/royalnet/commands/videoinfo.py b/royalnet/commands/royalgames/old/videoinfo.py
similarity index 100%
rename from royalnet/commands/videoinfo.py
rename to royalnet/commands/royalgames/old/videoinfo.py
diff --git a/royalnet/commands/pause.py b/royalnet/commands/royalgames/pause.py
similarity index 52%
rename from royalnet/commands/pause.py
rename to royalnet/commands/royalgames/pause.py
index 95437ff4..00f42d29 100644
--- a/royalnet/commands/pause.py
+++ b/royalnet/commands/royalgames/pause.py
@@ -1,10 +1,14 @@
import typing
import discord
-from ..network import Request, ResponseSuccess
-from ..utils import Command, Call, NetworkHandler
-from ..error import TooManyFoundError, NoneFoundError
+from ..command import Command
+from ..commandinterface import CommandInterface
+from ..commandargs import CommandArgs
+from ..commanddata import CommandData
+from ...utils import NetworkHandler
+from ...network import Request, ResponseSuccess
+from ...error import NoneFoundError
if typing.TYPE_CHECKING:
- from ..bots import DiscordBot
+ from ...bots import DiscordBot
class PauseNH(NetworkHandler):
@@ -32,22 +36,24 @@ class PauseNH(NetworkHandler):
voice_client._player.resume()
else:
voice_client._player.pause()
- return ResponseSuccess({"resume": resume})
+ return ResponseSuccess({"resumed": resume})
class PauseCommand(Command):
+ name: str = "pause"
- command_name = "pause"
- command_description = "Mette in pausa o riprende la riproduzione della canzone attuale."
- command_syntax = "[ [guild] ]"
+ description: str = "Mette in pausa o riprende la riproduzione della canzone attuale."
- network_handlers = [PauseNH]
+ syntax = "[ [guild] ]"
- @classmethod
- async def common(cls, call: Call):
- guild, = call.args.match(r"(?:\[(.+)])?")
- response = await call.net_request(Request("music_pause", {"guild_name": guild}), "discord")
- if response["resume"]:
- await call.reply(f"▶️ Riproduzione ripresa.")
+ def __init__(self, interface: CommandInterface):
+ super().__init__(interface)
+ interface.register_net_handler(PauseNH.message_type, PauseNH)
+
+ async def run(self, args: CommandArgs, data: CommandData) -> None:
+ guild, = args.match(r"(?:\[(.+)])?")
+ response = await self.interface.net_request(Request("music_pause", {"guild_name": guild}), "discord")
+ if response["resumed"]:
+ await data.reply(f"▶️ Riproduzione ripresa.")
else:
- await call.reply(f"⏸ Riproduzione messa in pausa.")
+ await data.reply(f"⏸ Riproduzione messa in pausa.")
diff --git a/royalnet/commands/royalgames/ping.py b/royalnet/commands/royalgames/ping.py
new file mode 100644
index 00000000..5fbefea4
--- /dev/null
+++ b/royalnet/commands/royalgames/ping.py
@@ -0,0 +1,13 @@
+import typing
+from ..command import Command
+from ..commandargs import CommandArgs
+from ..commanddata import CommandData
+
+
+class PingCommand(Command):
+ name: str = "ping"
+
+ description: str = "Gioca a ping-pong con il bot."
+
+ async def run(self, args: CommandArgs, data: CommandData) -> None:
+ await data.reply("🏓 Pong!")
diff --git a/royalnet/commands/royalgames/play.py b/royalnet/commands/royalgames/play.py
new file mode 100644
index 00000000..8a091857
--- /dev/null
+++ b/royalnet/commands/royalgames/play.py
@@ -0,0 +1,75 @@
+import typing
+import pickle
+from ..command import Command
+from ..commandinterface import CommandInterface
+from ..commandargs import CommandArgs
+from ..commanddata import CommandData
+from ...utils import NetworkHandler, asyncify
+from ...network import Request, ResponseSuccess
+from ...error import *
+from ...audio import YtdlDiscord
+if typing.TYPE_CHECKING:
+ from ...bots import DiscordBot
+
+
+class PlayNH(NetworkHandler):
+ message_type = "music_play"
+
+ ytdl_args = {
+ "format": "bestaudio",
+ "outtmpl": f"./downloads/%(title)s.%(ext)s"
+ }
+
+ @classmethod
+ async def discord(cls, bot: "DiscordBot", data: dict):
+ """Handle a play Royalnet request. That is, add audio to a PlayMode."""
+ # Find the matching guild
+ if data["guild_name"]:
+ guild = bot.client.find_guild(data["guild_name"])
+ else:
+ if len(bot.music_data) == 0:
+ raise NoneFoundError("No voice clients active")
+ if len(bot.music_data) > 1:
+ raise TooManyFoundError("Multiple guilds found")
+ guild = list(bot.music_data)[0]
+ # Ensure the guild has a PlayMode before adding the file to it
+ if not bot.music_data.get(guild):
+ # TODO: change Exception
+ raise Exception("No music_data for this guild")
+ # Start downloading
+ if data["url"].startswith("http://") or data["url"].startswith("https://"):
+ dfiles: typing.List[YtdlDiscord] = await asyncify(YtdlDiscord.create_and_ready_from_url, data["url"], **cls.ytdl_args)
+ else:
+ dfiles = await asyncify(YtdlDiscord.create_and_ready_from_url, f"ytsearch:{data['url']}", **cls.ytdl_args)
+ await bot.add_to_music_data(dfiles, guild)
+ # Create response dictionary
+ response = {
+ "videos": [{
+ "title": dfile.info.title,
+ "discord_embed_pickle": str(pickle.dumps(dfile.info.to_discord_embed()))
+ } for dfile in dfiles]
+ }
+ return ResponseSuccess(response)
+
+
+class PlayCommand(Command):
+ name: str = "play"
+
+ description: str = "Aggiunge una canzone alla coda della chat vocale."
+
+ syntax = "[ [guild] ] (url)"
+
+ def __init__(self, interface: CommandInterface):
+ super().__init__(interface)
+ interface.register_net_handler(PlayNH.message_type, PlayNH)
+
+ async def run(self, args: CommandArgs, data: CommandData) -> None:
+ guild_name, url = args.match(r"(?:\[(.+)])?\s*(.+)>?")
+ await self.interface.net_request(Request("music_play", {"url": url, "guild_name": guild_name}), "discord")
+ for video in data["videos"]:
+ if self.interface.name == "discord":
+ # This is one of the unsafest things ever
+ embed = pickle.loads(eval(video["discord_embed_pickle"]))
+ await data.message.channel.send(content="▶️ Aggiunto alla coda:", embed=embed)
+ else:
+ await data.reply(f"▶️ Aggiunto alla coda: [i]{video['title']}[/i]")
diff --git a/royalnet/commands/playmode.py b/royalnet/commands/royalgames/playmode.py
similarity index 51%
rename from royalnet/commands/playmode.py
rename to royalnet/commands/royalgames/playmode.py
index f562bd5a..989d7d95 100644
--- a/royalnet/commands/playmode.py
+++ b/royalnet/commands/royalgames/playmode.py
@@ -1,10 +1,15 @@
import typing
-from ..utils import Command, Call, NetworkHandler
-from ..network import Request, ResponseSuccess
-from ..error import NoneFoundError, TooManyFoundError
-from ..audio.playmodes import Playlist, Pool, Layers
+import pickle
+from ..command import Command
+from ..commandinterface import CommandInterface
+from ..commandargs import CommandArgs
+from ..commanddata import CommandData
+from ...utils import NetworkHandler
+from ...network import Request, ResponseSuccess
+from ...error import *
+from ...audio.playmodes import Playlist, Pool, Layers
if typing.TYPE_CHECKING:
- from ..bots import DiscordBot
+ from ...bots import DiscordBot
class PlaymodeNH(NetworkHandler):
@@ -38,14 +43,19 @@ class PlaymodeNH(NetworkHandler):
class PlaymodeCommand(Command):
- command_name = "playmode"
- command_description = "Cambia modalità di riproduzione per la chat vocale."
- command_syntax = "[ [guild] ] (mode)"
+ name: str = "playmode"
- network_handlers = [PlaymodeNH]
+ description: str = "Cambia modalità di riproduzione per la chat vocale."
- @classmethod
- async def common(cls, call: Call):
- guild_name, mode_name = call.args.match(r"(?:\[(.+)])?\s*(\S+)\s*")
- await call.net_request(Request("music_playmode", {"mode_name": mode_name, "guild_name": guild_name}), "discord")
- await call.reply(f"✅ Modalità di riproduzione [c]{mode_name}[/c].")
+ syntax = "[ [guild] ] (mode)"
+
+ def __init__(self, interface: CommandInterface):
+ super().__init__(interface)
+ interface.register_net_handler(PlaymodeNH.message_type, PlaymodeNH)
+
+ async def run(self, args: CommandArgs, data: CommandData) -> None:
+ guild_name, mode_name = args.match(r"(?:\[(.+)])?\s*(\S+)\s*")
+ await self.interface.net_request(Request(PlaymodeNH.message_type, {"mode_name": mode_name,
+ "guild_name": guild_name}),
+ "discord")
+ await data.reply(f"🔃 Impostata la modalità di riproduzione a: [c]{mode_name}[/c].")
diff --git a/royalnet/commands/queue.py b/royalnet/commands/royalgames/queue.py
similarity index 72%
rename from royalnet/commands/queue.py
rename to royalnet/commands/royalgames/queue.py
index adba83a2..7db63b5c 100644
--- a/royalnet/commands/queue.py
+++ b/royalnet/commands/royalgames/queue.py
@@ -1,10 +1,14 @@
import typing
import pickle
-from ..network import Request, ResponseSuccess
-from ..utils import Command, Call, NetworkHandler, numberemojiformat
-from ..error import TooManyFoundError, NoneFoundError
+from ..command import Command
+from ..commandinterface import CommandInterface
+from ..commandargs import CommandArgs
+from ..commanddata import CommandData
+from ...utils import NetworkHandler, numberemojiformat
+from ...network import Request, ResponseSuccess
+from ...error import *
if typing.TYPE_CHECKING:
- from ..bots import DiscordBot
+ from ...bots import DiscordBot
class QueueNH(NetworkHandler):
@@ -44,22 +48,24 @@ class QueueNH(NetworkHandler):
class QueueCommand(Command):
+ name: str = "queue"
- command_name = "queue"
- command_description = "Visualizza un'anteprima della coda di riproduzione attuale."
- command_syntax = "[ [guild] ]"
+ description: str = "Visualizza la coda di riproduzione attuale."
- network_handlers = [QueueNH]
+ syntax = "[ [guild] ]"
- @classmethod
- async def common(cls, call: Call):
- guild, = call.args.match(r"(?:\[(.+)])?")
- data = await call.net_request(Request("music_queue", {"guild_name": guild}), "discord")
+ def __init__(self, interface: CommandInterface):
+ super().__init__(interface)
+ interface.register_net_handler(QueueNH.message_type, QueueNH)
+
+ async def run(self, args: CommandArgs, data: CommandData) -> None:
+ guild, = args.match(r"(?:\[(.+)])?")
+ data = await self.interface.net_request(Request(QueueNH.message_type, {"guild_name": guild}), "discord")
if data["type"] is None:
- await call.reply("ℹ️ Non c'è nessuna coda di riproduzione attiva al momento.")
+ await data.reply("ℹ️ Non c'è nessuna coda di riproduzione attiva al momento.")
return
elif "queue" not in data:
- await call.reply(f"ℹ️ La coda di riproduzione attuale ([c]{data['type']}[/c]) non permette l'anteprima.")
+ await data.reply(f"ℹ️ La coda di riproduzione attuale ([c]{data['type']}[/c]) non permette l'anteprima.")
return
if data["type"] == "Playlist":
if len(data["queue"]["strings"]) == 0:
@@ -81,10 +87,10 @@ class QueueCommand(Command):
message = f"ℹ️ Il PlayMode attuale, [c]{data['type']}[/c], è vuoto.\n"
else:
message = f"ℹ️ Il PlayMode attuale, [c]{data['type']}[/c], contiene {len(data['queue']['strings'])} elementi:\n"
- if call.interface_name == "discord":
- await call.reply(message)
+ if self.interface.name == "discord":
+ await data.reply(message)
for embed in pickle.loads(eval(data["queue"]["pickled_embeds"]))[:5]:
- await call.channel.send(embed=embed)
+ await data.message.channel.send(embed=embed)
else:
message += numberemojiformat(data["queue"]["strings"][:10])
- await call.reply(message)
+ await data.reply(message)
diff --git a/royalnet/commands/royalgames/rage.py b/royalnet/commands/royalgames/rage.py
new file mode 100644
index 00000000..1112efa5
--- /dev/null
+++ b/royalnet/commands/royalgames/rage.py
@@ -0,0 +1,20 @@
+import typing
+import random
+from ..command import Command
+from ..commandargs import CommandArgs
+from ..commanddata import CommandData
+
+
+class RageCommand(Command):
+ name: str = "ship"
+
+ description: str = "Arrabbiati per qualcosa, come una software house californiana."
+
+ MAD = ["MADDEN MADDEN MADDEN MADDEN",
+ "EA bad, praise Geraldo!",
+ "Stai sfogando la tua ira sul bot!",
+ "Basta, io cambio gilda!",
+ "Fondiamo la RRYG!"]
+
+ async def run(self, args: CommandArgs, data: CommandData) -> None:
+ await data.reply(f"😠 {random.sample(self.MAD, 1)[0]}")
diff --git a/royalnet/commands/royalgames/reminder.py b/royalnet/commands/royalgames/reminder.py
new file mode 100644
index 00000000..bb7cda3f
--- /dev/null
+++ b/royalnet/commands/royalgames/reminder.py
@@ -0,0 +1,79 @@
+import typing
+import dateparser
+import datetime
+import pickle
+import telegram
+import discord
+from sqlalchemy import and_
+from ..command import Command
+from ..commandargs import CommandArgs
+from ..commandinterface import CommandInterface
+from ..commanddata import CommandData
+from ...utils import sleep_until, asyncify, telegram_escape, discord_escape
+from ...database.tables import Reminder
+from ...error import *
+
+
+class ReminderCommand(Command):
+ name: str = "reminder"
+
+ description: str = "Ti ricorda di fare qualcosa dopo un po' di tempo."
+
+ syntax: str = "[ (data) ] (messaggio)"
+
+ require_alchemy_tables = {Reminder}
+
+ def __init__(self, interface: CommandInterface):
+ super().__init__(interface)
+ reminders = (
+ interface.session
+ .query(interface.alchemy.Reminder)
+ .filter(and_(
+ interface.alchemy.Reminder.datetime >= datetime.datetime.now(),
+ interface.alchemy.Reminder.interface_name == interface.name))
+ .all()
+ )
+ for reminder in reminders:
+ interface.loop.create_task(self.remind(reminder))
+
+ async def remind(self, reminder):
+ await sleep_until(reminder.datetime)
+ if self.interface.name == "telegram":
+ chat_id: int = pickle.loads(reminder.interface_data)
+ bot: telegram.Bot = self.interface.bot.client
+ await asyncify(bot.send_message,
+ chat_id=chat_id,
+ text=telegram_escape(f"❗️ {reminder.message}"),
+ parse_mode="HTML",
+ disable_web_page_preview=True)
+ elif self.interface.name == "discord":
+ channel_id: int = pickle.loads(reminder.interface_data)
+ bot: discord.Client = self.interface.bot.client
+ channel = bot.get_channel(channel_id)
+ await channel.send(discord_escape(f"❗️ {reminder.message}"))
+
+ async def run(self, args: CommandArgs, data: CommandData) -> None:
+ date_str, reminder_text = args.match(r"\[ *(.+?) *] *(.+?) *$")
+ try:
+ date: typing.Optional[datetime.datetime] = dateparser.parse(date_str)
+ except OverflowError:
+ date = None
+ if date is None:
+ await data.reply("⚠️ La data che hai inserito non è valida.")
+ return
+ await data.reply(f"✅ Promemoria impostato per [b]{date.strftime('%Y-%m-%d %H:%M:%S')}[/b]")
+ if self.interface.name == "telegram":
+ interface_data = pickle.dumps(data.update.effective_chat.id)
+ elif self.interface.name == "discord":
+ interface_data = pickle.dumps(data.message.channel.id)
+ else:
+ raise UnsupportedError("Interface not supported")
+ creator = await data.get_author()
+ reminder = self.interface.alchemy.Reminder(creator=creator,
+ interface_name=self.interface.name,
+ interface_data=interface_data,
+ datetime=date,
+ message=reminder_text)
+ self.interface.loop.create_task(self.remind(reminder))
+ self.interface.session.add(reminder)
+ await asyncify(self.interface.session.commit)
diff --git a/royalnet/commands/ship.py b/royalnet/commands/royalgames/ship.py
similarity index 66%
rename from royalnet/commands/ship.py
rename to royalnet/commands/royalgames/ship.py
index 8ec4e661..7fb354e1 100644
--- a/royalnet/commands/ship.py
+++ b/royalnet/commands/royalgames/ship.py
@@ -1,22 +1,23 @@
+import typing
import re
-from ..utils import Command, Call, safeformat
-
-
-SHIP_RESULT = "💕 {one} + {two} = [b]{result}[/b]"
+from ..command import Command
+from ..commandargs import CommandArgs
+from ..commanddata import CommandData
+from ...utils import safeformat
class ShipCommand(Command):
+ name: str = "ship"
- command_name = "ship"
- command_description = "Crea una ship tra due cose."
- command_syntax = "(uno) (due)"
+ description: str = "Crea una ship tra due nomi."
- @classmethod
- async def common(cls, call: Call):
- name_one = call.args[0]
- name_two = call.args[1]
+ syntax = "(nomeuno) (nomedue)"
+
+ async def run(self, args: CommandArgs, data: CommandData) -> None:
+ name_one = args[0]
+ name_two = args[1]
if name_two == "+":
- name_two = call.args[2]
+ name_two = args[2]
name_one = name_one.lower()
name_two = name_two.lower()
# Get all letters until the first vowel, included
@@ -33,7 +34,7 @@ class ShipCommand(Command):
part_two = match_two.group(0)
# Combine the two name parts
mixed = part_one + part_two
- await call.reply(safeformat(SHIP_RESULT,
+ await data.reply(safeformat("💕 {one} + {two} = [b]{result}[/b]",
one=name_one.capitalize(),
two=name_two.capitalize(),
result=mixed.capitalize()))
diff --git a/royalnet/commands/skip.py b/royalnet/commands/royalgames/skip.py
similarity index 52%
rename from royalnet/commands/skip.py
rename to royalnet/commands/royalgames/skip.py
index a91a02b4..74201e42 100644
--- a/royalnet/commands/skip.py
+++ b/royalnet/commands/royalgames/skip.py
@@ -1,10 +1,16 @@
import typing
+import pickle
import discord
-from ..network import Request, ResponseSuccess
-from ..utils import Command, Call, NetworkHandler
-from ..error import TooManyFoundError, NoneFoundError
+from ..command import Command
+from ..commandinterface import CommandInterface
+from ..commandargs import CommandArgs
+from ..commanddata import CommandData
+from ...utils import NetworkHandler, asyncify
+from ...network import Request, ResponseSuccess
+from ...error import *
+from ...audio import YtdlDiscord
if typing.TYPE_CHECKING:
- from ..bots import DiscordBot
+ from ...bots import DiscordBot
class SkipNH(NetworkHandler):
@@ -31,15 +37,17 @@ class SkipNH(NetworkHandler):
class SkipCommand(Command):
+ name: str = "skip"
- command_name = "skip"
- command_description = "Salta la canzone attualmente in riproduzione in chat vocale."
- command_syntax = "[ [guild] ]"
+ description: str = "Salta la canzone attualmente in riproduzione in chat vocale."
- network_handlers = [SkipNH]
+ syntax: str = "[ [guild] ]"
- @classmethod
- async def common(cls, call: Call):
- guild, = call.args.match(r"(?:\[(.+)])?")
- await call.net_request(Request("music_skip", {"guild_name": guild}), "discord")
- await call.reply(f"✅ Richiesto lo skip della canzone attuale.")
+ def __init__(self, interface: CommandInterface):
+ super().__init__(interface)
+ interface.register_net_handler(SkipNH.message_type, SkipNH)
+
+ async def run(self, args: CommandArgs, data: CommandData) -> None:
+ guild, = args.match(r"(?:\[(.+)])?")
+ await self.interface.net_request(Request(SkipNH.message_type, {"guild_name": guild}), "discord")
+ await data.reply(f"⏩ Richiesto lo skip della canzone attuale.")
diff --git a/royalnet/commands/royalgames/smecds.py b/royalnet/commands/royalgames/smecds.py
new file mode 100644
index 00000000..abac8a90
--- /dev/null
+++ b/royalnet/commands/royalgames/smecds.py
@@ -0,0 +1,79 @@
+import typing
+import random
+from ..command import Command
+from ..commandargs import CommandArgs
+from ..commanddata import CommandData
+from ...utils import safeformat
+
+
+class SmecdsCommand(Command):
+ name: str = "smecds"
+
+ description: str = "Secondo me, è colpa dello stagista..."
+
+ syntax = ""
+
+ DS_LIST = ["della secca", "del seccatore", "del secchiello", "del secchio", "del secchione", "del secondino",
+ "del sedano", "del sedativo", "della sedia", "del sedicente", "del sedile", "della sega", "del segale",
+ "della segatura", "della seggiola", "del seggiolino", "della seggiovia", "della segheria",
+ "del seghetto",
+ "del segnalibro", "del segnaposto", "del segno", "del segretario", "della segreteria", "del seguace",
+ "del segugio", "della selce", "della sella", "della selz", "della selva", "della selvaggina",
+ "del semaforo",
+ "del seme", "del semifreddo", "del seminario", "della seminarista", "della semola", "del semolino",
+ "del semplicione", "della senape", "del senatore", "del seno", "del sensore", "della sentenza",
+ "della sentinella", "del sentore", "della seppia", "del sequestratore", "della serenata", "del sergente",
+ "del sermone", "della serpe", "del serpente", "della serpentina", "della serra", "del serraglio",
+ "del serramanico", "della serranda", "della serratura", "del servitore", "della servitù",
+ "del servizievole",
+ "del servo", "del set", "della seta", "della setola", "del sidecar", "del siderurgico", "del sidro",
+ "della siepe", "del sifone", "della sigaretta", "del sigaro", "del sigillo", "della signora",
+ "della signorina", "del silenziatore", "della silhouette", "del silicio", "del silicone", "del siluro",
+ "della sinagoga", "della sindacalista", "del sindacato", "del sindaco", "della sindrome",
+ "della sinfonia",
+ "del sipario", "del sire", "della sirena", "della siringa", "del sismografo", "del sobborgo",
+ "del sobillatore", "del sobrio", "del soccorritore", "del socio", "del sociologo", "della soda",
+ "del sofà",
+ "della soffitta", "del software", "dello sogghignare", "del soggiorno", "della sogliola",
+ "del sognatore",
+ "della soia", "del solaio", "del solco", "del soldato", "del soldo", "del sole", "della soletta",
+ "della solista", "del solitario", "del sollazzare", "del sollazzo", "del sollecito", "del solleone",
+ "del solletico", "del sollevare", "del sollievo", "del solstizio", "del solubile", "del solvente",
+ "della soluzione", "del somaro", "del sombrero", "del sommergibile", "del sommo", "della sommossa",
+ "del sommozzatore", "del sonar", "della sonda", "del sondaggio", "del sondare", "del sonnacchioso",
+ "del sonnambulo", "del sonnellino", "del sonnifero", "del sonno", "della sonnolenza", "del sontuoso",
+ "del soppalco", "del soprabito", "del sopracciglio", "del sopraffare", "del sopraffino",
+ "del sopraluogo",
+ "del sopramobile", "del soprannome", "del soprano", "del soprappensiero", "del soprassalto",
+ "del soprassedere", "del sopravvento", "del sopravvivere", "del soqquadro", "del sorbetto",
+ "del sordido",
+ "della sordina", "del sordo", "della sorella", "della sorgente", "del sornione", "del sorpasso",
+ "della sorpresa", "del sorreggere", "del sorridere", "della sorsata", "del sorteggio", "del sortilegio",
+ "del sorvegliante", "del sorvolare", "del sosia", "del sospettoso", "del sospirare", "della sosta",
+ "della sostanza", "del sostegno", "del sostenitore", "del sostituto", "del sottaceto", "della sottana",
+ "del sotterfugio", "del sotterraneo", "del sottile", "del sottilizzare", "del sottintendere",
+ "del sottobanco", "del sottobosco", "del sottomarino", "del sottopassaggio", "del sottoposto",
+ "del sottoscala", "della sottoscrizione", "del sottostare", "del sottosuolo", "del sottotetto",
+ "del sottotitolo", "del sottovalutare", "del sottovaso", "della sottoveste", "del sottovuoto",
+ "del sottufficiale", "della soubrette", "del souvenir", "del soverchiare", "del sovrano",
+ "del sovrapprezzo",
+ "della sovvenzione", "del sovversivo", "del sozzo", "dello suadente", "del sub", "del subalterno",
+ "del subbuglio", "del subdolo", "del sublime", "del suburbano", "del successore", "del succo",
+ "della succube", "del succulento", "della succursale", "del sudario", "della sudditanza", "del suddito",
+ "del sudicio", "del suffisso", "del suffragio", "del suffumigio", "del suggeritore", "del sughero",
+ "del sugo", "del suino", "della suite", "del sulfureo", "del sultano", "di Steffo", "di Spaggia",
+ "di Sabrina", "del sas", "del ses", "del sis", "del sos", "del sus", "della supremazia",
+ "del Santissimo",
+ "della scatola", "del supercalifragilistichespiralidoso", "del sale", "del salame", "di (Town of) Salem",
+ "di Stronghold", "di SOMA", "dei Saints", "di S.T.A.L.K.E.R.", "di Sanctum", "dei Sims", "di Sid",
+ "delle Skullgirls", "di Sonic", "di Spiral (Knights)", "di Spore", "di Starbound", "di SimCity",
+ "di Sensei",
+ "di Ssssssssssssss... Boom! E' esploso il dizionario", "della scala", "di Sakura", "di Suzie",
+ "di Shinji",
+ "del senpai", "del support", "di Superman", "di Sekiro", "dello Slime God", "del salassato",
+ "della salsa"]
+ SMECDS = "🤔 Secondo me, è colpa {ds}."
+
+ async def run(self, args: CommandArgs, data: CommandData) -> None:
+ ds = random.sample(self.DS_LIST, 1)[0]
+ await data.reply(safeformat(self.SMECDS, ds=ds))
diff --git a/royalnet/commands/royalgames/summon.py b/royalnet/commands/royalgames/summon.py
new file mode 100644
index 00000000..c583a9dd
--- /dev/null
+++ b/royalnet/commands/royalgames/summon.py
@@ -0,0 +1,74 @@
+import typing
+import discord
+from ..command import Command
+from ..commandinterface import CommandInterface
+from ..commandargs import CommandArgs
+from ..commanddata import CommandData
+from ...utils import NetworkHandler
+from ...network import Request, ResponseSuccess
+from ...error import NoneFoundError
+if typing.TYPE_CHECKING:
+ from ...bots import DiscordBot
+
+
+class SummonNH(NetworkHandler):
+ message_type = "music_summon"
+
+ @classmethod
+ async def discord(cls, bot: "DiscordBot", data: dict):
+ """Handle a summon Royalnet request.
+ That is, join a voice channel, or move to a different one if that is not possible."""
+ channel = bot.client.find_channel_by_name(data["channel_name"])
+ if not isinstance(channel, discord.VoiceChannel):
+ raise NoneFoundError("Channel is not a voice channel")
+ bot.loop.create_task(bot.client.vc_connect_or_move(channel))
+ return ResponseSuccess()
+
+
+class SummonCommand(Command):
+ name: str = "summon"
+
+ description: str = "Evoca il bot in un canale vocale."
+
+ syntax: str = "[nomecanale]"
+
+ def __init__(self, interface: CommandInterface):
+ super().__init__(interface)
+ interface.register_net_handler(SummonNH.message_type, SummonNH)
+
+ async def run(self, args: CommandArgs, data: CommandData) -> None:
+ if self.interface.name == "discord":
+ bot = self.interface.bot.client
+ message: discord.Message = data.message
+ channel_name: str = args.optional(0)
+ if channel_name:
+ guild: typing.Optional[discord.Guild] = message.guild
+ if guild is not None:
+ channels: typing.List[discord.abc.GuildChannel] = guild.channels
+ else:
+ channels = bot.get_all_channels()
+ matching_channels: typing.List[discord.VoiceChannel] = []
+ for channel in channels:
+ if isinstance(channel, discord.VoiceChannel):
+ if channel.name == channel_name:
+ matching_channels.append(channel)
+ if len(matching_channels) == 0:
+ await data.reply("⚠️ Non esiste alcun canale vocale con il nome specificato.")
+ return
+ elif len(matching_channels) > 1:
+ await data.reply("⚠️ Esiste più di un canale vocale con il nome specificato.")
+ return
+ channel = matching_channels[0]
+ else:
+ author: discord.Member = message.author
+ voice: typing.Optional[discord.VoiceState] = author.voice
+ if voice is None:
+ await data.reply("⚠️ Non sei connesso a nessun canale vocale!")
+ return
+ channel = voice.channel
+ await bot.vc_connect_or_move(channel)
+ await data.reply(f"✅ Mi sono connesso in [c]#{channel.name}[/c].")
+ else:
+ channel_name: str = args[0].lstrip("#")
+ await self.interface.net_request(Request(SummonNH.message_type, {"channel_name": channel_name}), "discord")
+ await data.reply(f"✅ Mi sono connesso in [c]#{channel_name}[/c].")
diff --git a/royalnet/commands/royalgames/videochannel.py b/royalnet/commands/royalgames/videochannel.py
new file mode 100644
index 00000000..3d153675
--- /dev/null
+++ b/royalnet/commands/royalgames/videochannel.py
@@ -0,0 +1,51 @@
+import typing
+import discord
+from ..command import Command
+from ..commandargs import CommandArgs
+from ..commanddata import CommandData
+from ...error import *
+
+
+class VideochannelCommand(Command):
+ name: str = "videochannel"
+
+ description: str = "Converti il canale vocale in un canale video."
+
+ syntax = "[channelname]"
+
+ async def run(self, args: CommandArgs, data: CommandData) -> None:
+ if self.interface.name == "discord":
+ bot: discord.Client = self.interface.bot
+ message: discord.Message = data.message
+ channel_name: str = args.optional(0)
+ if channel_name:
+ guild: typing.Optional[discord.Guild] = message.guild
+ if guild is not None:
+ channels: typing.List[discord.abc.GuildChannel] = guild.channels
+ else:
+ channels = bot.get_all_channels()
+ matching_channels: typing.List[discord.VoiceChannel] = []
+ for channel in channels:
+ if isinstance(channel, discord.VoiceChannel):
+ if channel.name == channel_name:
+ matching_channels.append(channel)
+ if len(matching_channels) == 0:
+ await data.reply("⚠️ Non esiste alcun canale vocale con il nome specificato.")
+ return
+ elif len(matching_channels) > 1:
+ await data.reply("⚠️ Esiste più di un canale vocale con il nome specificato.")
+ return
+ channel = matching_channels[0]
+ else:
+ author: discord.Member = message.author
+ voice: typing.Optional[discord.VoiceState] = author.voice
+ if voice is None:
+ await data.reply("⚠️ Non sei connesso a nessun canale vocale!")
+ return
+ channel = voice.channel
+ if author.is_on_mobile():
+ await data.reply(f"📹 Per entrare in modalità video, clicca qui: \n[b]Attenzione: la modalità video non funziona su Discord per Android e iOS![/b]")
+ return
+ await data.reply(f"📹 Per entrare in modalità video, clicca qui: ")
+ else:
+ raise UnsupportedError(f"This command is not supported on {self.interface.name.capitalize()}.")
diff --git a/royalnet/commands/smecds.py b/royalnet/commands/smecds.py
deleted file mode 100644
index 1c2da18f..00000000
--- a/royalnet/commands/smecds.py
+++ /dev/null
@@ -1,63 +0,0 @@
-import random
-from ..utils import Command, Call, safeformat
-
-
-DS_LIST = ["della secca", "del seccatore", "del secchiello", "del secchio", "del secchione", "del secondino",
- "del sedano", "del sedativo", "della sedia", "del sedicente", "del sedile", "della sega", "del segale",
- "della segatura", "della seggiola", "del seggiolino", "della seggiovia", "della segheria", "del seghetto",
- "del segnalibro", "del segnaposto", "del segno", "del segretario", "della segreteria", "del seguace",
- "del segugio", "della selce", "della sella", "della selz", "della selva", "della selvaggina", "del semaforo",
- "del seme", "del semifreddo", "del seminario", "della seminarista", "della semola", "del semolino",
- "del semplicione", "della senape", "del senatore", "del seno", "del sensore", "della sentenza",
- "della sentinella", "del sentore", "della seppia", "del sequestratore", "della serenata", "del sergente",
- "del sermone", "della serpe", "del serpente", "della serpentina", "della serra", "del serraglio",
- "del serramanico", "della serranda", "della serratura", "del servitore", "della servitù", "del servizievole",
- "del servo", "del set", "della seta", "della setola", "del sidecar", "del siderurgico", "del sidro",
- "della siepe", "del sifone", "della sigaretta", "del sigaro", "del sigillo", "della signora",
- "della signorina", "del silenziatore", "della silhouette", "del silicio", "del silicone", "del siluro",
- "della sinagoga", "della sindacalista", "del sindacato", "del sindaco", "della sindrome", "della sinfonia",
- "del sipario", "del sire", "della sirena", "della siringa", "del sismografo", "del sobborgo",
- "del sobillatore", "del sobrio", "del soccorritore", "del socio", "del sociologo", "della soda", "del sofà",
- "della soffitta", "del software", "dello sogghignare", "del soggiorno", "della sogliola", "del sognatore",
- "della soia", "del solaio", "del solco", "del soldato", "del soldo", "del sole", "della soletta",
- "della solista", "del solitario", "del sollazzare", "del sollazzo", "del sollecito", "del solleone",
- "del solletico", "del sollevare", "del sollievo", "del solstizio", "del solubile", "del solvente",
- "della soluzione", "del somaro", "del sombrero", "del sommergibile", "del sommo", "della sommossa",
- "del sommozzatore", "del sonar", "della sonda", "del sondaggio", "del sondare", "del sonnacchioso",
- "del sonnambulo", "del sonnellino", "del sonnifero", "del sonno", "della sonnolenza", "del sontuoso",
- "del soppalco", "del soprabito", "del sopracciglio", "del sopraffare", "del sopraffino", "del sopraluogo",
- "del sopramobile", "del soprannome", "del soprano", "del soprappensiero", "del soprassalto",
- "del soprassedere", "del sopravvento", "del sopravvivere", "del soqquadro", "del sorbetto", "del sordido",
- "della sordina", "del sordo", "della sorella", "della sorgente", "del sornione", "del sorpasso",
- "della sorpresa", "del sorreggere", "del sorridere", "della sorsata", "del sorteggio", "del sortilegio",
- "del sorvegliante", "del sorvolare", "del sosia", "del sospettoso", "del sospirare", "della sosta",
- "della sostanza", "del sostegno", "del sostenitore", "del sostituto", "del sottaceto", "della sottana",
- "del sotterfugio", "del sotterraneo", "del sottile", "del sottilizzare", "del sottintendere",
- "del sottobanco", "del sottobosco", "del sottomarino", "del sottopassaggio", "del sottoposto",
- "del sottoscala", "della sottoscrizione", "del sottostare", "del sottosuolo", "del sottotetto",
- "del sottotitolo", "del sottovalutare", "del sottovaso", "della sottoveste", "del sottovuoto",
- "del sottufficiale", "della soubrette", "del souvenir", "del soverchiare", "del sovrano", "del sovrapprezzo",
- "della sovvenzione", "del sovversivo", "del sozzo", "dello suadente", "del sub", "del subalterno",
- "del subbuglio", "del subdolo", "del sublime", "del suburbano", "del successore", "del succo",
- "della succube", "del succulento", "della succursale", "del sudario", "della sudditanza", "del suddito",
- "del sudicio", "del suffisso", "del suffragio", "del suffumigio", "del suggeritore", "del sughero",
- "del sugo", "del suino", "della suite", "del sulfureo", "del sultano", "di Steffo", "di Spaggia",
- "di Sabrina", "del sas", "del ses", "del sis", "del sos", "del sus", "della supremazia", "del Santissimo",
- "della scatola", "del supercalifragilistichespiralidoso", "del sale", "del salame", "di (Town of) Salem",
- "di Stronghold", "di SOMA", "dei Saints", "di S.T.A.L.K.E.R.", "di Sanctum", "dei Sims", "di Sid",
- "delle Skullgirls", "di Sonic", "di Spiral (Knights)", "di Spore", "di Starbound", "di SimCity", "di Sensei",
- "di Ssssssssssssss... Boom! E' esploso il dizionario", "della scala", "di Sakura", "di Suzie", "di Shinji",
- "del senpai", "del support", "di Superman", "di Sekiro", "dello Slime God", "del salassato", "della salsa"]
-SMECDS = "🤔 Secondo me, è colpa {ds}."
-
-
-class SmecdsCommand(Command):
-
- command_name = "smecds"
- command_description = "Secondo me, è colpa dello stagista..."
- command_syntax = ""
-
- @classmethod
- async def common(cls, call: Call):
- ds = random.sample(DS_LIST, 1)[0]
- return await call.reply(safeformat(SMECDS, ds=ds))
diff --git a/royalnet/commands/summon.py b/royalnet/commands/summon.py
deleted file mode 100644
index f2cc2493..00000000
--- a/royalnet/commands/summon.py
+++ /dev/null
@@ -1,68 +0,0 @@
-import typing
-import discord
-from ..utils import Command, Call, NetworkHandler
-from ..network import Request, ResponseSuccess
-from ..error import NoneFoundError
-if typing.TYPE_CHECKING:
- from ..bots import DiscordBot
-
-
-class SummonNH(NetworkHandler):
- message_type = "music_summon"
-
- @classmethod
- async def discord(cls, bot: "DiscordBot", data: dict):
- """Handle a summon Royalnet request. That is, join a voice channel, or move to a different one if that is not possible."""
- channel = bot.client.find_channel_by_name(data["channel_name"])
- if not isinstance(channel, discord.VoiceChannel):
- raise NoneFoundError("Channel is not a voice channel")
- bot.loop.create_task(bot.client.vc_connect_or_move(channel))
- return ResponseSuccess()
-
-
-class SummonCommand(Command):
-
- command_name = "summon"
- command_description = "Evoca il bot in un canale vocale."
- command_syntax = "[channelname]"
-
- network_handlers = [SummonNH]
-
- @classmethod
- async def common(cls, call: Call):
- channel_name: str = call.args[0].lstrip("#")
- await call.net_request(Request("music_summon", {"channel_name": channel_name}), "discord")
- await call.reply(f"✅ Mi sono connesso in [c]#{channel_name}[/c].")
-
- @classmethod
- async def discord(cls, call: Call):
- bot = call.interface_obj.client
- message: discord.Message = call.kwargs["message"]
- channel_name: str = call.args.optional(0)
- if channel_name:
- guild: typing.Optional[discord.Guild] = message.guild
- if guild is not None:
- channels: typing.List[discord.abc.GuildChannel] = guild.channels
- else:
- channels = bot.get_all_channels()
- matching_channels: typing.List[discord.VoiceChannel] = []
- for channel in channels:
- if isinstance(channel, discord.VoiceChannel):
- if channel.name == channel_name:
- matching_channels.append(channel)
- if len(matching_channels) == 0:
- await call.reply("⚠️ Non esiste alcun canale vocale con il nome specificato.")
- return
- elif len(matching_channels) > 1:
- await call.reply("⚠️ Esiste più di un canale vocale con il nome specificato.")
- return
- channel = matching_channels[0]
- else:
- author: discord.Member = message.author
- voice: typing.Optional[discord.VoiceState] = author.voice
- if voice is None:
- await call.reply("⚠️ Non sei connesso a nessun canale vocale!")
- return
- channel = voice.channel
- await bot.vc_connect_or_move(channel)
- await call.reply(f"✅ Mi sono connesso in [c]#{channel.name}[/c].")
diff --git a/royalnet/commands/videochannel.py b/royalnet/commands/videochannel.py
deleted file mode 100644
index cf869eee..00000000
--- a/royalnet/commands/videochannel.py
+++ /dev/null
@@ -1,45 +0,0 @@
-import discord
-import typing
-from ..utils import Command, Call
-
-
-class VideochannelCommand(Command):
-
- command_name = "videochannel"
- command_description = "Converti il canale vocale in un canale video."
- command_syntax = "[channelname]"
-
- @classmethod
- async def discord(cls, call: Call):
- bot = call.interface_obj.client
- message: discord.Message = call.kwargs["message"]
- channel_name: str = call.args.optional(0)
- if channel_name:
- guild: typing.Optional[discord.Guild] = message.guild
- if guild is not None:
- channels: typing.List[discord.abc.GuildChannel] = guild.channels
- else:
- channels = bot.get_all_channels()
- matching_channels: typing.List[discord.VoiceChannel] = []
- for channel in channels:
- if isinstance(channel, discord.VoiceChannel):
- if channel.name == channel_name:
- matching_channels.append(channel)
- if len(matching_channels) == 0:
- await call.reply("⚠️ Non esiste alcun canale vocale con il nome specificato.")
- return
- elif len(matching_channels) > 1:
- await call.reply("⚠️ Esiste più di un canale vocale con il nome specificato.")
- return
- channel = matching_channels[0]
- else:
- author: discord.Member = message.author
- voice: typing.Optional[discord.VoiceState] = author.voice
- if voice is None:
- await call.reply("⚠️ Non sei connesso a nessun canale vocale!")
- return
- channel = voice.channel
- if author.is_on_mobile():
- await call.reply(f"📹 Per entrare in modalità video, clicca qui: \n[b]Attenzione: la modalità video non funziona su Discord per Android e iOS![/b]")
- return
- await call.reply(f"📹 Per entrare in modalità video, clicca qui: ")
diff --git a/royalnet/database/tables/__init__.py b/royalnet/database/tables/__init__.py
index 102e9868..f316c01b 100644
--- a/royalnet/database/tables/__init__.py
+++ b/royalnet/database/tables/__init__.py
@@ -11,6 +11,7 @@ from .wikirevisions import WikiRevision
from .medals import Medal
from .medalawards import MedalAward
from .bios import Bio
+from .reminders import Reminder
__all__ = ["Royal", "Telegram", "Diario", "Alias", "ActiveKvGroup", "Keyvalue", "Keygroup", "Discord", "WikiPage",
- "WikiRevision", "Medal", "MedalAward", "Bio"]
+ "WikiRevision", "Medal", "MedalAward", "Bio", "Reminder"]
diff --git a/royalnet/database/tables/reminders.py b/royalnet/database/tables/reminders.py
new file mode 100644
index 00000000..95f6d331
--- /dev/null
+++ b/royalnet/database/tables/reminders.py
@@ -0,0 +1,48 @@
+from sqlalchemy import Column, \
+ Integer, \
+ String, \
+ LargeBinary, \
+ DateTime, \
+ ForeignKey
+from sqlalchemy.orm import relationship
+from sqlalchemy.ext.declarative import declared_attr
+# noinspection PyUnresolvedReferences
+from .royals import Royal
+
+
+class Reminder:
+ __tablename__ = "reminder"
+
+ @declared_attr
+ def reminder_id(self):
+ return Column(Integer, primary_key=True)
+
+ @declared_attr
+ def creator_id(self):
+ return Column(Integer, ForeignKey("royals.uid"))
+
+ @declared_attr
+ def creator(self):
+ return relationship("Royal", backref="reminders_created")
+
+ @declared_attr
+ def interface_name(self):
+ return Column(String)
+
+ @declared_attr
+ def interface_data(self):
+ return Column(LargeBinary)
+
+ @declared_attr
+ def datetime(self):
+ return Column(DateTime)
+
+ @declared_attr
+ def message(self):
+ return Column(String)
+
+ def __repr__(self):
+ return f""
+
+ def __str__(self):
+ return self.message
diff --git a/royalnet/royalgames.py b/royalnet/royalgames.py
index 7882a61e..42dc748d 100644
--- a/royalnet/royalgames.py
+++ b/royalnet/royalgames.py
@@ -4,10 +4,7 @@ import os
import asyncio
import logging
from royalnet.bots import DiscordBot, DiscordConfig, TelegramBot, TelegramConfig
-from royalnet.commands import *
-# noinspection PyUnresolvedReferences
-from royalnet.commands.debug_create import DebugCreateCommand
-from royalnet.commands.error_handler import ErrorHandlerCommand
+from royalnet.commands.royalgames import *
from royalnet.network import RoyalnetServer, RoyalnetConfig
from royalnet.database import DatabaseConfig
from royalnet.database.tables import Royal, Telegram, Discord
@@ -19,15 +16,21 @@ stream_handler = logging.StreamHandler()
stream_handler.formatter = logging.Formatter("{asctime}\t{name}\t{levelname}\t{message}", style="{")
log.addHandler(stream_handler)
-commands = [PingCommand, ShipCommand, SmecdsCommand, ColorCommand, CiaoruoziCommand, SyncCommand,
- DiarioCommand, RageCommand, ReminderCommand, KvactiveCommand, KvCommand,
- KvrollCommand, SummonCommand, PlayCommand, SkipCommand, PlaymodeCommand,
- VideochannelCommand, CvCommand, PauseCommand, QueueCommand, RoyalnetprofileCommand, VideoinfoCommand,
- IdCommand, DlmusicCommand]
+commands = [PingCommand,
+ ColorCommand,
+ CiaoruoziCommand,
+ CvCommand,
+ DiarioCommand,
+ Mp3Command,
+ SummonCommand,
+ PauseCommand,
+ PlayCommand,
+ PlaymodeCommand,
+ QueueCommand,
+ ReminderCommand]
# noinspection PyUnreachableCode
if __debug__:
- commands = [DebugCreateCommand, DateparserCommand, AuthorCommand, *commands]
log.setLevel(logging.DEBUG)
else:
log.setLevel(logging.INFO)
@@ -42,15 +45,11 @@ print("Starting bots...")
ds_bot = DiscordBot(discord_config=DiscordConfig(os.environ["DS_AK"]),
royalnet_config=RoyalnetConfig(f"ws://{address}:{port}", os.environ["MASTER_KEY"]),
database_config=DatabaseConfig(os.environ["DB_PATH"], Royal, Discord, "discord_id"),
- commands=commands,
- error_command=ErrorHandlerCommand,
- missing_command=MissingCommand)
+ commands=commands)
tg_bot = TelegramBot(telegram_config=TelegramConfig(os.environ["TG_AK"]),
royalnet_config=RoyalnetConfig(f"ws://{address}:{port}", os.environ["MASTER_KEY"]),
database_config=DatabaseConfig(os.environ["DB_PATH"], Royal, Telegram, "tg_id"),
- commands=commands,
- error_command=ErrorHandlerCommand,
- missing_command=MissingCommand)
+ commands=commands)
loop.create_task(tg_bot.run())
loop.create_task(ds_bot.run())
diff --git a/royalnet/shareradio.py b/royalnet/shareradio.py
deleted file mode 100644
index 02335ed6..00000000
--- a/royalnet/shareradio.py
+++ /dev/null
@@ -1,48 +0,0 @@
-"""The production Royalnet, active at @royalgamesbot on Telegram and Royalbot on Discord."""
-
-import os
-import asyncio
-import logging
-from royalnet.bots import DiscordBot, DiscordConfig, TelegramBot, TelegramConfig
-from royalnet.commands import *
-# noinspection PyUnresolvedReferences
-from royalnet.commands.debug_create import DebugCreateCommand
-from royalnet.commands.error_handler import ErrorHandlerCommand
-from royalnet.network import RoyalnetServer, RoyalnetConfig
-from royalnet.database import DatabaseConfig
-from royalnet.database.tables import Royal, Telegram, Discord
-
-loop = asyncio.get_event_loop()
-
-log = logging.root
-stream_handler = logging.StreamHandler()
-stream_handler.formatter = logging.Formatter("{asctime}\t{name}\t{levelname}\t{message}", style="{")
-log.addHandler(stream_handler)
-log.setLevel(logging.INFO)
-
-commands = [PingCommand, SummonCommand, PlayCommand, SkipCommand, PlaymodeCommand, PauseCommand, QueueCommand]
-
-address, port = "127.0.0.1", 1234
-
-print("Starting master...")
-master = RoyalnetServer(address, port, os.environ["MASTER_KEY"])
-loop.run_until_complete(master.start())
-
-print("Starting bots...")
-ds_bot = DiscordBot(discord_config=DiscordConfig(os.environ["DS_AK"]),
- royalnet_config=RoyalnetConfig(f"ws://{address}:{port}", os.environ["MASTER_KEY"]),
- database_config=None,
- commands=commands,
- error_command=ErrorHandlerCommand,
- missing_command=MissingCommand)
-tg_bot = TelegramBot(telegram_config=TelegramConfig(os.environ["TG_AK"]),
- royalnet_config=RoyalnetConfig(f"ws://{address}:{port}", os.environ["MASTER_KEY"]),
- database_config=None,
- commands=commands,
- error_command=ErrorHandlerCommand,
- missing_command=MissingCommand)
-loop.create_task(tg_bot.run())
-loop.create_task(ds_bot.run())
-
-print("Running loop...")
-loop.run_forever()
diff --git a/royalnet/utils/__init__.py b/royalnet/utils/__init__.py
index 353b5fe7..7455eacc 100644
--- a/royalnet/utils/__init__.py
+++ b/royalnet/utils/__init__.py
@@ -2,15 +2,12 @@
from .asyncify import asyncify
from .escaping import telegram_escape, discord_escape
-from .call import Call
-from .command import Command
-from .commandargs import CommandArgs
from .safeformat import safeformat
from .classdictjanitor import cdj
from .sleepuntil import sleep_until
from .networkhandler import NetworkHandler
from .formatters import andformat, plusformat, fileformat, ytdldateformat, numberemojiformat
-__all__ = ["asyncify", "Call", "Command", "safeformat", "cdj", "sleep_until", "plusformat", "CommandArgs",
+__all__ = ["asyncify", "safeformat", "cdj", "sleep_until", "plusformat",
"NetworkHandler", "andformat", "plusformat", "fileformat", "ytdldateformat", "numberemojiformat",
"telegram_escape", "discord_escape"]
diff --git a/royalnet/utils/call.py b/royalnet/utils/call.py
deleted file mode 100644
index b9ccf58c..00000000
--- a/royalnet/utils/call.py
+++ /dev/null
@@ -1,100 +0,0 @@
-import typing
-import asyncio
-from .command import Command
-from .commandargs import CommandArgs
-if typing.TYPE_CHECKING:
- from ..database import Alchemy
-
-
-class Call:
- """A command call. An abstract class, sub-bots should create a new call class from this.
-
- Attributes:
- interface_name: The name of the interface that is calling the command. For example, ``telegram``, or ``discord``.
- interface_obj: The main object of the interface that is calling the command. For example, the :py:class:`royalnet.bots.TelegramBot` object.
- interface_prefix: The command prefix used by the interface. For example, ``/``, or ``!``.
- alchemy: The :py:class:`royalnet.database.Alchemy` object associated to this interface. May be None if the interface is not connected to any database."""
-
- # These parameters / methods should be overridden
- interface_name = NotImplemented
- interface_obj = NotImplemented
- interface_prefix = NotImplemented
- alchemy: "Alchemy" = NotImplemented
-
- async def reply(self, text: str) -> None:
- """Send a text message to the channel where the call was made.
-
- Parameters:
- text: The text to be sent, possibly formatted in the weird undescribed markup that I'm using."""
- raise NotImplementedError()
-
- async def net_request(self, message, destination: str) -> dict:
- """Send data through a :py:class:`royalnet.network.RoyalnetLink` and wait for a :py:class:`royalnet.network.Reply`.
-
- Parameters:
- message: The data to be sent. Must be :py:mod:`pickle`-able.
- destination: The destination of the request, either in UUID format or node name."""
- raise NotImplementedError()
-
- async def get_author(self, error_if_none=False):
- """Try to find the universal identifier of the user that sent the message.
- That probably means, the database row identifying the user.
-
- Parameters:
- error_if_none: Raise a :py:exc:`royalnet.error.UnregisteredError` if this is True and the call has no author.
-
- Raises:
- :py:exc:`royalnet.error.UnregisteredError` if ``error_if_none`` is set to True and no author is found."""
- raise NotImplementedError()
-
- # These parameters / methods should be left alone
- def __init__(self,
- channel,
- command: typing.Type[Command],
- command_args: typing.List[str] = None,
- loop: asyncio.AbstractEventLoop = None,
- **kwargs):
- """Create the call.
-
- Parameters:
- channel: The channel object this call was sent in.
- command: The command to be called.
- command_args: The arguments to be passed to the command
- kwargs: Additional optional keyword arguments that may be passed to the command, possibly specific to the bot.
- """
- if command_args is None:
- command_args = []
- if loop is None:
- self.loop = asyncio.get_event_loop()
- else:
- self.loop = loop
- self.channel = channel
- self.command = command
- self.args = CommandArgs(command_args)
- self.kwargs = kwargs
- self.session = None
-
- async def _session_init(self):
- """If the command requires database access, create a :py:class:`royalnet.database.Alchemy` session for this call, otherwise, do nothing."""
- if not self.command.require_alchemy_tables:
- return
- self.session = await self.loop.run_in_executor(None, self.alchemy.Session)
-
- async def session_end(self):
- """Close the previously created :py:class:`royalnet.database.Alchemy` session for this call (if it was created)."""
- if not self.session:
- return
- self.session.close()
-
- async def run(self):
- """Execute the called command, and return the command result."""
- await self._session_init()
- try:
- coroutine = getattr(self.command, self.interface_name)
- except AttributeError:
- coroutine = self.command.common
- try:
- result = await coroutine(self)
- finally:
- await self.session_end()
- return result
diff --git a/royalnet/utils/command.py b/royalnet/utils/command.py
deleted file mode 100644
index 7c6a9e7d..00000000
--- a/royalnet/utils/command.py
+++ /dev/null
@@ -1,35 +0,0 @@
-import typing
-from ..error import UnsupportedError
-if typing.TYPE_CHECKING:
- from .call import Call
- from ..utils import NetworkHandler
-
-
-class Command:
- """The base class from which all commands should inherit."""
-
- command_name: typing.Optional[str] = NotImplemented
- """The name of the command. To have ``/example`` on Telegram, the name should be ``example``. If the name is None or empty, the command won't be registered."""
-
- command_description: str = NotImplemented
- """A small description of the command, to be displayed when the command is being autocompleted."""
-
- command_syntax: str = NotImplemented
- """The syntax of the command, to be displayed when a :py:exc:`royalnet.error.InvalidInputError` is raised, in the format ``(required_arg) [optional_arg]``."""
-
- require_alchemy_tables: typing.Set = set()
- """A set of :py:class:`royalnet.database` tables, that must exist for this command to work."""
-
- network_handlers: typing.List[typing.Type["NetworkHandler"]] = []
- """A set of :py:class:`royalnet.utils.NetworkHandler`s that must exist for this command to work."""
-
- @classmethod
- async def common(cls, call: "Call"):
- raise UnsupportedError()
-
- @classmethod
- def network_handler_dict(cls):
- d = {}
- for network_handler in cls.network_handlers:
- d[network_handler.message_type] = network_handler
- return d