From 372e35a9944b2be0c6f9af67699657bc30cbbff7 Mon Sep 17 00:00:00 2001 From: Stefano Pigozzi Date: Sun, 25 Aug 2019 17:32:28 +0200 Subject: [PATCH] Completato #83 * Start moving some stuff around * Maybe this was a bad idea afterall * This might actually work * Fix leftover bugs * Port a few commands to the new format * MUCH STUFF VERY DOGE --- royalnet/__init__.py | 3 +- royalnet/audio/__init__.py | 4 +- royalnet/audio/{ytdlvorbis.py => ytdlmp3.py} | 22 +- royalnet/bots/discord.py | 103 ++++----- royalnet/bots/generic.py | 113 +++++----- royalnet/bots/telegram.py | 95 ++++---- royalnet/commands/__init__.py | 43 +--- royalnet/commands/ciaoruozi.py | 22 -- royalnet/commands/color.py | 14 -- royalnet/commands/command.py | 27 +++ royalnet/{utils => commands}/commandargs.py | 2 +- royalnet/commands/commanddata.py | 18 ++ royalnet/commands/commandinterface.py | 33 +++ royalnet/commands/diario.py | 209 ----------------- royalnet/commands/dlmusic.py | 34 --- royalnet/commands/ping.py | 21 -- royalnet/commands/play.py | 84 ------- royalnet/commands/rage.py | 20 -- royalnet/commands/royalgames/__init__.py | 30 +++ royalnet/commands/royalgames/ciaoruozi.py | 24 ++ royalnet/commands/royalgames/color.py | 15 ++ royalnet/commands/{ => royalgames}/cv.py | 43 ++-- royalnet/commands/royalgames/diario.py | 212 ++++++++++++++++++ royalnet/commands/royalgames/mp3.py | 40 ++++ .../commands/{ => royalgames/old}/author.py | 0 .../{ => royalgames/old}/dateparser.py | 0 .../{ => royalgames/old}/debug_create.py | 0 .../{ => royalgames/old}/error_handler.py | 0 royalnet/commands/{ => royalgames/old}/id.py | 0 royalnet/commands/{ => royalgames/old}/kv.py | 0 .../commands/{ => royalgames/old}/kvactive.py | 0 .../commands/{ => royalgames/old}/kvroll.py | 0 .../commands/{ => royalgames/old}/missing.py | 0 .../commands/{ => royalgames/old}/null.py | 0 .../commands/{ => royalgames/old}/reminder.py | 0 .../{ => royalgames/old}/royalnetprofile.py | 0 .../commands/{ => royalgames/old}/sync.py | 0 .../{ => royalgames/old}/videoinfo.py | 0 royalnet/commands/{ => royalgames}/pause.py | 38 ++-- royalnet/commands/royalgames/ping.py | 13 ++ royalnet/commands/royalgames/play.py | 75 +++++++ .../commands/{ => royalgames}/playmode.py | 38 ++-- royalnet/commands/{ => royalgames}/queue.py | 42 ++-- royalnet/commands/royalgames/rage.py | 20 ++ royalnet/commands/royalgames/reminder.py | 79 +++++++ royalnet/commands/{ => royalgames}/ship.py | 27 +-- royalnet/commands/{ => royalgames}/skip.py | 34 +-- royalnet/commands/royalgames/smecds.py | 79 +++++++ royalnet/commands/royalgames/summon.py | 74 ++++++ royalnet/commands/royalgames/videochannel.py | 51 +++++ royalnet/commands/smecds.py | 63 ------ royalnet/commands/summon.py | 68 ------ royalnet/commands/videochannel.py | 45 ---- royalnet/database/tables/__init__.py | 3 +- royalnet/database/tables/reminders.py | 48 ++++ royalnet/royalgames.py | 31 ++- royalnet/shareradio.py | 48 ---- royalnet/utils/__init__.py | 5 +- royalnet/utils/call.py | 100 --------- royalnet/utils/command.py | 35 --- 60 files changed, 1148 insertions(+), 1099 deletions(-) rename royalnet/audio/{ytdlvorbis.py => ytdlmp3.py} (79%) delete mode 100644 royalnet/commands/ciaoruozi.py delete mode 100644 royalnet/commands/color.py create mode 100644 royalnet/commands/command.py rename royalnet/{utils => commands}/commandargs.py (96%) create mode 100644 royalnet/commands/commanddata.py create mode 100644 royalnet/commands/commandinterface.py delete mode 100644 royalnet/commands/diario.py delete mode 100644 royalnet/commands/dlmusic.py delete mode 100644 royalnet/commands/ping.py delete mode 100644 royalnet/commands/play.py delete mode 100644 royalnet/commands/rage.py create mode 100644 royalnet/commands/royalgames/__init__.py create mode 100644 royalnet/commands/royalgames/ciaoruozi.py create mode 100644 royalnet/commands/royalgames/color.py rename royalnet/commands/{ => royalgames}/cv.py (80%) create mode 100644 royalnet/commands/royalgames/diario.py create mode 100644 royalnet/commands/royalgames/mp3.py rename royalnet/commands/{ => royalgames/old}/author.py (100%) rename royalnet/commands/{ => royalgames/old}/dateparser.py (100%) rename royalnet/commands/{ => royalgames/old}/debug_create.py (100%) rename royalnet/commands/{ => royalgames/old}/error_handler.py (100%) rename royalnet/commands/{ => royalgames/old}/id.py (100%) rename royalnet/commands/{ => royalgames/old}/kv.py (100%) rename royalnet/commands/{ => royalgames/old}/kvactive.py (100%) rename royalnet/commands/{ => royalgames/old}/kvroll.py (100%) rename royalnet/commands/{ => royalgames/old}/missing.py (100%) rename royalnet/commands/{ => royalgames/old}/null.py (100%) rename royalnet/commands/{ => royalgames/old}/reminder.py (100%) rename royalnet/commands/{ => royalgames/old}/royalnetprofile.py (100%) rename royalnet/commands/{ => royalgames/old}/sync.py (100%) rename royalnet/commands/{ => royalgames/old}/videoinfo.py (100%) rename royalnet/commands/{ => royalgames}/pause.py (52%) create mode 100644 royalnet/commands/royalgames/ping.py create mode 100644 royalnet/commands/royalgames/play.py rename royalnet/commands/{ => royalgames}/playmode.py (51%) rename royalnet/commands/{ => royalgames}/queue.py (72%) create mode 100644 royalnet/commands/royalgames/rage.py create mode 100644 royalnet/commands/royalgames/reminder.py rename royalnet/commands/{ => royalgames}/ship.py (66%) rename royalnet/commands/{ => royalgames}/skip.py (52%) create mode 100644 royalnet/commands/royalgames/smecds.py create mode 100644 royalnet/commands/royalgames/summon.py create mode 100644 royalnet/commands/royalgames/videochannel.py delete mode 100644 royalnet/commands/smecds.py delete mode 100644 royalnet/commands/summon.py delete mode 100644 royalnet/commands/videochannel.py create mode 100644 royalnet/database/tables/reminders.py delete mode 100644 royalnet/shareradio.py delete mode 100644 royalnet/utils/call.py delete mode 100644 royalnet/utils/command.py 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