diff --git a/requirements.txt b/requirements.txt index 75e6bd32..47259f29 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ python-telegram-bot>=11.1.0 websockets>=7.0 pytest>=4.3.1 psycopg2-binary>=2.8 +aiohttp>=3.5.4 diff --git a/royalnet/bots/telegram.py b/royalnet/bots/telegram.py index 9595ab0a..10036cc5 100644 --- a/royalnet/bots/telegram.py +++ b/royalnet/bots/telegram.py @@ -82,7 +82,7 @@ class TelegramBot: query = query.filter(self.identity_column == user.id) return await asyncify(query.one_or_none) - self.Call = TelegramCall + self.TelegramCall = TelegramCall async def run(self): self.should_run = True @@ -121,15 +121,14 @@ class TelegramBot: command = self.missing_command # Call the command try: - return await self.Call(message.chat, command, parameters, - update=update).run() + return await self.TelegramCall(message.chat, command, parameters, log, + update=update).run() except Exception as exc: try: - return await self.Call(message.chat, self.error_command, parameters, - update=update, - exception_info=sys.exc_info(), - previous_command=command, - log=log).run() + return await self.TelegramCall(message.chat, self.error_command, parameters, log, + update=update, + exception_info=sys.exc_info(), + previous_command=command).run() except Exception as exc2: log.error(f"Exception in error handler command: {exc2}") diff --git a/royalnet/commands/diario.py b/royalnet/commands/diario.py index 9f589595..d6d41d6b 100644 --- a/royalnet/commands/diario.py +++ b/royalnet/commands/diario.py @@ -1,10 +1,16 @@ import re import datetime -from ..utils import Command, CommandArgs, Call, InvalidInputError +import telegram +import typing +import os +import aiohttp +from urllib.parse import quote +from ..utils import Command, CommandArgs, Call, 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" @@ -65,3 +71,136 @@ class DiarioCommand(Command): call.session.add(diario) await asyncify(call.session.commit) await call.reply(f"✅ {str(diario)}") + + async def telegram(self, 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() + 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: + # 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", params={ + "image": quote(photo_file.file_path), + "type": "URL", + "title": "Diario image", + "description": reply.caption if reply.caption is not None else "" + }, 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.") + media_url = response["data"]["link"] + 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: str = 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) + # 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 + # Check if there's an image associated with the reply + photosizes: typing.Optional[typing.List[telegram.PhotoSize]] = message.photo + if photosizes: + # 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", params={ + "image": quote(photo_file.file_path), + "type": "URL", + "title": "Diario image", + "description": message.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.") + media_url = response["data"]["link"] + else: + media_url = 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=None, + spoiler=spoiler) + call.session.add(diario) + await asyncify(call.session.commit) + await call.reply(f"✅ {str(diario)}") diff --git a/royalnet/commands/error_handler.py b/royalnet/commands/error_handler.py index 787d8ec9..7b93b601 100644 --- a/royalnet/commands/error_handler.py +++ b/royalnet/commands/error_handler.py @@ -2,6 +2,7 @@ import traceback from logging import Logger from ..utils import Command, CommandArgs, Call, InvalidInputError, UnsupportedError + class ErrorHandlerCommand(Command): command_name = "error_handler" @@ -22,6 +23,5 @@ class ErrorHandlerCommand(Command): await call.reply(f"⚠️ Sintassi non valida.\nSintassi corretta:[c]/{command.command_name} {command.command_syntax}[/c]") return await call.reply(f"❌ Eccezione non gestita durante l'esecuzione del comando:\n[b]{e_type.__name__}[/b]\n{e_value}") - log: Logger = call.kwargs["log"] formatted_tb: str = '\n'.join(traceback.format_tb(e_tb)) - log.error(f"Unhandled exception - {e_type.__name__}: {e_value}\n{formatted_tb}") + call.logger.error(f"Unhandled exception - {e_type.__name__}: {e_value}\n{formatted_tb}") diff --git a/royalnet/database/TODO.md b/royalnet/database/TODO.md deleted file mode 100644 index ae86b680..00000000 --- a/royalnet/database/TODO.md +++ /dev/null @@ -1,3 +0,0 @@ -# TODO - -L'obiettivo per il sottopackage `database` è quello di creare una classe `Alchemy`. \ No newline at end of file diff --git a/royalnet/utils/__init__.py b/royalnet/utils/__init__.py index 2c7c9e93..c75aaa79 100644 --- a/royalnet/utils/__init__.py +++ b/royalnet/utils/__init__.py @@ -1,8 +1,8 @@ from .asyncify import asyncify from .call import Call -from .command import Command, CommandArgs, InvalidInputError, UnsupportedError +from .command import Command, CommandArgs, InvalidInputError, UnsupportedError, InvalidConfigError, ExternalError from .safeformat import safeformat from .classdictjanitor import cdj __all__ = ["asyncify", "Call", "Command", "safeformat", "InvalidInputError", "UnsupportedError", "CommandArgs", - "cdj"] + "cdj", "InvalidConfigError", "ExternalError"] diff --git a/royalnet/utils/call.py b/royalnet/utils/call.py index c7cfca70..0b57781a 100644 --- a/royalnet/utils/call.py +++ b/royalnet/utils/call.py @@ -1,5 +1,6 @@ import typing import asyncio +import logging from ..network.messages import Message from .command import Command, CommandArgs if typing.TYPE_CHECKING: @@ -32,12 +33,13 @@ class Call: raise NotImplementedError() # These parameters / methods should be left alone - def __init__(self, channel, command: typing.Type[Command], command_args: list, **kwargs): + def __init__(self, channel, command: typing.Type[Command], command_args: list, logger: logging.Logger, **kwargs): self.channel = channel self.command = command self.args = CommandArgs(command_args) self.kwargs = kwargs self.session = None + self.logger = logger async def session_init(self): if not self.command.require_alchemy_tables: diff --git a/royalnet/utils/command.py b/royalnet/utils/command.py index 61bcaf31..cdaaa99f 100644 --- a/royalnet/utils/command.py +++ b/royalnet/utils/command.py @@ -5,12 +5,18 @@ if typing.TYPE_CHECKING: class UnsupportedError(Exception): """The command is not supported for the specified source.""" - pass class InvalidInputError(Exception): """The command has received invalid input and cannot complete.""" - pass + + +class InvalidConfigError(Exception): + """The bot has not been configured correctly, therefore the command can not function.""" + + +class ExternalError(Exception): + """Something went wrong in a non-Royalnet component and the command cannot be executed fully.""" class CommandArgs(list):