From fc303ee1275f95f46d2cf100ff4f54e6a856372c Mon Sep 17 00:00:00 2001 From: profitroll Date: Wed, 21 Jun 2023 16:39:33 +0200 Subject: [PATCH 01/16] WIP: Complete overhaul --- .gitignore | 24 +- classes/commandset.py | 29 ++ classes/exceptions.py | 5 + classes/poster_client.py | 98 ------- classes/pyroclient.py | 465 ++++++++++++++++++++++++++++++++ classes/pyrocommand.py | 9 + classes/user.py | 29 +- config_example.json | 100 +++++-- locale/en.json | 10 +- locale/uk.json | 10 +- main.py | 39 +++ modules/api_client.py | 298 ++++++-------------- modules/app.py | 14 - modules/cli.py | 78 ------ modules/commands_register.py | 57 ---- modules/logger.py | 67 ----- modules/scheduler.py | 45 ++-- modules/sender.py | 137 +++++----- modules/utils.py | 245 +---------------- plugins/callbacks/nothing.py | 12 +- plugins/callbacks/shutdown.py | 44 ++- plugins/callbacks/submission.py | 197 +++++++------- plugins/commands/general.py | 72 ++--- plugins/commands/mode_submit.py | 31 +-- plugins/commands/photos.py | 463 ++++++++++++++++++------------- plugins/handlers/submission.py | 165 ++++++------ plugins/remove_commands.py | 13 + poster.py | 266 ------------------ requirements-optional.txt | 0 requirements.txt | 25 +- 30 files changed, 1407 insertions(+), 1640 deletions(-) create mode 100644 classes/commandset.py delete mode 100644 classes/poster_client.py create mode 100644 classes/pyroclient.py create mode 100644 classes/pyrocommand.py create mode 100644 main.py delete mode 100644 modules/app.py delete mode 100644 modules/cli.py delete mode 100644 modules/commands_register.py delete mode 100644 modules/logger.py create mode 100644 plugins/remove_commands.py delete mode 100644 poster.py delete mode 100644 requirements-optional.txt diff --git a/.gitignore b/.gitignore index 9ac0792..b4a0707 100644 --- a/.gitignore +++ b/.gitignore @@ -152,21 +152,13 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ -# ---> VisualStudioCode -.vscode/* -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -!.vscode/*.code-snippets +# Custom +config.json +*.session +*.session-journal -# Local History for Visual Studio Code -.history/ +venv +venv_linux +venv_windows -# Built Visual Studio Code Extensions -*.vsix - -# Project -cache -data -logs -config.json \ No newline at end of file +.vscode \ No newline at end of file diff --git a/classes/commandset.py b/classes/commandset.py new file mode 100644 index 0000000..e50620d --- /dev/null +++ b/classes/commandset.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass +from typing import List, Union +from pyrogram.types import ( + BotCommandScopeAllChatAdministrators, + BotCommandScopeAllGroupChats, + BotCommandScopeAllPrivateChats, + BotCommandScopeChat, + BotCommandScopeChatAdministrators, + BotCommandScopeChatMember, + BotCommandScopeDefault, + BotCommand, +) + + +@dataclass +class CommandSet: + """Command stored in PyroClient's 'commands' attribute""" + + commands: List[BotCommand] + scope: Union[ + BotCommandScopeDefault, + BotCommandScopeAllPrivateChats, + BotCommandScopeAllGroupChats, + BotCommandScopeAllChatAdministrators, + BotCommandScopeChat, + BotCommandScopeChatAdministrators, + BotCommandScopeChatMember, + ] = BotCommandScopeDefault + language_code: str = "" diff --git a/classes/exceptions.py b/classes/exceptions.py index 4ca8e58..b1a6d06 100644 --- a/classes/exceptions.py +++ b/classes/exceptions.py @@ -22,6 +22,11 @@ class SubmissionDuplicatesError(Exception): ) +class SubmissionUnsupportedError(Exception): + def __init__(self, file_path: str) -> None: + super().__init__(f"Type of file does not seem to be supported: '{file_path}'") + + class UserCreationError(Exception): def __init__(self, code: int, data: str) -> None: self.code = code diff --git a/classes/poster_client.py b/classes/poster_client.py deleted file mode 100644 index ba0aace..0000000 --- a/classes/poster_client.py +++ /dev/null @@ -1,98 +0,0 @@ -from os import path, remove, sep -from shutil import rmtree -from typing import Tuple, Union -from pyrogram.client import Client -from pyrogram.types import Message -from classes.exceptions import SubmissionDuplicatesError, SubmissionUnavailableError -from modules.api_client import upload_pic -from modules.database import col_submitted -from bson import ObjectId -from modules.logger import logWrite - -from modules.utils import configGet - - -class PosterClient(Client): - def __init__(self, name: str, **kwargs): # type: ignore - super().__init__(name, **kwargs) - self.owner = configGet("owner") - self.admins = configGet("admins") + [configGet("owner")] - - async def submit_photo( - self, id: str - ) -> Tuple[Union[Message, None], Union[str, None]]: - db_entry = col_submitted.find_one({"_id": ObjectId(id)}) - submission = None - - if db_entry is None: - raise SubmissionUnavailableError() - else: - if db_entry["temp"]["uuid"] is not None: - if not path.exists( - path.join( - configGet("data", "locations"), - "submissions", - db_entry["temp"]["uuid"], - db_entry["temp"]["file"], - ) - ): - raise SubmissionUnavailableError() - else: - filepath = path.join( - configGet("data", "locations"), - "submissions", - db_entry["temp"]["uuid"], - db_entry["temp"]["file"], - ) - try: - submission = await self.get_messages( - db_entry["user"], db_entry["telegram"]["msg_id"] - ) - except: - pass - else: - try: - submission = await self.get_messages( - db_entry["user"], db_entry["telegram"]["msg_id"] - ) - filepath = await self.download_media( - submission, file_name=configGet("tmp", "locations") + sep - ) - except: - raise SubmissionUnavailableError() - - response = await upload_pic( - str(filepath), allow_duplicates=configGet("allow_duplicates", "submission") - ) - - if len(response[1]) > 0: - raise SubmissionDuplicatesError(str(filepath), response[1]) - - col_submitted.find_one_and_update( - {"_id": ObjectId(id)}, {"$set": {"done": True}} - ) - - try: - if db_entry["temp"]["uuid"] is not None: - rmtree( - path.join( - configGet("data", "locations"), - "submissions", - db_entry["temp"]["uuid"], - ), - ignore_errors=True, - ) - else: - remove(str(filepath)) - except (FileNotFoundError, NotADirectoryError): - logWrite( - f"Could not delete '{filepath}' on submission accepted", debug=True - ) - - return submission, response[2] - - async def ban_user(self, id: int) -> None: - pass - - async def unban_user(self, id: int) -> None: - pass diff --git a/classes/pyroclient.py b/classes/pyroclient.py new file mode 100644 index 0000000..2ca2662 --- /dev/null +++ b/classes/pyroclient.py @@ -0,0 +1,465 @@ +import contextlib +import logging +from datetime import datetime, timedelta +from io import BytesIO +from os import getpid, makedirs, remove, sep +from pathlib import Path +from shutil import rmtree +from time import time +from traceback import format_exc +from typing import List, Tuple, Union + +import pyrogram +from aiohttp import ClientSession +from bson import ObjectId +from dateutil.relativedelta import relativedelta +from libbot import json_read, json_write +from libbot.i18n import BotLocale +from libbot.i18n.sync import _ +from photosapi_client.errors import UnexpectedStatus +from pyrogram.client import Client +from pyrogram.errors import BadRequest, bad_request_400 +from pyrogram.handlers.message_handler import MessageHandler +from pyrogram.raw.all import layer +from pyrogram.types import ( + BotCommand, + BotCommandScopeAllChatAdministrators, + BotCommandScopeAllGroupChats, + BotCommandScopeAllPrivateChats, + BotCommandScopeChat, + BotCommandScopeChatAdministrators, + BotCommandScopeChatMember, + BotCommandScopeDefault, + Message, +) +from ujson import dumps, loads + +from classes.commandset import CommandSet +from classes.exceptions import ( + SubmissionDuplicatesError, + SubmissionUnavailableError, + SubmissionUnsupportedError, +) +from classes.pyrocommand import PyroCommand +from modules.api_client import ( + BodyPhotoUploadAlbumsAlbumPhotosPost, + File, + client, + photo_upload, +) +from modules.database import col_submitted +from modules.http_client import http_session +from modules.scheduler import scheduler + +logger = logging.getLogger(__name__) + + +class PyroClient(Client): + def __init__(self): + with open("config.json", "r", encoding="utf-8") as f: + self.config: dict = loads(f.read()) + + super().__init__( + name="bot_client", + api_id=self.config["bot"]["api_id"], + api_hash=self.config["bot"]["api_hash"], + bot_token=self.config["bot"]["bot_token"], + plugins=dict(root="plugins", exclude=self.config["disabled_plugins"]), + sleep_threshold=120, + max_concurrent_transmissions=self.config["bot"][ + "max_concurrent_transmissions" + ], + ) + + self.version: float = 0.2 + + self.owner: int = self.config["bot"]["owner"] + self.admins: List[int] = self.config["bot"]["admins"] + [ + self.config["bot"]["owner"] + ] + self.commands: List[PyroCommand] = [] + self.scoped_commands: bool = self.config["bot"]["scoped_commands"] + self.start_time: float = 0 + + self.bot_locale: BotLocale = BotLocale(Path(self.config["locations"]["locale"])) + self.default_locale: str = self.bot_locale.default + self.locales: dict = self.bot_locale.locales + + self._ = self.bot_locale._ + self.in_all_locales = self.bot_locale.in_all_locales + self.in_every_locale = self.bot_locale.in_every_locale + + async def start(self): + await super().start() + + self.start_time = time() + + logger.info( + "Bot is running with Pyrogram v%s (Layer %s) and has started as @%s on PID %s.", + pyrogram.__version__, + layer, + self.me.username, + getpid(), + ) + + try: + if Path(f"{self.config['locations']['cache']}/shutdown_time").exists(): + downtime = relativedelta( + datetime.now(), + datetime.fromtimestamp( + ( + await json_read( + Path( + f"{self.config['locations']['cache']}/shutdown_time" + ) + ) + )["timestamp"] + ), + ) + + if downtime.days >= 1: + startup_message = self._( + "startup_downtime_days", + "message", + ).format(getpid(), downtime.days) + elif downtime.hours >= 1: + startup_message = self._( + "startup_downtime_hours", + "message", + ).format(getpid(), downtime.hours) + else: + startup_message = self._( + "startup_downtime_minutes", + "message", + ).format(getpid(), downtime.minutes) + else: + startup_message = (self._("startup", "message").format(getpid()),) + + await self.send_message( + chat_id=self.config["reports"]["chat_id"], + text=startup_message, + ) + + if self.config["reports"]["update"]: + try: + async with ClientSession( + json_serialize=dumps, + ) as http_session: + check_update = await http_session.get( + "https://git.end-play.xyz/api/v1/repos/profitroll/TelegramPoster/releases?page=1&limit=1" + ) + + response = await check_update.json() + + if len(response) == 0: + raise ValueError("No bot releases on git found.") + + if float(response[0]["tag_name"].replace("v", "")) > self.version: + logger.info( + "New version %s found (current %s)", + response[0]["tag_name"].replace("v", ""), + self.version, + ) + await self.send_message( + self.owner, + self._( + "update_available", + "message", + ).format( + response[0]["tag_name"], + response[0]["html_url"], + response[0]["body"], + ), + ) + else: + logger.info("No updates found, bot is up to date.") + except bad_request_400.PeerIdInvalid: + logger.warning( + "Could not send startup message to bot owner. Perhaps user has not started the bot yet." + ) + except Exception as exp: + logger.exception( + "Update check failed due to %s: %s", exp, format_exc() + ) + + scheduler.add_job( + self.register_commands, + trigger="date", + run_date=datetime.now() + timedelta(seconds=5), + kwargs={"command_sets": await self.collect_commands()}, + ) + + scheduler.start() + except BadRequest: + logger.warning("Unable to send message to report chat.") + + async def stop(self): + makedirs(self.config["locations"]["cache"], exist_ok=True) + await json_write( + {"timestamp": time()}, + Path(f"{self.config['locations']['cache']}/shutdown_time"), + ) + + try: + await self.send_message( + chat_id=self.config["reports"]["chat_id"], + text=f"Bot stopped with PID `{getpid()}`", + ) + except BadRequest: + logger.warning("Unable to send message to report chat.") + await http_session.close() + await super().stop() + logger.warning("Bot stopped with PID %s.", getpid()) + + async def collect_commands(self) -> Union[List[CommandSet], None]: + """Gather list of the bot's commands + + ### Returns: + * `List[CommandSet]`: List of the commands' sets + """ + command_sets = None + + # If config get bot.scoped_commands is true - more complicated + # scopes system will be used instead of simple global commands + if self.scoped_commands: + scopes = {} + command_sets = [] + + # Iterate through all commands in config + for command, contents in self.config["commands"].items(): + # Iterate through all scopes of a command + for scope in contents["scopes"]: + if dumps(scope) not in scopes: + scopes[dumps(scope)] = {"_": []} + + # Add command to the scope's flattened key in scopes dict + scopes[dumps(scope)]["_"].append( + BotCommand(command, _(command, "commands")) + ) + + for locale, string in ( + self.in_every_locale(command, "commands") + ).items(): + if locale not in scopes[dumps(scope)]: + scopes[dumps(scope)][locale] = [] + + scopes[dumps(scope)][locale].append(BotCommand(command, string)) + + # Iterate through all scopes and its commands + for scope, locales in scopes.items(): + # Make flat key a dict again + scope_dict = loads(scope) + + # Replace "owner" in the bot scope with owner's id + if "chat_id" in scope_dict and scope_dict["chat_id"] == "owner": + scope_dict["chat_id"] = self.owner + + # Create object with the same name and args from the dict + try: + scope_obj = globals()[scope_dict["name"]]( + **{ + key: value + for key, value in scope_dict.items() + if key != "name" + } + ) + except NameError: + logger.error( + "Could not register commands of the scope '%s' due to an invalid scope class provided!", + scope_dict["name"], + ) + continue + except TypeError: + logger.error( + "Could not register commands of the scope '%s' due to an invalid class arguments provided!", + scope_dict["name"], + ) + continue + + # Add set of commands to the list of the command sets + for locale, commands in locales.items(): + if locale == "_": + command_sets.append( + CommandSet(commands, scope=scope_obj, language_code="") + ) + continue + command_sets.append( + CommandSet(commands, scope=scope_obj, language_code=locale) + ) + + logger.info("Registering the following command sets: %s", command_sets) + + else: + # This part here looks into the handlers and looks for commands + # in it, if there are any. Then adds them to self.commands + for handler in self.dispatcher.groups[0]: + if isinstance(handler, MessageHandler): + for entry in [handler.filters.base, handler.filters.other]: + if hasattr(entry, "commands"): + for command in entry.commands: + logger.info("I see a command %s in my filters", command) + self.add_command(command) + + return command_sets + + def add_command( + self, + command: str, + ): + """Add command to the bot's internal commands list + + ### Args: + * command (`str`) + """ + self.commands.append( + PyroCommand( + command, + _(command, "commands"), + ) + ) + logger.info( + "Added command '%s' to the bot's internal commands list", + command, + ) + + async def register_commands( + self, command_sets: Union[List[CommandSet], None] = None + ): + """Register commands stored in bot's 'commands' attribute""" + + if command_sets is None: + commands = [ + BotCommand(command=command.command, description=command.description) + for command in self.commands + ] + + logger.info( + "Registering commands %s with a default scope 'BotCommandScopeDefault'" + ) + + await self.set_bot_commands(commands) + return + + for command_set in command_sets: + logger.info( + "Registering command set with commands %s and scope '%s' (%s)", + command_set.commands, + command_set.scope, + command_set.language_code, + ) + await self.set_bot_commands( + command_set.commands, + command_set.scope, + language_code=command_set.language_code, + ) + + async def remove_commands(self, command_sets: Union[List[CommandSet], None] = None): + """Remove commands stored in bot's 'commands' attribute""" + + if command_sets is None: + logger.info( + "Removing commands with a default scope 'BotCommandScopeDefault'" + ) + await self.delete_bot_commands(BotCommandScopeDefault()) + return + + for command_set in command_sets: + logger.info( + "Removing command set with scope '%s' (%s)", + command_set.scope, + command_set.language_code, + ) + await self.delete_bot_commands( + command_set.scope, + language_code=command_set.language_code, + ) + + async def submit_photo( + self, id: str + ) -> Tuple[Union[Message, None], Union[str, None]]: + db_entry = col_submitted.find_one({"_id": ObjectId(id)}) + submission = None + + if db_entry is None: + raise SubmissionUnavailableError() + + if db_entry["temp"]["uuid"] is None: + try: + submission = await self.get_messages( + db_entry["user"], db_entry["telegram"]["msg_id"] + ) + filepath = await self.download_media( + submission, file_name=self.config["locations"]["tmp"] + sep + ) + except Exception as exp: + raise SubmissionUnavailableError() + + elif not Path( + f"{self.config['locations']['data']}/submissions/{db_entry['temp']['uuid']}/{db_entry['temp']['file']}", + ).exists(): + raise SubmissionUnavailableError() + else: + filepath = Path( + f"{self.config['locations']['data']}/submissions/{db_entry['temp']['uuid']}/{db_entry['temp']['file']}", + ) + with contextlib.suppress(Exception): + submission = await self.get_messages( + db_entry["user"], db_entry["telegram"]["msg_id"] + ) + + with open(str(filepath), "rb") as fh: + photo_bytes = BytesIO(fh.read()) + + try: + response = await photo_upload( + self.config["posting"]["api"]["album"], + client=client, + multipart_data=BodyPhotoUploadAlbumsAlbumPhotosPost( + File(photo_bytes, filepath.name, "image/jpeg") + ), + ignore_duplicates=self.config["submission"]["allow_duplicates"], + compress=False, + caption="queue", + ) + except UnexpectedStatus: + raise SubmissionUnsupportedError(str(filepath)) + + response_dict = loads(response.content.decode("utf-8")) + + if "duplicates" in response_dict and len(response_dict["duplicates"]) > 0: + duplicates = [] + for index, duplicate in enumerate(response_dict["duplicates"]): # type: ignore + if response_dict["access_token"] is None: + duplicates.append( + f"`{duplicate['id']}`:\n{self.config['posting']['api']['address_external']}/photos/{duplicate['id']}" + ) + else: + duplicates.append( + f"`{duplicate['id']}`:\n{self.config['posting']['api']['address_external']}/token/photo/{response_dict['access_token']}?id={index}" + ) + raise SubmissionDuplicatesError(str(filepath), duplicates) + + col_submitted.find_one_and_update( + {"_id": ObjectId(id)}, {"$set": {"done": True}} + ) + + try: + if db_entry["temp"]["uuid"] is not None: + rmtree( + Path( + f"{self.config['locations']['data']}/submissions/{db_entry['temp']['uuid']}", + ), + ignore_errors=True, + ) + else: + remove(str(filepath)) + except (FileNotFoundError, NotADirectoryError): + logger.error("Could not delete '%s' on submission accepted", filepath) + + return submission, response.parsed.id + + async def ban_user(self, id: int) -> None: + pass + + async def unban_user(self, id: int) -> None: + pass diff --git a/classes/pyrocommand.py b/classes/pyrocommand.py new file mode 100644 index 0000000..7b01180 --- /dev/null +++ b/classes/pyrocommand.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + + +@dataclass +class PyroCommand: + """Command stored in PyroClient's 'commands' attribute""" + + command: str + description: str diff --git a/classes/user.py b/classes/user.py index 8042908..7e6d368 100644 --- a/classes/user.py +++ b/classes/user.py @@ -1,7 +1,8 @@ -from modules.app import app from datetime import datetime + +from libbot import sync + from modules.database import col_banned, col_users -from modules.utils import configGet class PosterUser: @@ -31,18 +32,20 @@ class PosterUser: ### Returns: `bool`: Must be `True` if on the cooldown and `False` if not """ - if self.id in app.admins: + if self.id in sync.config_get("admins", "bot"): return False - else: - db_record = col_users.find_one({"user": self.id}) - if db_record is None: - return False - return ( - True - if (datetime.now() - db_record["cooldown"]).total_seconds() - < configGet("timeout", "submission") - else False - ) + + db_record = col_users.find_one({"user": self.id}) + + if db_record is None: + return False + + return ( + True + if (datetime.now() - db_record["cooldown"]).total_seconds() + < sync.config_get("timeout", "submission") + else False + ) def limit(self) -> None: """Restart user's cooldown. Used after post has been submitted.""" diff --git a/config_example.json b/config_example.json index 473f439..8c9fded 100644 --- a/config_example.json +++ b/config_example.json @@ -2,12 +2,14 @@ "locale": "en", "locale_log": "en", "locale_fallback": "en", - "owner": 0, - "admins": [], "bot": { + "owner": 0, + "admins": [], "api_id": 0, "api_hash": "", - "bot_token": "" + "bot_token": "", + "max_concurrent_transmissions": 5, + "scoped_commands": true }, "database": { "user": null, @@ -16,17 +18,18 @@ "port": 27017, "name": "tgposter" }, - "mode": { - "post": true, - "submit": true - }, "reports": { + "chat_id": 0, "sent": false, "error": true, "update": true, "startup": true, "shutdown": true }, + "mode": { + "post": true, + "submit": true + }, "logging": { "size": 512, "location": "logs" @@ -40,6 +43,7 @@ "index": "data/index.json", "locale": "locale" }, + "disabled_plugins": [], "posting": { "channel": 0, "silent": false, @@ -110,14 +114,76 @@ "video/quicktime" ] }, - "commands": [ - "start", - "rules" - ], - "commands_admin": [ - "import", - "export", - "remove", - "shutdown" - ] + "commands": { + "start": { + "scopes": [ + { + "name": "BotCommandScopeDefault" + }, + { + "name": "BotCommandScopeChat", + "chat_id": "owner" + } + ] + }, + "rules": { + "scopes": [ + { + "name": "BotCommandScopeDefault" + }, + { + "name": "BotCommandScopeChat", + "chat_id": "owner" + } + ] + }, + "forwards": { + "scopes": [ + { + "name": "BotCommandScopeChat", + "chat_id": "owner" + } + ] + }, + "import": { + "scopes": [ + { + "name": "BotCommandScopeChat", + "chat_id": "owner" + } + ] + }, + "export": { + "scopes": [ + { + "name": "BotCommandScopeChat", + "chat_id": "owner" + } + ] + }, + "remove": { + "scopes": [ + { + "name": "BotCommandScopeChat", + "chat_id": "owner" + } + ] + }, + "purge": { + "scopes": [ + { + "name": "BotCommandScopeChat", + "chat_id": "owner" + } + ] + }, + "shutdown": { + "scopes": [ + { + "name": "BotCommandScopeChat", + "chat_id": "owner" + } + ] + } + } } \ No newline at end of file diff --git a/locale/en.json b/locale/en.json index 3a23761..8dfda65 100644 --- a/locale/en.json +++ b/locale/en.json @@ -1,9 +1,7 @@ { "commands": { "start": "Start using the bot", - "rules": "Photos submission rules" - }, - "commands_admin": { + "rules": "Photos submission rules", "forwards": "Check post forwards", "import": "Submit .zip archive with photos", "export": "Get .zip archive with all photos", @@ -60,7 +58,8 @@ "remove_abort": "Removal aborted.", "remove_success": "Removed media with ID `{0}`.", "remove_failure": "Could not remove media with ID `{0}`. Check if provided ID is correct and if it is - you can also check bot's log for details.", - "update_available": "**New version found**\nThere's a newer version of a bot found. You can update your bot to [{0}]({1}) using command line of your host.\n\n**Release notes**\n{2}\n\nRead more about updating you bot on the [wiki page](https://git.end-play.xyz/profitroll/TelegramPoster/wiki/Updating-Instance).\n\nPlease not that you can also disable this notification by editing `reports.update` key of the config." + "update_available": "**New version found**\nThere's a newer version of a bot found. You can update your bot to [{0}]({1}) using command line of your host.\n\n**Release notes**\n{2}\n\nRead more about updating you bot on the [wiki page](https://git.end-play.xyz/profitroll/TelegramPoster/wiki/Updating-Instance).\n\nPlease not that you can also disable this notification by editing `reports.update` key of the config.", + "shutdown_confirm": "There are {0} unfinished users' contexts. If you turn off the bot, those will be lost. Please confirm shutdown using a button below." }, "button": { "sub_yes": "✅ Accept", @@ -70,7 +69,8 @@ "sub_unblock": "🏳️ Unblock sender", "post_view": "View in channel", "accepted": "✅ Accepted", - "declined": "❌ Declined" + "declined": "❌ Declined", + "shutdown": "Confirm shutdown" }, "callback": { "sub_yes": "✅ Submission approved", diff --git a/locale/uk.json b/locale/uk.json index 0696eb3..6d461a5 100644 --- a/locale/uk.json +++ b/locale/uk.json @@ -1,9 +1,7 @@ { "commands": { "start": "Почати користуватись ботом", - "rules": "Правила пропонування фото" - }, - "commands_admin": { + "rules": "Правила пропонування фото", "forwards": "Переглянути репости", "import": "Надати боту .zip архів з фотографіями", "export": "Отримати .zip архів з усіма фотографіями", @@ -60,7 +58,8 @@ "remove_abort": "Видалення перервано.", "remove_success": "Видалено медіа з ID `{0}`.", "remove_failure": "Не вдалося видалити медіа з ID `{0}`. Перевірте, чи вказано правильний ID, і якщо він правильний, ви також можете переглянути логи бота для отримання більш детальної інформації.", - "update_available": "**Знайдено нову версію**\nЗнайдено нову версію бота. Ви можете оновити бота до [{0}]({1}) за допомогою командного рядка вашого хосту.\n\n**Примітки до релізу**\n{2}\n\nДетальніше про оновлення бота можна знайти на [вікі-сторінці](https://git.end-play.xyz/profitroll/TelegramPoster/wiki/Updating-Instance).\n\nЗверніть увагу, що ви також можете вимкнути це сповіщення, відредагувавши ключ `reports.update` у конфігурації." + "update_available": "**Знайдено нову версію**\nЗнайдено нову версію бота. Ви можете оновити бота до [{0}]({1}) за допомогою командного рядка вашого хосту.\n\n**Примітки до релізу**\n{2}\n\nДетальніше про оновлення бота можна знайти на [вікі-сторінці](https://git.end-play.xyz/profitroll/TelegramPoster/wiki/Updating-Instance).\n\nЗверніть увагу, що ви також можете вимкнути це сповіщення, відредагувавши ключ `reports.update` у конфігурації.", + "shutdown_confirm": "Існує {0} незавершених контекстів користувачів. Якщо ви вимкнете бота, вони будуть втрачені. Будь ласка, підтвердіть вимкнення за допомогою кнопки нижче." }, "button": { "sub_yes": "✅ Прийняти", @@ -70,7 +69,8 @@ "sub_unblock": "🏳️ Розблокувати відправника", "post_view": "Переглянути на каналі", "accepted": "✅ Прийнято", - "declined": "❌ Відхилено" + "declined": "❌ Відхилено", + "shutdown": "Підтвердити вимкнення" }, "callback": { "sub_yes": "✅ Подання схвалено", diff --git a/main.py b/main.py new file mode 100644 index 0000000..51629d7 --- /dev/null +++ b/main.py @@ -0,0 +1,39 @@ +import contextlib +import logging +from os import getpid + +from classes.pyroclient import PyroClient +from modules.scheduler import scheduler + +from convopyro import Conversation + + +logging.basicConfig( + level=logging.INFO, + format="%(name)s.%(funcName)s | %(levelname)s | %(message)s", + datefmt="[%X]", +) + +logger = logging.getLogger(__name__) + +with contextlib.suppress(ImportError): + import uvloop + + uvloop.install() + + +def main(): + client = PyroClient() + Conversation(client) + + try: + client.run() + except KeyboardInterrupt: + logger.warning("Forcefully shutting down with PID %s...", getpid()) + finally: + scheduler.shutdown() + exit() + + +if __name__ == "__main__": + main() diff --git a/modules/api_client.py b/modules/api_client.py index af0507a..fac6487 100644 --- a/modules/api_client.py +++ b/modules/api_client.py @@ -1,38 +1,63 @@ -"""This is only a temporary solution. Complete Photos API client is yet to be developed.""" - import asyncio +import logging from base64 import b64decode, b64encode from os import makedirs, path, sep -from random import choice -from traceback import print_exc -from typing import Tuple, Union +from pathlib import Path import aiofiles -from aiohttp import FormData - -from classes.exceptions import ( - AlbumCreationDuplicateError, - AlbumCreationError, - AlbumCreationNameError, - SubmissionUploadError, - UserCreationDuplicateError, - UserCreationError, +from libbot import config_get, i18n, sync +from photosapi_client import AuthenticatedClient, Client +from photosapi_client.api.default.album_create_albums_post import ( + asyncio as album_create, ) -from modules.logger import logWrite -from modules.utils import configGet, locale +from photosapi_client.api.default.album_delete_album_id_delete import ( + asyncio as album_delete, +) +from photosapi_client.api.default.album_find_albums_get import asyncio as album_find +from photosapi_client.api.default.login_for_access_token_token_post import sync as login +from photosapi_client.api.default.photo_delete_photos_id_delete import ( + asyncio as photo_delete, +) +from photosapi_client.api.default.photo_find_albums_album_photos_get import ( + asyncio as photo_find, +) +from photosapi_client.api.default.photo_get_photos_id_get import asyncio as photo_get +from photosapi_client.api.default.photo_patch_photos_id_patch import ( + asyncio as photo_patch, +) +from photosapi_client.api.default.photo_upload_albums_album_photos_post import ( + asyncio_detailed as photo_upload, +) +from photosapi_client.api.default.user_create_users_post import asyncio as user_create +from photosapi_client.api.default.user_me_users_me_get import sync as user_me +from photosapi_client.api.default.video_find_albums_album_videos_get import ( + asyncio as video_find, +) +from photosapi_client.models.body_login_for_access_token_token_post import ( + BodyLoginForAccessTokenTokenPost, +) +from photosapi_client.models.body_photo_upload_albums_album_photos_post import ( + BodyPhotoUploadAlbumsAlbumPhotosPost, +) +from photosapi_client.models.http_validation_error import HTTPValidationError +from photosapi_client.models.token import Token +from photosapi_client.types import File + from modules.http_client import http_session +logger = logging.getLogger(__name__) + async def authorize() -> str: - makedirs(configGet("cache", "locations"), exist_ok=True) - if path.exists(configGet("cache", "locations") + sep + "api_access") is True: + makedirs(await config_get("cache", "locations"), exist_ok=True) + if path.exists(await config_get("cache", "locations") + sep + "api_access") is True: async with aiofiles.open( - configGet("cache", "locations") + sep + "api_access", "rb" + await config_get("cache", "locations") + sep + "api_access", "rb" ) as file: token = b64decode(await file.read()).decode("utf-8") if ( await http_session.get( - configGet("address", "posting", "api") + "/users/me/", + await config_get("address", "posting", "api") + "/users/me/", headers={"Authorization": f"Bearer {token}"}, ) ).status == 200: @@ -40,27 +65,28 @@ async def authorize() -> str: payload = { "grant_type": "password", "scope": "me albums.list albums.read albums.write photos.list photos.read photos.write videos.list videos.read videos.write", - "username": configGet("username", "posting", "api"), - "password": configGet("password", "posting", "api"), + "username": await config_get("username", "posting", "api"), + "password": await config_get("password", "posting", "api"), } response = await http_session.post( - configGet("address", "posting", "api") + "/token", data=payload + await config_get("address", "posting", "api") + "/token", data=payload ) if not response.ok: - logWrite( - locale( + logger.warning( + i18n._( "api_creds_invalid", "console", - locale=configGet("locale_log").format( - configGet("address", "posting", "api"), - configGet("username", "posting", "api"), + locale=(await config_get("locale_log")).format( + await config_get("address", "posting", "api"), + await config_get("username", "posting", "api"), response.status, ), ) ) raise ValueError async with aiofiles.open( - configGet("cache", "locations") + sep + "api_access", "wb" + str(Path(f"{await config_get('cache', 'locations')}/api_access")), + "wb", ) as file: await file.write( b64encode((await response.json())["access_token"].encode("utf-8")) @@ -68,196 +94,36 @@ async def authorize() -> str: return (await response.json())["access_token"] -async def random_pic(token: Union[str, None] = None) -> Tuple[str, str]: - """Returns random image id and filename from the queue. +unauthorized_client = Client( + base_url=sync.config_get("address", "posting", "api"), + timeout=5.0, + verify_ssl=True, + raise_on_unexpected_status=True, +) - ### Returns: - * `Tuple[str, str]`: First value is an ID and the filename in the filesystem to be indexed. - """ - token = await authorize() if token is None else token - logWrite( - f'{configGet("address", "posting", "api")}/albums/{configGet("album", "posting", "api")}/photos?q=&page_size={configGet("page_size", "posting")}&caption=queue' +login_token = login( + client=unauthorized_client, + form_data=BodyLoginForAccessTokenTokenPost( + grant_type="password", + scope="me albums.list albums.read albums.write photos.list photos.read photos.write videos.list videos.read videos.write", + username=sync.config_get("username", "posting", "api"), + password=sync.config_get("password", "posting", "api"), + ), +) + +if not isinstance(login_token, Token): + logger.warning( + "Could not initialize connection due to invalid token: %s", login_token ) - resp = await http_session.get( - f'{configGet("address", "posting", "api")}/albums/{configGet("album", "posting", "api")}/photos?q=&page_size={configGet("page_size", "posting")}&caption=queue', - headers={"Authorization": f"Bearer {token}"}, - ) - # logWrite( - # locale("random_pic_response", "console", locale=configGet("locale_log")).format( - # await resp.json() - # ), - # debug=True, - # ) - if resp.status != 200: - logWrite( - locale( - "random_pic_error_code", - "console", - locale=configGet("locale_log").format( - configGet("album", "posting", "api"), resp.status - ), - ), - ) - logWrite( - locale( - "random_pic_error_debug", - "console", - locale=configGet("locale_log").format( - configGet("address", "posting", "api"), - configGet("album", "posting", "api"), - configGet("page_size", "posting"), - token, - resp.status, - ), - ), - debug=True, - ) - raise ValueError - if len((await resp.json())["results"]) == 0: - raise KeyError - pic = choice((await resp.json())["results"]) - return pic["id"], pic["filename"] - - -async def upload_pic( - filepath: str, allow_duplicates: bool = False, token: Union[str, None] = None -) -> Tuple[bool, list, Union[str, None]]: - token = await authorize() if token is None else token - try: - pic_name = path.basename(filepath) - logWrite(f"Uploading {pic_name} to the API...", debug=True) - async with aiofiles.open(filepath, "rb") as f: - file_bytes = await f.read() - formdata = FormData() - formdata.add_field( - "file", file_bytes, filename=pic_name, content_type="image/jpeg" - ) - response = await http_session.post( - f'{configGet("address", "posting", "api")}/albums/{configGet("album", "posting", "api")}/photos', - params={ - "caption": "queue", - "compress": "false", - "ignore_duplicates": str(allow_duplicates).lower(), - }, - headers={"Authorization": f"Bearer {token}"}, - data=formdata, - ) - response_json = await response.json() - if response.status != 200 and response.status != 409: - logWrite( - locale( - "pic_upload_error", - "console", - locale=configGet("locale_log").format( - filepath, response.status, response.content - ), - ), - ) - raise SubmissionUploadError( - str(filepath), response.status, response.content - ) - id = response_json["id"] if "id" in await response.json() else None - duplicates = [] - if "duplicates" in response_json: - for index, duplicate in enumerate(response_json["duplicates"]): # type: ignore - if response_json["access_token"] is None: - duplicates.append( - f'`{duplicate["id"]}`:\n{configGet("address_external", "posting", "api")}/photos/{duplicate["id"]}' - ) - else: - duplicates.append( - f'`{duplicate["id"]}`:\n{configGet("address_external", "posting", "api")}/token/photo/{response_json["access_token"]}?id={index}' - ) - return True, duplicates, id - except Exception as exp: - print_exc() - return False, [], None - - -async def find_pic( - name: str, caption: Union[str, None] = None, token: Union[str, None] = None -) -> Union[dict, None]: - token = await authorize() if token is None else token - try: - response = await http_session.get( - f'{configGet("address", "posting", "api")}/albums/{configGet("album", "posting", "api")}/photos', - params={"q": name, "caption": caption}, - headers={"Authorization": f"Bearer {token}"}, - ) - # logWrite(response.json()) - if response.status != 200: - return None - if len((await response.json())["results"]) == 0: - return None - return (await response.json())["results"] - except Exception as exp: - logWrite( - locale( - "find_pic_error", - "console", - locale=configGet("locale_log").format(name, caption, exp), - ), - ) - return None - - -async def move_pic(id: str, token: Union[str, None] = None) -> bool: - token = await authorize() if token is None else token - try: - response = await http_session.patch( - f'{configGet("address", "posting", "api")}/photos/{id}?caption=sent', - headers={"Authorization": f"Bearer {token}"}, - ) - if response.status != 200: - logWrite(f"Media moving failed with HTTP {response.status}", debug=True) - return False - return True - except: - return False - - -async def remove_pic(id: str, token: Union[str, None] = None) -> bool: - token = await authorize() if token is None else token - try: - response = await http_session.delete( - f'{configGet("address", "posting", "api")}/photos/{id}', - headers={"Authorization": f"Bearer {token}"}, - ) - if response.status != 204: - logWrite(f"Media removal failed with HTTP {response.status}", debug=True) - return False - return True - except: - return False - - -async def create_user(username: str, email: str, password: str) -> None: - response = await http_session.post( - f'{configGet("address", "posting", "api")}/users', - data={"user": username, "email": email, "password": password}, - ) - if response.status == 409: - raise UserCreationDuplicateError(username) - elif response.status != 204: - raise UserCreationError(response.status, await response.text(encoding="utf-8")) - return None - - -async def create_album(name: str, title: str) -> None: - token = await authorize() - response = await http_session.post( - f'{configGet("address", "posting", "api")}/albums', - params={"name": name, "title": title}, - headers={"Authorization": f"Bearer {token}"}, - ) - if response.status == 409: - raise AlbumCreationDuplicateError(name) - elif response.status == 406: - raise AlbumCreationNameError(await response.json()) - elif response.status != 200: - raise AlbumCreationError(response.status, await response.text(encoding="utf-8")) - return None + exit() +client = AuthenticatedClient( + base_url=sync.config_get("address", "posting", "api"), + timeout=5.0, + verify_ssl=True, + raise_on_unexpected_status=True, + token=login_token.access_token, +) if __name__ == "__main__": print(asyncio.run(authorize())) diff --git a/modules/app.py b/modules/app.py deleted file mode 100644 index 2724747..0000000 --- a/modules/app.py +++ /dev/null @@ -1,14 +0,0 @@ -from modules.utils import configGet -from classes.poster_client import PosterClient -from convopyro import Conversation - -app = PosterClient( - "duptsiaposter", - bot_token=configGet("bot_token", "bot"), - api_id=configGet("api_id", "bot"), - api_hash=configGet("api_hash", "bot"), -) - -Conversation(app) - -users_with_context = [] diff --git a/modules/cli.py b/modules/cli.py deleted file mode 100644 index 0e4768e..0000000 --- a/modules/cli.py +++ /dev/null @@ -1,78 +0,0 @@ -import asyncio -from sys import exit -from traceback import print_exc -from modules.api_client import create_album, create_user, http_session -from argparse import ArgumentParser - -from modules.utils import configSet - -parser = ArgumentParser( - prog="Telegram Poster", - description="Bot for posting some of your stuff and also receiving submissions.", -) - -parser.add_argument("--create-user", action="store_true") -parser.add_argument("--create-album", action="store_true") - -args = parser.parse_args() - - -async def cli_create_user() -> None: - print( - "To set up Photos API connection you need to create a new user.\nIf you have email confirmation enabled in your Photos API config - you need to use a real email that will get a confirmation code afterwards.", - flush=True, - ) - username = input("Choose username for new Photos API user: ").strip() - email = input(f"Choose email for user '{username}': ").strip() - password = input(f"Choose password for user '{username}': ").strip() - try: - result_1 = await create_user(username, email, password) - # asyncio.run(create_user(username, email, password)) - configSet("username", username, "posting", "api") - configSet("password", password, "posting", "api") - none = input( - "Alright. If you have email confirmation enabled - please confirm registration by using the link in your email. After that press Enter. Otherwise just press Enter." - ) - except Exception as exp: - print(f"Could not create a user due to {exp}", flush=True) - print_exc() - exit() - if not args.create_album: - print("You're done!", flush=True) - await http_session.close() - exit() - return None - - -async def cli_create_album() -> None: - print( - "To use Photos API your user needs to have an album to store its data.\nThis wizard will help you to create a new album with its name and title.", - flush=True, - ) - name = input("Choose a name for your album: ").strip() - title = input(f"Choose a title for album '{name}': ").strip() - try: - result_2 = await create_album(name, title) - # asyncio.run(create_album(name, title)) - configSet("album", name, "posting", "api") - except Exception as exp: - print(f"Could not create an album due to {exp}", flush=True) - print_exc() - exit() - print("You're done!", flush=True) - await http_session.close() - exit() - return None - - -if args.create_user or args.create_album: - loop = asyncio.get_event_loop() - tasks = [] - - if args.create_user: - loop.run_until_complete(asyncio.wait([loop.create_task(cli_create_user())])) - - if args.create_album: - loop.run_until_complete(asyncio.wait([loop.create_task(cli_create_album())])) - - loop.close() diff --git a/modules/commands_register.py b/modules/commands_register.py deleted file mode 100644 index de1bcbe..0000000 --- a/modules/commands_register.py +++ /dev/null @@ -1,57 +0,0 @@ -from os import listdir -from classes.poster_client import PosterClient -from pyrogram.types import BotCommand, BotCommandScopeChat -from modules.utils import configGet, locale - - -async def register_commands(app: PosterClient) -> None: - if configGet("submit", "mode"): - # Registering user commands - for entry in listdir(configGet("locale", "locations")): - if entry.endswith(".json"): - commands_list = [] - for command in configGet("commands"): - commands_list.append( - BotCommand( - command, - locale( - command, "commands", locale=entry.replace(".json", "") - ), - ) - ) - await app.set_bot_commands( - commands_list, language_code=entry.replace(".json", "") - ) - - # Registering user commands for fallback locale - commands_list = [] - for command in configGet("commands"): - commands_list.append( - BotCommand( - command, - locale(command, "commands", locale=configGet("locale_fallback")), - ) - ) - await app.set_bot_commands(commands_list) - - # Registering admin commands - commands_admin_list = [] - - if configGet("submit", "mode"): - for command in configGet("commands"): - commands_admin_list.append( - BotCommand( - command, locale(command, "commands", locale=configGet("locale")) - ) - ) - for command in configGet("commands_admin"): - commands_admin_list.append( - BotCommand( - command, locale(command, "commands_admin", locale=configGet("locale")) - ) - ) - - for admin in app.admins: - await app.set_bot_commands( - commands_admin_list, scope=BotCommandScopeChat(chat_id=admin) - ) diff --git a/modules/logger.py b/modules/logger.py deleted file mode 100644 index 0f32b14..0000000 --- a/modules/logger.py +++ /dev/null @@ -1,67 +0,0 @@ -from datetime import datetime -from gzip import open as gzipopen -from os import getcwd, makedirs, path, stat -from shutil import copyfileobj - -from ujson import loads - -with open(getcwd() + path.sep + "config.json", "r", encoding="utf8") as file: - json_contents = loads(file.read()) - log_size = json_contents["logging"]["size"] - log_folder = json_contents["logging"]["location"] - file.close() - - -# Check latest log size -def checkSize(debug=False) -> None: - global log_folder - - if debug: - log_file = "debug.log" - else: - log_file = "latest.log" - - try: - makedirs(log_folder, exist_ok=True) - log = stat(path.join(log_folder, log_file)) - if (log.st_size / 1024) > log_size: - with open(path.join(log_folder, log_file), "rb") as f_in: - with gzipopen( - path.join( - log_folder, - f'{datetime.now().strftime("%d.%m.%Y_%H:%M:%S")}.log.gz', - ), - "wb", - ) as f_out: - copyfileobj(f_in, f_out) - print( - f'Copied {path.join(log_folder, datetime.now().strftime("%d.%m.%Y_%H:%M:%S"))}.log.gz' - ) - open(path.join(log_folder, log_file), "w").close() - except FileNotFoundError: - print(f"Log file {path.join(log_folder, log_file)} does not exist") - pass - - -# Append string to log -def logAppend(message, debug=False) -> None: - global log_folder - - message_formatted = f'[{datetime.now().strftime("%d.%m.%Y")}] [{datetime.now().strftime("%H:%M:%S")}] {message}' - checkSize(debug=debug) - - if debug: - log_file = "debug.log" - else: - log_file = "latest.log" - - log = open(path.join(log_folder, log_file), "a") - log.write(f"{message_formatted}\n") - log.close() - - -# Print to stdout and then to log -def logWrite(message, debug=False) -> None: - # save to log file and rotation is to be done - logAppend(f"{message}", debug=debug) - print(f"{message}", flush=True) diff --git a/modules/scheduler.py b/modules/scheduler.py index ba8fe06..b01330d 100644 --- a/modules/scheduler.py +++ b/modules/scheduler.py @@ -1,31 +1,24 @@ -from datetime import datetime, timedelta +from datetime import datetime + from apscheduler.schedulers.asyncio import AsyncIOScheduler +from libbot import sync from pytimeparse.timeparse import timeparse -from modules.utils import configGet -from modules.sender import send_content -from modules.commands_register import register_commands -from modules.app import app + +# from modules.sender import send_content scheduler = AsyncIOScheduler() -if configGet("post", "mode"): - if configGet("use_interval", "posting"): - scheduler.add_job( - send_content, - "interval", - seconds=timeparse(configGet("interval", "posting")), - args=[app], - ) - else: - for entry in configGet("time", "posting"): - dt_obj = datetime.strptime(entry, "%H:%M") - scheduler.add_job( - send_content, "cron", hour=dt_obj.hour, minute=dt_obj.minute, args=[app] - ) - -scheduler.add_job( - register_commands, - "date", - run_date=datetime.now() + timedelta(seconds=10), - args=[app], -) +# if sync.config_get("post", "mode"): +# if sync.config_get("use_interval", "posting"): +# scheduler.add_job( +# send_content, +# "interval", +# seconds=timeparse(sync.config_get("interval", "posting")), +# args=[app], +# ) +# else: +# for entry in sync.config_get("time", "posting"): +# dt_obj = datetime.strptime(entry, "%H:%M") +# scheduler.add_job( +# send_content, "cron", hour=dt_obj.hour, minute=dt_obj.minute, args=[app] +# ) diff --git a/modules/sender.py b/modules/sender.py index 52a0df0..b4f2845 100644 --- a/modules/sender.py +++ b/modules/sender.py @@ -1,4 +1,5 @@ from datetime import datetime +import logging from os import makedirs, path from random import choice from shutil import rmtree @@ -7,107 +8,107 @@ from uuid import uuid4 from PIL import Image import aiofiles -from classes.poster_client import PosterClient +from classes.pyroclient import PyroClient -from modules.api_client import authorize, move_pic, random_pic, http_session +from modules.api_client import authorize, http_session, photo_patch, photo_find, client from modules.database import col_sent, col_submitted -from modules.logger import logWrite -from modules.utils import configGet, locale + +from photosapi_client.errors import UnexpectedStatus +logger = logging.getLogger(__name__) -async def send_content(app: PosterClient) -> None: +async def send_content(app: PyroClient) -> None: try: try: token = await authorize() except ValueError: await app.send_message( app.owner, - locale("api_creds_invalid", "message", locale=configGet("locale")), + app._("api_creds_invalid", "message"), ) return try: - pic = await random_pic() - except KeyError: - logWrite(locale("post_empty", "console", locale=configGet("locale"))) - if configGet("error", "reports"): + pic = choice((await photo_find(album=app.config["posting"]["api"]["album"], caption="queue", page_size=app.config["posting"]["page_size"], client=client)).results) + except (KeyError, AttributeError, TypeError): + logger.info(app._("post_empty", "console")) + if app.config["reports"]["error"]: await app.send_message( app.owner, - locale("api_queue_empty", "message", locale=configGet("locale")), + app._("api_queue_empty", "message"), ) return - except ValueError: - if configGet("error", "reports"): + except (ValueError, UnexpectedStatus): + if app.config["reports"]["error"]: await app.send_message( app.owner, - locale("api_queue_error", "message", locale=configGet("locale")), + app._("api_queue_error", "message"), ) return response = await http_session.get( - f'{configGet("address", "posting", "api")}/photos/{pic[0]}', + f"{app.config['posting']['api']['address']}/photos/{pic.id}", headers={"Authorization": f"Bearer {token}"}, ) if response.status != 200: - logWrite( - locale( - "post_invalid_pic", "console", locale=configGet("locale") - ).format(response.status, str(await response.json())) + logger.warning( + app._("post_invalid_pic", "console").format( + response.status, str(await response.json()) + ) ) - if configGet("error", "reports"): + if app.config["reports"]["error"]: await app.send_message( app.owner, - locale( - "post_invalid_pic", "message", locale=configGet("locale") - ).format(response.status, await response.json()), + app._("post_invalid_pic", "message").format( + response.status, await response.json() + ), ) tmp_dir = str(uuid4()) - makedirs(path.join(configGet("tmp", "locations"), tmp_dir), exist_ok=True) + makedirs(path.join(app.config['locations']['tmp'], tmp_dir), exist_ok=True) - tmp_path = path.join(tmp_dir, pic[1]) + tmp_path = path.join(tmp_dir, pic.filename) async with aiofiles.open( - path.join(configGet("tmp", "locations"), tmp_path), "wb" + path.join(app.config['locations']['tmp'], tmp_path), "wb" ) as out_file: await out_file.write(await response.read()) - logWrite( - f'Candidate {pic[1]} ({pic[0]}) is {path.getsize(path.join(configGet("tmp", "locations"), tmp_path))} bytes big', - debug=True, + logger.info( + f'Candidate {pic.filename} ({pic.id}) is {path.getsize(path.join(app.config['locations']['tmp'], tmp_path))} bytes big', ) - if path.getsize(path.join(configGet("tmp", "locations"), tmp_path)) > 5242880: - image = Image.open(path.join(configGet("tmp", "locations"), tmp_path)) + if path.getsize(path.join(app.config['locations']['tmp'], tmp_path)) > 5242880: + image = Image.open(path.join(app.config['locations']['tmp'], tmp_path)) width, height = image.size image = image.resize((int(width / 2), int(height / 2)), Image.ANTIALIAS) if tmp_path.lower().endswith(".jpeg") or tmp_path.lower().endswith(".jpg"): image.save( - path.join(configGet("tmp", "locations"), tmp_path), + path.join(app.config['locations']['tmp'], tmp_path), "JPEG", optimize=True, quality=50, ) elif tmp_path.lower().endswith(".png"): image.save( - path.join(configGet("tmp", "locations"), tmp_path), + path.join(app.config['locations']['tmp'], tmp_path), "PNG", optimize=True, compress_level=8, ) image.close() - if path.getsize(path.join(configGet("tmp", "locations"), tmp_path)) > 5242880: + if path.getsize(path.join(app.config['locations']['tmp'], tmp_path)) > 5242880: rmtree( - path.join(configGet("tmp", "locations"), tmp_dir), ignore_errors=True + path.join(app.config['locations']['tmp'], tmp_dir), ignore_errors=True ) raise BytesWarning del response - submitted = col_submitted.find_one({"temp.file": pic[1]}) + submitted = col_submitted.find_one({"temp.file": pic.filename}) if submitted is not None and submitted["caption"] is not None: caption = submitted["caption"].strip() @@ -116,86 +117,78 @@ async def send_content(app: PosterClient) -> None: if ( submitted is not None - and configGet("enabled", "posting", "submitted_caption") + and app.config["posting"]["submitted_caption"]["enabled"] and ( (submitted["user"] not in app.admins) - or (configGet("ignore_admins", "posting", "submitted_caption") is False) + or (app.config["posting"]["submitted_caption"]["ignore_admins"] is False) ) ): caption = ( - f"{caption}\n\n{configGet('text', 'posting', 'submitted_caption')}\n" + f"{caption}\n\n{app.config['posting']['submitted_caption']['text']}\n" ) else: caption = f"{caption}\n\n" - if configGet("enabled", "caption"): - if configGet("link", "caption") != None: - caption = f"{caption}[{choice(configGet('text', 'caption'))}]({configGet('link', 'caption')})" + if app.config["caption"]["enabled"]: + if app.config["caption"]["link"] is not None: + caption = f"{caption}[{choice(app.config['caption']['text'])}]({app.config['caption']['link']})" else: - caption = f"{caption}{choice(configGet('text', 'caption'))}" + caption = f"{caption}{choice(app.config['caption']['text'])}" else: caption = caption try: sent = await app.send_photo( - configGet("channel", "posting"), - path.join(configGet("tmp", "locations"), tmp_path), + app.config["posting"]["channel"], + path.join(app.config['locations']['tmp'], tmp_path), caption=caption, - disable_notification=configGet("silent", "posting"), + disable_notification=app.config["posting"]["silent"], ) except Exception as exp: - logWrite(f"Could not send image {pic[1]} ({pic[0]}) due to {exp}") - if configGet("error", "reports"): + logger.error(f"Could not send image {pic.filename} ({pic.id}) due to {exp}") + if app.config["reports"]["error"]: await app.send_message( app.owner, - locale( - "post_exception", "message", locale=configGet("locale") - ).format(exp, format_exc()), + app._("post_exception", "message").format(exp, format_exc()), ) - # rmtree(path.join(configGet("tmp", "locations"), tmp_dir), ignore_errors=True) + # rmtree(path.join(app.config['locations']['tmp'], tmp_dir), ignore_errors=True) return col_sent.insert_one( { "date": datetime.now(), - "image": pic[0], - "filename": pic[1], - "channel": configGet("channel", "posting"), + "image": pic.id, + "filename": pic.filename, + "channel": app.config["posting"]["channel"], "caption": None if (submitted is None or submitted["caption"] is None) else submitted["caption"].strip(), } ) - await move_pic(pic[0]) + await photo_patch(id=pic.id, client=client, caption="sent") - rmtree(path.join(configGet("tmp", "locations"), tmp_dir), ignore_errors=True) + rmtree(path.join(app.config['locations']['tmp'], tmp_dir), ignore_errors=True) - logWrite( - locale("post_sent", "console", locale=configGet("locale")).format( - pic[0], - str(configGet("channel", "posting")), + logger.info( + app._("post_sent", "console").format( + pic.id, + str(app.config["posting"]["channel"]), caption.replace("\n", "%n"), - str(configGet("silent", "posting")), + str(app.config["posting"]["silent"]), ) ) except Exception as exp: - logWrite( - locale("post_exception", "console", locale=configGet("locale")).format( - str(exp), format_exc() - ) - ) - if configGet("error", "reports"): + logger.error(app._("post_exception", "console").format(str(exp), format_exc())) + if app.config["reports"]["error"]: await app.send_message( app.owner, - locale("post_exception", "message", locale=configGet("locale")).format( - exp, format_exc() - ), + app._("post_exception", "message").format(exp, format_exc()), ) try: rmtree( - path.join(configGet("tmp", "locations"), tmp_dir), ignore_errors=True + path.join(app.config['locations']['tmp'], tmp_dir), ignore_errors=True ) except: pass diff --git a/modules/utils.py b/modules/utils.py index 0256377..df017b9 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -1,252 +1,33 @@ -from os import kill, makedirs -from os import name as osname -from os import path, sep -from sys import exit -from traceback import print_exc -from typing import Any +import logging +from os import makedirs, path +from pathlib import Path +from typing import List, Union from zipfile import ZipFile import aiofiles -from ujson import JSONDecodeError, dumps, loads -from modules.logger import logWrite +logger = logging.getLogger(__name__) -default_config = { - "locale": "en", - "locale_log": "en", - "locale_fallback": "en", - "owner": 0, - "admins": [], - "bot": {"api_id": 0, "api_hash": "", "bot_token": ""}, - "database": { - "user": None, - "password": None, - "host": "127.0.0.1", - "port": 27017, - "name": "tgposter", - }, - "mode": {"post": True, "submit": True}, - "reports": {"sent": False, "error": True, "startup": True, "shutdown": True}, - "logging": {"size": 512, "location": "logs"}, - "locations": { - "tmp": "tmp", - "data": "data", - "cache": "cache", - "sent": "data/sent", - "queue": "data/queue", - "index": "data/index.json", - "locale": "locale", - }, - "posting": { - "channel": 0, - "silent": False, - "move_sent": False, - "use_interval": False, - "interval": "1h30m", - "page_size": 300, - "submitted_caption": { - "enabled": True, - "ignore_admins": True, - "text": "#submitted", - }, - "extensions": { - "photo": ["jpg", "png", "gif", "jpeg"], - "video": ["mp4", "avi", "mkv", "webm", "mov"], - }, - "time": [ - "08:00", - "10:00", - "12:00", - "14:00", - "16:00", - "18:00", - "20:00", - "22:00", - ], - "api": { - "address": "http://localhost:8054", - "address_external": "https://photos.domain.com", - "username": "", - "password": "", - "album": "", - }, - }, - "caption": {"enabled": False, "link": None, "text": ["sample text"]}, - "submission": { - "timeout": 30, - "file_size": 15728640, - "tmp_size": 15728640, - "allow_duplicates": False, - "send_uploaded_id": False, - "require_confirmation": {"users": True, "admins": True}, - "mime_types": [ - "image/png", - "image/gif", - "image/jpeg", - "video/mp4", - "video/quicktime", - ], - }, - "commands": ["start", "rules"], - "commands_admin": ["import", "export", "shutdown"], -} +USERS_WITH_CONTEXT: List[int] = [] -def jsonLoad(filename: str) -> Any: - """Loads arg1 as json and returns its contents""" - with open(filename, "r", encoding="utf8") as file: - try: - output = loads(file.read()) - except JSONDecodeError: - logWrite( - f"Could not load json file {filename}: file seems to be incorrect!\n{print_exc()}" - ) - raise - except FileNotFoundError: - logWrite( - f"Could not load json file {filename}: file does not seem to exist!\n{print_exc()}" - ) - raise - file.close() - return output - - -def jsonSave(contents: Any, filename: str) -> None: - """Dumps dict/list arg1 to file arg2""" - try: - with open(filename, "w", encoding="utf8") as file: - file.write( - dumps( - contents, ensure_ascii=False, indent=4, escape_forward_slashes=False - ) - ) - file.close() - except Exception as exp: - logWrite(f"Could not save json file {filename}: {exp}\n{print_exc()}") - return - - -def configSet(key: str, value, *args: str): - """Set key to a value - Args: - * key (str): The last key of the keys path. - * value (str/int/float/list/dict/None): Some needed value. - * *args (str): Path to key like: dict[args][key]. - """ - this_dict = jsonLoad("config.json") - string = "this_dict" - for arg in args: - string += f'["{arg}"]' - if type(value) in [str]: - string += f'["{key}"] = "{value}"' - else: - string += f'["{key}"] = {value}' - exec(string) - jsonSave(this_dict, "config.json") - return - - -def configGet(key: str, *args: str): - """Get value of the config key - Args: - * key (str): The last key of the keys path. - * *args (str): Path to key like: dict[args][key]. - Returns: - * any: Value of provided key - """ - this_dict = jsonLoad("config.json") - try: - this_key = this_dict - for dict_key in args: - this_key = this_key[dict_key] - this_key[key] - except KeyError: - print( - f"Could not find config key '{key}' under path {args}: falling back to default config", - flush=True, - ) - this_key = default_config - for dict_key in args: - this_key = this_key[dict_key] - configSet(key, this_key[key], *args) - return this_key[key] - - -def locale(key: str, *args: str, locale=configGet("locale")): - """Get value of locale string - Args: - * key (str): The last key of the locale's keys path. - * *args (list): Path to key like: dict[args][key]. - * locale (str): Locale to looked up in. Defaults to config's locale value. - Returns: - * any: Value of provided locale key - """ - if locale == None: - locale = configGet("locale") - - try: - this_dict = jsonLoad(f'{configGet("locale", "locations")}{sep}{locale}.json') - except FileNotFoundError: - try: - this_dict = jsonLoad( - f'{configGet("locale", "locations")}{sep}{configGet("locale")}.json' - ) - except FileNotFoundError: - try: - this_dict = jsonLoad( - f'{configGet("locale_fallback", "locations")}{sep}{configGet("locale")}.json' - ) - except: - return f'⚠️ Locale in config is invalid: could not get "{key}" in {str(args)} from locale "{locale}"' - - this_key = this_dict - for dict_key in args: - this_key = this_key[dict_key] - - try: - return this_key[key] - except KeyError: - return f'⚠️ Locale in config is invalid: could not get "{key}" in {str(args)} from locale "{locale}"' - - -async def extract_and_save(handle: ZipFile, filename: str, destpath: str): +async def extract_and_save(handle: ZipFile, filename: str, destpath: Union[str, Path]): """Extract and save file from archive - Args: - * handle (ZipFile): ZipFile handler - * filename (str): File base name - * path (str): Path where to store + ### Args: + * handle (`ZipFile`): ZipFile handler + * filename (`str`): File base name + * path (`Union[str, Path]`): Path where to store """ data = handle.read(filename) - filepath = path.join(destpath, filename) + filepath = path.join(str(destpath), filename) try: makedirs(path.dirname(filepath), exist_ok=True) async with aiofiles.open(filepath, "wb") as fd: await fd.write(data) - logWrite(f"Unzipped {filename}", debug=True) + logger.debug("Unzipped %s", filename) except IsADirectoryError: makedirs(filepath, exist_ok=True) except FileNotFoundError: pass return - - -try: - from psutil import Process -except ModuleNotFoundError: - print(locale("deps_missing", "console", locale=configGet("locale")), flush=True) - exit() - - -def killProc(pid: int) -> None: - """Kill process by its PID. Meant to be used to kill the main process of bot itself. - - ### Args: - * pid (`int`): PID of the target - """ - if osname == "posix": - from signal import SIGKILL - - kill(pid, SIGKILL) - else: - Process(pid).kill() diff --git a/plugins/callbacks/nothing.py b/plugins/callbacks/nothing.py index a2ccaa0..d0a3086 100644 --- a/plugins/callbacks/nothing.py +++ b/plugins/callbacks/nothing.py @@ -1,12 +1,12 @@ -from modules.app import app from pyrogram import filters +from pyrogram.client import Client from pyrogram.types import CallbackQuery -from classes.poster_client import PosterClient -from modules.utils import locale + +from classes.pyroclient import PyroClient -@app.on_callback_query(filters.regex("nothing")) -async def callback_query_nothing(app: PosterClient, clb: CallbackQuery): +@Client.on_callback_query(filters.regex("nothing")) +async def callback_query_nothing(app: PyroClient, clb: CallbackQuery): await clb.answer( - text=locale("nothing", "callback", locale=clb.from_user.language_code) + text=app._("nothing", "callback", locale=clb.from_user.language_code) ) diff --git a/plugins/callbacks/shutdown.py b/plugins/callbacks/shutdown.py index 595471a..197a0d8 100644 --- a/plugins/callbacks/shutdown.py +++ b/plugins/callbacks/shutdown.py @@ -1,29 +1,25 @@ -from os import getpid, makedirs, path +from os import makedirs, path from time import time -from modules.app import app + +from libbot import config_get, json_write from pyrogram import filters +from pyrogram.client import Client from pyrogram.types import CallbackQuery -from classes.poster_client import PosterClient -from modules.scheduler import scheduler -from modules.logger import logWrite -from modules.utils import configGet, jsonSave, locale + +from classes.pyroclient import PyroClient -@app.on_callback_query(filters.regex("shutdown")) -async def callback_query_nothing(app: PosterClient, clb: CallbackQuery): - if clb.from_user.id in app.admins: - pid = getpid() - logWrite(f"Shutting down bot with pid {pid}") - await clb.answer() - await clb.message.reply_text( - locale("shutdown", "message", locale=clb.from_user.language_code).format( - pid - ), - ) - scheduler.shutdown() - makedirs(configGet("cache", "locations"), exist_ok=True) - jsonSave( - {"timestamp": time()}, - path.join(configGet("cache", "locations"), "shutdown_time"), - ) - exit() +@Client.on_callback_query(filters.regex("shutdown")) +async def callback_query_nothing(app: PyroClient, clb: CallbackQuery): + if clb.from_user.id not in app.admins: + return + + await clb.answer() + + makedirs(await config_get("cache", "locations"), exist_ok=True) + await json_write( + {"timestamp": time()}, + path.join(await config_get("cache", "locations"), "shutdown_time"), + ) + + exit() diff --git a/plugins/callbacks/submission.py b/plugins/callbacks/submission.py index 33178b1..0a7439e 100644 --- a/plugins/callbacks/submission.py +++ b/plugins/callbacks/submission.py @@ -1,20 +1,28 @@ +import logging from os import path +from pathlib import Path from shutil import rmtree -from pyrogram import filters -from pyrogram.types import CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton -from classes.exceptions import SubmissionDuplicatesError, SubmissionUnavailableError -from classes.poster_client import PosterClient -from classes.user import PosterUser -from modules.app import app -from modules.logger import logWrite -from modules.utils import configGet, locale -from modules.database import col_submitted from bson import ObjectId +from libbot import config_get +from pyrogram import filters +from pyrogram.client import Client +from pyrogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup + +from classes.exceptions import ( + SubmissionDuplicatesError, + SubmissionUnavailableError, + SubmissionUnsupportedError, +) +from classes.pyroclient import PyroClient +from classes.user import PosterUser +from modules.database import col_submitted + +logger = logging.getLogger(__name__) -@app.on_callback_query(filters.regex("sub_yes_[\s\S]*")) -async def callback_query_yes(app: PosterClient, clb: CallbackQuery): +@Client.on_callback_query(filters.regex("sub_yes_[\s\S]*")) +async def callback_query_yes(app: PyroClient, clb: CallbackQuery): fullclb = str(clb.data).split("_") user_locale = clb.from_user.language_code @@ -24,44 +32,49 @@ async def callback_query_yes(app: PosterClient, clb: CallbackQuery): submission = await app.submit_photo(fullclb[2]) except SubmissionUnavailableError: await clb.answer( - text=locale("sub_msg_unavail", "callback", locale=user_locale), + text=app._("sub_msg_unavail", "callback", locale=user_locale), + show_alert=True, + ) + return + except SubmissionUnsupportedError: + await clb.answer( + text="Unsupported.", show_alert=True, ) return except SubmissionDuplicatesError as exp: await clb.answer( - text=locale("sub_duplicates_found", "callback", locale=user_locale), + text=app._("sub_duplicates_found", "callback", locale=user_locale), show_alert=True, ) await clb.message.reply_text( - locale("sub_media_duplicates_list", "message", locale=user_locale).format( + app._("sub_media_duplicates_list", "message", locale=user_locale).format( "\n • ".join(exp.duplicates) ), quote=True, ) - logWrite( - locale( + logger.info( + app._( "submission_duplicate", "console", - locale=configGet("locale_log").format( - fullclb[2], - str(exp.duplicates), - ), + locale=app.config["locale_log"], + ).format( + fullclb[2], + str(exp.duplicates), ), - debug=True, ) return if submission[0] is not None: await submission[0].reply_text( - locale("sub_yes", "message", locale=submission[0].from_user.language_code), + app._("sub_yes", "message", locale=submission[0].from_user.language_code), quote=True, ) elif db_entry is not None: - await app.send_message(db_entry["user"], locale("sub_yes", "message")) + await app.send_message(db_entry["user"], app._("sub_yes", "message")) await clb.answer( - text=locale("sub_yes", "callback", locale=user_locale).format(fullclb[2]), + text=app._("sub_yes", "callback", locale=user_locale).format(fullclb[2]), show_alert=True, ) @@ -69,7 +82,7 @@ async def callback_query_yes(app: PosterClient, clb: CallbackQuery): [ [ InlineKeyboardButton( - text=str(locale("accepted", "button", locale=user_locale)), + text=str(app._("accepted", "button", locale=user_locale)), callback_data="nothing", ) ], @@ -79,14 +92,14 @@ async def callback_query_yes(app: PosterClient, clb: CallbackQuery): else [ [ InlineKeyboardButton( - text=str(locale("accepted", "button", locale=user_locale)), + text=str(app._("accepted", "button", locale=user_locale)), callback_data="nothing", ) ] ] ) - if configGet("send_uploaded_id", "submission"): + if await config_get("send_uploaded_id", "submission"): await clb.message.edit_caption( clb.message.caption + f"\n\nID: `{submission[1]}`" ) @@ -95,79 +108,52 @@ async def callback_query_yes(app: PosterClient, clb: CallbackQuery): reply_markup=InlineKeyboardMarkup(edited_markup) ) - logWrite( - locale( + logger.info( + app._( "submission_accepted", "console", - locale=configGet("locale_log").format(fullclb[2], submission[1]), - ), - debug=True, + locale=app.config["locale_log"], + ).format(fullclb[2], submission[1]), ) - # try: - # if configGet("api_based", "mode") is True: - # media = await app.download_media(submission, file_name=configGet("tmp", "locations")+sep) - # upload = upload_pic(media) - # if upload[0] is False: - # await clb.answer(text=locale("sub_media_failed", "message", locale=user_locale), show_alert=True) - # elif len(upload[1]) > 0: - # await clb.answer(text=locale("sub_media_duplicates", "message", locale=user_locale)) - # await clb.message.reply_text(locale("sub_media_duplicates_list", "message", locale=user_locale).format("\n • ".join(upload[1]))) - # else: - # if clb.data.endswith("_caption"): - # index = jsonLoad(configGet("index", "locations")) - # index["captions"][Path(media).name] = submission.caption - # jsonSave(index, configGet("index", "locations")) - # else: - # media = await app.download_media(submission, file_name=configGet("queue", "locations")+sep) - # if clb.data.endswith("_caption"): - # index = jsonLoad(configGet("index", "locations")) - # index["captions"][Path(media).name] = submission.caption - # jsonSave(index, configGet("index", "locations")) - # except: - # await clb.answer(text=locale("sub_media_unavail", "message", locale=user_locale), show_alert=True) - # return - -@app.on_callback_query(filters.regex("sub_no_[\s\S]*")) -async def callback_query_no(app: PosterClient, clb: CallbackQuery): +@Client.on_callback_query(filters.regex("sub_no_[\s\S]*")) +async def callback_query_no(app: PyroClient, clb: CallbackQuery): fullclb = str(clb.data).split("_") user_locale = clb.from_user.language_code db_entry = col_submitted.find_one_and_delete({"_id": ObjectId(fullclb[2])}) - if db_entry["temp"]["uuid"] is not None: - if path.exists( - path.join( - configGet("data", "locations"), "submissions", db_entry["temp"]["uuid"] - ) - ): - rmtree( - path.join( - configGet("data", "locations"), - "submissions", - db_entry["temp"]["uuid"], - ), - ignore_errors=True, - ) + if ( + db_entry["temp"]["uuid"] is not None + and Path( + f"{app.config['locations']['data']}/submissions/{db_entry['temp']['uuid']}" + ).exists() + ): + rmtree( + Path( + f"{app.config['locations']['data']}/submissions/{db_entry['temp']['uuid']}" + ), + ignore_errors=True, + ) try: submission = await app.get_messages( db_entry["user"], db_entry["telegram"]["msg_id"] ) - except: + except Exception as exp: await clb.answer( - text=locale("sub_msg_unavail", "message", locale=user_locale), + text=app._("sub_msg_unavail", "message", locale=user_locale), show_alert=True, ) return await submission.reply_text( - locale("sub_no", "message", locale=submission.from_user.language_code), + app._("sub_no", "message", locale=submission.from_user.language_code), quote=True, ) await clb.answer( - text=locale("sub_no", "callback", locale=user_locale).format(fullclb[2]), + text=app._("sub_no", "callback", locale=user_locale).format(fullclb[2]), show_alert=True, ) @@ -175,7 +161,7 @@ async def callback_query_no(app: PosterClient, clb: CallbackQuery): [ [ InlineKeyboardButton( - text=str(locale("declined", "button", locale=user_locale)), + text=str(app._("declined", "button", locale=user_locale)), callback_data="nothing", ) ], @@ -185,7 +171,7 @@ async def callback_query_no(app: PosterClient, clb: CallbackQuery): else [ [ InlineKeyboardButton( - text=str(locale("declined", "button", locale=user_locale)), + text=str(app._("declined", "button", locale=user_locale)), callback_data="nothing", ) ] @@ -194,26 +180,28 @@ async def callback_query_no(app: PosterClient, clb: CallbackQuery): await clb.message.edit_reply_markup( reply_markup=InlineKeyboardMarkup(edited_markup) ) - logWrite( - locale( + logger.info( + app._( "submission_rejected", "console", - locale=configGet("locale_log").format(fullclb[2]), - ), - debug=True, + locale=app.config["locale_log"], + ).format(fullclb[2]), ) -@app.on_callback_query(filters.regex("sub_block_[\s\S]*")) -async def callback_query_block(app: PosterClient, clb: CallbackQuery): +@Client.on_callback_query(filters.regex("sub_block_[\s\S]*")) +async def callback_query_block(app: PyroClient, clb: CallbackQuery): fullclb = str(clb.data).split("_") user_locale = clb.from_user.language_code + await app.send_message( - int(fullclb[2]), locale("sub_blocked", "message", locale=configGet("locale")) + int(fullclb[2]), + app._("sub_blocked", "message"), ) PosterUser(int(fullclb[2])).block() + await clb.answer( - text=locale("sub_block", "callback", locale=user_locale).format(fullclb[2]), + text=app._("sub_block", "callback", locale=user_locale).format(fullclb[2]), show_alert=True, ) @@ -221,7 +209,7 @@ async def callback_query_block(app: PosterClient, clb: CallbackQuery): clb.message.reply_markup.inline_keyboard[0], [ InlineKeyboardButton( - text=str(locale("sub_unblock", "button", locale=user_locale)), + text=str(app._("sub_unblock", "button", locale=user_locale)), callback_data=f"sub_unblock_{fullclb[2]}", ) ], @@ -229,26 +217,26 @@ async def callback_query_block(app: PosterClient, clb: CallbackQuery): await clb.message.edit_reply_markup( reply_markup=InlineKeyboardMarkup(edited_markup) ) - logWrite( - locale( + logger.info( + app._( "user_blocked", "console", - locale=configGet("locale_log").format(fullclb[2]), - ), - debug=True, + locale=app.config["locale_log"], + ).format(fullclb[2]), ) -@app.on_callback_query(filters.regex("sub_unblock_[\s\S]*")) -async def callback_query_unblock(app: PosterClient, clb: CallbackQuery): +@Client.on_callback_query(filters.regex("sub_unblock_[\s\S]*")) +async def callback_query_unblock(app: PyroClient, clb: CallbackQuery): fullclb = str(clb.data).split("_") user_locale = clb.from_user.language_code - await app.send_message( - int(fullclb[2]), locale("sub_unblocked", "message", locale=configGet("locale")) - ) + + await app.send_message(int(fullclb[2]), app._("sub_unblocked", "message")) + PosterUser(int(fullclb[2])).unblock() + await clb.answer( - text=locale("sub_unblock", "callback", locale=user_locale).format(fullclb[2]), + text=app._("sub_unblock", "callback", locale=user_locale).format(fullclb[2]), show_alert=True, ) @@ -256,7 +244,7 @@ async def callback_query_unblock(app: PosterClient, clb: CallbackQuery): clb.message.reply_markup.inline_keyboard[0], [ InlineKeyboardButton( - text=str(locale("sub_block", "button", locale=user_locale)), + text=str(app._("sub_block", "button", locale=user_locale)), callback_data=f"sub_block_{fullclb[2]}", ) ], @@ -264,11 +252,10 @@ async def callback_query_unblock(app: PosterClient, clb: CallbackQuery): await clb.message.edit_reply_markup( reply_markup=InlineKeyboardMarkup(edited_markup) ) - logWrite( - locale( + logger.info( + app._( "user_unblocked", "console", - locale=configGet("locale_log").format(fullclb[2]), - ), - debug=True, + locale=app.config["locale_log"], + ).format(fullclb[2]), ) diff --git a/plugins/commands/general.py b/plugins/commands/general.py index e839482..8aaf34a 100644 --- a/plugins/commands/general.py +++ b/plugins/commands/general.py @@ -1,45 +1,45 @@ -from os import getpid, makedirs, path +from os import makedirs +from pathlib import Path from time import time +from libbot import json_write from pyrogram import filters -from pyrogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton +from pyrogram.client import Client +from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message -from classes.poster_client import PosterClient -from modules.app import app, users_with_context -from modules.logger import logWrite -from modules.scheduler import scheduler -from modules.utils import configGet, jsonSave, locale +from classes.pyroclient import PyroClient +from modules.utils import USERS_WITH_CONTEXT -@app.on_message(~filters.scheduled & filters.command(["shutdown"], prefixes=["", "/"])) -async def cmd_kill(app: PosterClient, msg: Message): - if msg.from_user.id in app.admins: - global users_with_context - if len(users_with_context) > 0: - await msg.reply_text( - f"There're {len(users_with_context)} unfinished users' contexts. If you turn off the bot, those will be lost. Please confirm shutdown using a button below.", - reply_markup=InlineKeyboardMarkup( - [ - [ - InlineKeyboardButton( - "Confirm shutdown", callback_data="shutdown" - ) - ] - ] - ), - ) - return - pid = getpid() - logWrite(f"Shutting down bot with pid {pid}") +@Client.on_message( + ~filters.scheduled & filters.command(["shutdown"], prefixes=["", "/"]) +) +async def cmd_kill(app: PyroClient, msg: Message): + if msg.from_user.id not in app.admins: + return + + if len(USERS_WITH_CONTEXT) > 0: await msg.reply_text( - locale("shutdown", "message", locale=msg.from_user.language_code).format( - pid + app._("shutdown_confirm", "message").format(len(USERS_WITH_CONTEXT)), + reply_markup=InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton( + app._( + "shutdown", "button", locale=msg.from_user.language_code + ), + callback_data="shutdown", + ) + ] + ] ), ) - scheduler.shutdown() - makedirs(configGet("cache", "locations"), exist_ok=True) - jsonSave( - {"timestamp": time()}, - path.join(configGet("cache", "locations"), "shutdown_time"), - ) - exit() + return + + makedirs(app.config["locations"]["cache"], exist_ok=True) + await json_write( + {"timestamp": time()}, + Path(f"{app.config['locations']['cache']}/shutdown_time"), + ) + + exit() diff --git a/plugins/commands/mode_submit.py b/plugins/commands/mode_submit.py index 016801d..f759b33 100644 --- a/plugins/commands/mode_submit.py +++ b/plugins/commands/mode_submit.py @@ -1,23 +1,24 @@ from pyrogram import filters +from pyrogram.client import Client from pyrogram.types import Message -from modules.app import app -from modules.utils import locale +from classes.pyroclient import PyroClient from classes.user import PosterUser -from classes.poster_client import PosterClient -@app.on_message(~filters.scheduled & filters.command(["start"], prefixes="/")) -async def cmd_start(app: PosterClient, msg: Message): - if PosterUser(msg.from_user.id).is_blocked() is False: - await msg.reply_text( - locale("start", "message", locale=msg.from_user.language_code) - ) +@Client.on_message(~filters.scheduled & filters.command(["start"], prefixes="/")) +async def cmd_start(app: PyroClient, msg: Message): + if PosterUser(msg.from_user.id).is_blocked(): + return + + await msg.reply_text(app._("start", "message", locale=msg.from_user.language_code)) -@app.on_message(~filters.scheduled & filters.command(["rules", "help"], prefixes="/")) -async def cmd_rules(app: PosterClient, msg: Message): - if PosterUser(msg.from_user.id).is_blocked() is False: - await msg.reply_text( - locale("rules", "message", locale=msg.from_user.language_code) - ) +@Client.on_message( + ~filters.scheduled & filters.command(["rules", "help"], prefixes="/") +) +async def cmd_rules(app: PyroClient, msg: Message): + if PosterUser(msg.from_user.id).is_blocked(): + return + + await msg.reply_text(app._("rules", "message", locale=msg.from_user.language_code)) diff --git a/plugins/commands/photos.py b/plugins/commands/photos.py index e56a798..8e9123b 100644 --- a/plugins/commands/photos.py +++ b/plugins/commands/photos.py @@ -1,214 +1,303 @@ import asyncio +import logging from glob import iglob +from io import BytesIO from os import getcwd, makedirs, path, remove +from pathlib import Path from shutil import disk_usage, rmtree from traceback import format_exc from uuid import uuid4 from zipfile import ZipFile from convopyro import listen_message +from photosapi_client.errors import UnexpectedStatus from pyrogram import filters +from pyrogram.client import Client from pyrogram.types import Message +from ujson import loads -from classes.poster_client import PosterClient -from modules.api_client import remove_pic, upload_pic -from modules.app import app, users_with_context -from modules.logger import logWrite -from modules.utils import configGet, extract_and_save, locale +from classes.pyroclient import PyroClient +from modules.api_client import ( + BodyPhotoUploadAlbumsAlbumPhotosPost, + File, + client, + photo_delete, + photo_upload, +) +from modules.utils import USERS_WITH_CONTEXT, extract_and_save + +logger = logging.getLogger(__name__) -@app.on_message(~filters.scheduled & filters.command(["import"], prefixes=["", "/"])) -async def cmd_import(app: PosterClient, msg: Message): - if msg.from_user.id in app.admins: - global users_with_context - if msg.from_user.id not in users_with_context: - users_with_context.append(msg.from_user.id) - else: - return +@Client.on_message(~filters.scheduled & filters.command(["import"], prefixes=["", "/"])) +async def cmd_import(app: PyroClient, msg: Message): + if msg.from_user.id not in app.admins: + return + + global USERS_WITH_CONTEXT + + if msg.from_user.id not in USERS_WITH_CONTEXT: + USERS_WITH_CONTEXT.append(msg.from_user.id) + else: + return + + await msg.reply_text( + app._("import_request", "message", locale=msg.from_user.language_code) + ) + + answer = await listen_message(app, msg.chat.id, timeout=600) + + USERS_WITH_CONTEXT.remove(msg.from_user.id) + + if answer is None: await msg.reply_text( - locale("import_request", "message", locale=msg.from_user.language_code) - ) - answer = await listen_message(app, msg.chat.id, timeout=600) - users_with_context.remove(msg.from_user.id) - if answer is None: - await msg.reply_text( - locale("import_ignored", "message", locale=msg.from_user.language_code), - quote=True, - ) - return - if answer.text == "/cancel": - await answer.reply_text( - locale("import_abort", "message", locale=msg.from_user.language_code) - ) - return - if answer.document is None: - await answer.reply_text( - locale( - "import_invalid_media", - "message", - locale=msg.from_user.language_code, - ), - quote=True, - ) - return - if answer.document.mime_type != "application/zip": - await answer.reply_text( - locale( - "import_invalid_mime", "message", locale=msg.from_user.language_code - ), - quote=True, - ) - return - if disk_usage(getcwd())[2] < (answer.document.file_size) * 3: - await msg.reply_text( - locale( - "import_too_big", "message", locale=msg.from_user.language_code - ).format( - answer.document.file_size // (2**30), - disk_usage(getcwd())[2] // (2**30), - ) - ) - return - tmp_dir = str(uuid4()) - logWrite( - f"Importing '{answer.document.file_name}' file {answer.document.file_size} bytes big (TMP ID {tmp_dir})" - ) - makedirs(path.join(configGet("tmp", "locations"), tmp_dir), exist_ok=True) - tmp_path = path.join(configGet("tmp", "locations"), answer.document.file_id) - downloading = await answer.reply_text( - locale("import_downloading", "message", locale=msg.from_user.language_code), - quote=True, - ) - await app.download_media(answer, file_name=tmp_path) - await downloading.edit( - locale("import_unpacking", "message", locale=msg.from_user.language_code) - ) - try: - with ZipFile(tmp_path, "r") as handle: - tasks = [ - extract_and_save( - handle, name, path.join(configGet("tmp", "locations"), tmp_dir) - ) - for name in handle.namelist() - ] - _ = await asyncio.gather(*tasks) - except Exception as exp: - logWrite( - f"Could not import '{answer.document.file_name}' due to {exp}: {format_exc}" - ) - await answer.reply_text( - locale( - "import_unpack_error", "message", locale=msg.from_user.language_code - ).format(exp, format_exc()) - ) - return - logWrite(f"Downloaded '{answer.document.file_name}' - awaiting upload") - await downloading.edit( - locale("import_uploading", "message", locale=msg.from_user.language_code) - ) - remove(tmp_path) - - for filename in iglob( - path.join(configGet("tmp", "locations"), tmp_dir) + "**/**", recursive=True - ): - if not path.isfile(filename): - continue - # upload filename - uploaded = await upload_pic(filename) - if uploaded[0] is False: - logWrite( - f"Could not upload '{filename}' from '{path.join(configGet('tmp', 'locations'), tmp_dir)}'. Duplicates: {str(uploaded[1])}", - debug=True, - ) - if len(uploaded[1]) > 0: - await msg.reply_text( - locale( - "import_upload_error_duplicate", - "message", - locale=msg.from_user.language_code, - ).format(path.basename(filename)), - disable_notification=True, - ) - else: - await msg.reply_text( - locale( - "import_upload_error_other", - "message", - locale=msg.from_user.language_code, - ).format(path.basename(filename)), - disable_notification=True, - ) - else: - logWrite( - f"Uploaded '{filename}' from '{path.join(configGet('tmp', 'locations'), tmp_dir)}' and got ID {uploaded[2]}", - debug=True, - ) - - await downloading.delete() - logWrite( - f"Removing '{path.join(configGet('tmp', 'locations'), tmp_dir)}' after uploading", - debug=True, - ) - rmtree(path.join(configGet("tmp", "locations"), tmp_dir), ignore_errors=True) - await answer.reply_text( - locale("import_finished", "message", locale=msg.from_user.language_code), + app._("import_ignored", "message", locale=msg.from_user.language_code), quote=True, ) return - -@app.on_message(~filters.scheduled & filters.command(["export"], prefixes=["", "/"])) -async def cmd_export(app: PosterClient, msg: Message): - if msg.from_user.id in app.admins: - pass - - -@app.on_message(~filters.scheduled & filters.command(["remove"], prefixes=["", "/"])) -async def cmd_remove(app: PosterClient, msg: Message): - if msg.from_user.id in app.admins: - global users_with_context - if msg.from_user.id not in users_with_context: - users_with_context.append(msg.from_user.id) - else: - return - await msg.reply_text( - locale("remove_request", "message", locale=msg.from_user.language_code) + if answer.text == "/cancel": + await answer.reply_text( + app._("import_abort", "message", locale=msg.from_user.language_code) ) - answer = await listen_message(app, msg.chat.id, timeout=600) - users_with_context.remove(msg.from_user.id) - if answer is None: + return + + if answer.document is None: + await answer.reply_text( + app._( + "import_invalid_media", + "message", + locale=msg.from_user.language_code, + ), + quote=True, + ) + return + + if answer.document.mime_type != "application/zip": + await answer.reply_text( + app._("import_invalid_mime", "message", locale=msg.from_user.language_code), + quote=True, + ) + return + + if disk_usage(getcwd())[2] < (answer.document.file_size) * 3: + await msg.reply_text( + app._( + "import_too_big", "message", locale=msg.from_user.language_code + ).format( + answer.document.file_size // (2**30), + disk_usage(getcwd())[2] // (2**30), + ) + ) + return + + tmp_dir = str(uuid4()) + + logging.info( + "Importing '%s' file %s bytes big (TMP ID %s)", + answer.document.file_name, + answer.document.file_size, + tmp_dir, + ) + + makedirs(Path(f"{app.config['locations']['tmp']}/{tmp_dir}"), exist_ok=True) + tmp_path = Path(f"{app.config['locations']['tmp']}/{answer.document.file_id}") + + downloading = await answer.reply_text( + app._("import_downloading", "message", locale=msg.from_user.language_code), + quote=True, + ) + + await app.download_media(answer, file_name=str(tmp_path)) + await downloading.edit( + app._("import_unpacking", "message", locale=msg.from_user.language_code) + ) + + try: + with ZipFile(tmp_path, "r") as handle: + tasks = [ + extract_and_save( + handle, name, Path(f"{app.config['locations']['tmp']}/{tmp_dir}") + ) + for name in handle.namelist() + ] + _ = await asyncio.gather(*tasks) + except Exception as exp: + logger.error( + "Could not import '%s' due to %s: %s", + answer.document.file_name, + exp, + format_exc(), + ) + await answer.reply_text( + app._( + "import_unpack_error", "message", locale=msg.from_user.language_code + ).format(exp, format_exc()) + ) + return + + logger.info("Downloaded '%s' - awaiting upload", answer.document.file_name) + + await downloading.edit( + app._("import_uploading", "message", locale=msg.from_user.language_code) + ) + + remove(tmp_path) + + for filename in iglob( + str(Path(f"{app.config['locations']['tmp']}/{tmp_dir}")) + "**/**", + recursive=True, + ): + if not path.isfile(filename): + continue + + with open(str(filename), "rb") as fh: + photo_bytes = BytesIO(fh.read()) + + try: + uploaded = await photo_upload( + app.config["posting"]["api"]["album"], + client=client, + multipart_data=BodyPhotoUploadAlbumsAlbumPhotosPost( + File(photo_bytes, Path(filename).name, "image/jpeg") + ), + ignore_duplicates=app.config["submission"]["allow_duplicates"], + compress=False, + caption="queue", + ) + except UnexpectedStatus as exp: + logger.error( + "Could not upload '%s' from '%s': %s", + filename, + Path(f"{app.config['locations']['tmp']}/{tmp_dir}"), + exp, + ) await msg.reply_text( - locale("remove_ignored", "message", locale=msg.from_user.language_code), - quote=True, + app._( + "import_upload_error_other", + "message", + locale=msg.from_user.language_code, + ).format(path.basename(filename)), + disable_notification=True, ) - return - if answer.text == "/cancel": - await answer.reply_text( - locale("remove_abort", "message", locale=msg.from_user.language_code) - ) - return - response = await remove_pic(answer.text) - if response: - logWrite( - f"Removed '{answer.text}' by request of user {answer.from_user.id}" - ) - await answer.reply_text( - locale( - "remove_success", "message", locale=msg.from_user.language_code - ).format(answer.text) + continue + + uploaded_dict = loads(uploaded.content.decode("utf-8")) + + if "duplicates" in uploaded_dict: + logger.warning( + "Could not upload '%s' from '%s'. Duplicates: %s", + filename, + Path(f"{app.config['locations']['tmp']}/{tmp_dir}"), + str(uploaded_dict["duplicates"]), ) + + if len(uploaded_dict["duplicates"]) > 0: + await msg.reply_text( + app._( + "import_upload_error_duplicate", + "message", + locale=msg.from_user.language_code, + ).format(path.basename(filename)), + disable_notification=True, + ) + else: + await msg.reply_text( + app._( + "import_upload_error_other", + "message", + locale=msg.from_user.language_code, + ).format(path.basename(filename)), + disable_notification=True, + ) else: - logWrite( - f"Could not remove '{answer.text}' by request of user {answer.from_user.id}" - ) - await answer.reply_text( - locale( - "remove_failure", "message", locale=msg.from_user.language_code - ).format(answer.text) + logger.info( + "Uploaded '%s' from '%s' and got ID %s", + filename, + Path(f"{app.config['locations']['tmp']}/{tmp_dir}"), + uploaded.parsed.id, ) + await downloading.delete() -@app.on_message(~filters.scheduled & filters.command(["purge"], prefixes=["", "/"])) -async def cmd_purge(app: PosterClient, msg: Message): - if msg.from_user.id in app.admins: - pass + logger.info( + "Removing '%s' after uploading", + Path(f"{app.config['locations']['tmp']}/{tmp_dir}"), + ) + rmtree(Path(f"{app.config['locations']['tmp']}/{tmp_dir}"), ignore_errors=True) + + await answer.reply_text( + app._("import_finished", "message", locale=msg.from_user.language_code), + quote=True, + ) + + return + + +@Client.on_message(~filters.scheduled & filters.command(["export"], prefixes=["", "/"])) +async def cmd_export(app: PyroClient, msg: Message): + if msg.from_user.id not in app.admins: + return + + +@Client.on_message(~filters.scheduled & filters.command(["remove"], prefixes=["", "/"])) +async def cmd_remove(app: PyroClient, msg: Message): + if msg.from_user.id not in app.admins: + return + + global USERS_WITH_CONTEXT + + if msg.from_user.id not in USERS_WITH_CONTEXT: + USERS_WITH_CONTEXT.append(msg.from_user.id) + else: + return + + await msg.reply_text( + app._("remove_request", "message", locale=msg.from_user.language_code) + ) + + answer = await listen_message(app, msg.chat.id, timeout=600) + + USERS_WITH_CONTEXT.remove(msg.from_user.id) + + if answer is None: + await msg.reply_text( + app._("remove_ignored", "message", locale=msg.from_user.language_code), + quote=True, + ) + return + + if answer.text == "/cancel": + await answer.reply_text( + app._("remove_abort", "message", locale=msg.from_user.language_code) + ) + return + + response = await photo_delete(id=answer.text, client=client) + + if response: + logger.info( + "Removed '%s' by request of user %s", answer.text, answer.from_user.id + ) + await answer.reply_text( + app._( + "remove_success", "message", locale=msg.from_user.language_code + ).format(answer.text) + ) + else: + logger.warning( + "Could not remove '%s' by request of user %s", + answer.text, + answer.from_user.id, + ) + await answer.reply_text( + app._( + "remove_failure", "message", locale=msg.from_user.language_code + ).format(answer.text) + ) + + +@Client.on_message(~filters.scheduled & filters.command(["purge"], prefixes=["", "/"])) +async def cmd_purge(app: PyroClient, msg: Message): + if msg.from_user.id not in app.admins: + return diff --git a/plugins/handlers/submission.py b/plugins/handlers/submission.py index c28c67d..3b27d25 100644 --- a/plugins/handlers/submission.py +++ b/plugins/handlers/submission.py @@ -1,32 +1,37 @@ +import logging from datetime import datetime from os import makedirs, path, sep +from pathlib import Path from traceback import format_exc from uuid import uuid4 from pyrogram import filters +from pyrogram.client import Client from pyrogram.enums.chat_action import ChatAction from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message from classes.enums.submission_types import SubmissionType -from classes.exceptions import SubmissionDuplicatesError -from classes.poster_client import PosterClient +from classes.exceptions import SubmissionDuplicatesError, SubmissionUnsupportedError +from classes.pyroclient import PyroClient from classes.user import PosterUser -from modules.app import app, users_with_context from modules.database import col_banned, col_submitted -from modules.logger import logWrite -from modules.utils import configGet, locale +from modules.utils import USERS_WITH_CONTEXT + +logger = logging.getLogger(__name__) -@app.on_message( +@Client.on_message( ~filters.scheduled & filters.private & filters.photo | filters.video | filters.animation | filters.document ) -async def get_submission(app: PosterClient, msg: Message): - global users_with_context - if msg.from_user.id in users_with_context: +async def get_submission(app: PyroClient, msg: Message): + global USERS_WITH_CONTEXT + + if msg.from_user.id in USERS_WITH_CONTEXT: return + try: if col_banned.find_one({"user": msg.from_user.id}) is not None: return @@ -39,34 +44,37 @@ async def get_submission(app: PosterClient, msg: Message): if PosterUser(msg.from_user.id).is_limited(): await msg.reply_text( - locale("sub_cooldown", "message", locale=user_locale).format( - str(configGet("timeout", "submission")) + app._("sub_cooldown", "message", locale=user_locale).format( + str(app.config["submission"]["timeout"]) ) ) return if msg.document is not None: - logWrite( - f"User {msg.from_user.id} is trying to submit a file of type '{msg.document.mime_type}' with name '{msg.document.file_name}' and size of {msg.document.file_size / 1024 / 1024} MB", - debug=True, + logger.info( + "User %s is trying to submit a file of type '%s' with name '%s' and size of %s MB", + msg.from_user.id, + msg.document.mime_type, + msg.document.file_name, + msg.document.file_size / 1024 / 1024, ) - if msg.document.mime_type not in configGet("mime_types", "submission"): + if msg.document.mime_type not in app.config["submission"]["mime_types"]: await msg.reply_text( - locale("mime_not_allowed", "message", locale=user_locale).format( - ", ".join(configGet("mime_types", "submission")) + app._("mime_not_allowed", "message", locale=user_locale).format( + ", ".join(app.config["submission"]["mime_types"]) ), quote=True, ) return - if msg.document.file_size > configGet("file_size", "submission"): + if msg.document.file_size > app.config["submission"]["file_size"]: await msg.reply_text( - locale("document_too_large", "message", locale=user_locale).format( - str(configGet("file_size", "submission") / 1024 / 1024) + app._("document_too_large", "message", locale=user_locale).format( + str(app.config["submission"]["file_size"] / 1024 / 1024) ), quote=True, ) return - if msg.document.file_size > configGet("tmp_size", "submission"): + if msg.document.file_size > app.config["submission"]["tmp_size"]: save_tmp = False contents = ( msg.document.file_id, @@ -74,36 +82,40 @@ async def get_submission(app: PosterClient, msg: Message): ) # , msg.document.file_name if msg.video is not None: - logWrite( - f"User {msg.from_user.id} is trying to submit a video with name '{msg.video.file_name}' and size of {msg.video.file_size / 1024 / 1024} MB", - debug=True, + logger.info( + "User %s is trying to submit a video with name '%s' and size of %s MB", + msg.from_user.id, + msg.video.file_name, + msg.video.file_size / 1024 / 1024, ) - if msg.video.file_size > configGet("file_size", "submission"): + if msg.video.file_size > app.config["submission"]["file_size"]: await msg.reply_text( - locale("document_too_large", "message", locale=user_locale).format( - str(configGet("file_size", "submission") / 1024 / 1024) + app._("document_too_large", "message", locale=user_locale).format( + str(app.config["submission"]["file_size"] / 1024 / 1024) ), quote=True, ) return - if msg.video.file_size > configGet("tmp_size", "submission"): + if msg.video.file_size > app.config["submission"]["tmp_size"]: save_tmp = False contents = msg.video.file_id, SubmissionType.VIDEO # , msg.video.file_name if msg.animation is not None: - logWrite( - f"User {msg.from_user.id} is trying to submit an animation with name '{msg.animation.file_name}' and size of {msg.animation.file_size / 1024 / 1024} MB", - debug=True, + logger.info( + "User %s is trying to submit an animation with name '%s' and size of %s MB", + msg.from_user.id, + msg.animation.file_name, + msg.animation.file_size / 1024 / 1024, ) - if msg.animation.file_size > configGet("file_size", "submission"): + if msg.animation.file_size > app.config["submission"]["file_size"]: await msg.reply_text( - locale("document_too_large", "message", locale=user_locale).format( - str(configGet("file_size", "submission") / 1024 / 1024) + app._("document_too_large", "message", locale=user_locale).format( + str(app.config["submission"]["file_size"] / 1024 / 1024) ), quote=True, ) return - if msg.animation.file_size > configGet("tmp_size", "submission"): + if msg.animation.file_size > app.config["submission"]["tmp_size"]: save_tmp = False contents = ( msg.animation.file_id, @@ -111,26 +123,31 @@ async def get_submission(app: PosterClient, msg: Message): ) # , msg.animation.file_name if msg.photo is not None: - logWrite( - f"User {msg.from_user.id} is trying to submit a photo with ID '{msg.photo.file_id}' and size of {msg.photo.file_size / 1024 / 1024} MB", - debug=True, + logger.info( + "User %s is trying to submit a photo with ID '%s' and size of %s MB", + msg.from_user.id, + msg.photo.file_id, + msg.photo.file_size / 1024 / 1024, ) contents = msg.photo.file_id, SubmissionType.PHOTO # , "please_generate" - if save_tmp is not None: - if contents is None: - return + if contents is None: + return + if save_tmp is not None: tmp_id = str(uuid4()) + # filename = tmp_id if contents[1] == "please_generate" else contents[1] makedirs( - path.join(configGet("data", "locations"), "submissions", tmp_id), + Path(f"{app.config['locations']['data']}/submissions/{tmp_id}"), exist_ok=True, ) downloaded = await app.download_media( msg, - path.join(configGet("data", "locations"), "submissions", tmp_id) + sep, + str(Path(f"{app.config['locations']['data']}/submissions/{tmp_id}")) + + sep, ) + inserted = col_submitted.insert_one( { "user": msg.from_user.id, @@ -144,9 +161,6 @@ async def get_submission(app: PosterClient, msg: Message): ) else: - if contents is None: - return - inserted = col_submitted.insert_one( { "user": msg.from_user.id, @@ -162,7 +176,7 @@ async def get_submission(app: PosterClient, msg: Message): buttons = [ [ InlineKeyboardButton( - text=locale("sub_yes", "button", locale=configGet("locale")), + text=app._("sub_yes", "button"), callback_data=f"sub_yes_{str(inserted.inserted_id)}", ) ] @@ -172,28 +186,20 @@ async def get_submission(app: PosterClient, msg: Message): caption = str(msg.caption) buttons[0].append( InlineKeyboardButton( - text=locale( - "sub_yes_caption", "button", locale=configGet("locale") - ), + text=app._("sub_yes_caption", "button"), callback_data=f"sub_yes_{str(inserted.inserted_id)}_caption", ) ) - buttons[0].append( - InlineKeyboardButton( - text=locale("sub_no", "button", locale=configGet("locale")), - callback_data=f"sub_no_{str(inserted.inserted_id)}", - ) - ) else: caption = "" - buttons[0].append( - InlineKeyboardButton( - text=locale("sub_no", "button", locale=configGet("locale")), - callback_data=f"sub_no_{str(inserted.inserted_id)}", - ) - ) - caption += locale("sub_by", "message", locale=locale(configGet("locale"))) + buttons[0].append( + InlineKeyboardButton( + text=app._("sub_no", "button"), + callback_data=f"sub_no_{str(inserted.inserted_id)}", + ) + ) + caption += app._("sub_by", "message") if msg.from_user.first_name is not None: caption += f" {msg.from_user.first_name}" @@ -206,22 +212,28 @@ async def get_submission(app: PosterClient, msg: Message): if ( msg.from_user.id in app.admins - and configGet("admins", "submission", "require_confirmation") is False + and app.config["submission"]["require_confirmation"]["admins"] is False ): try: submitted = await app.submit_photo(str(inserted.inserted_id)) await msg.reply_text( - locale("sub_yes_auto", "message", locale=user_locale), + app._("sub_yes_auto", "message", locale=user_locale), disable_notification=True, quote=True, ) - if configGet("send_uploaded_id", "submission"): + if app.config["submission"]["send_uploaded_id"]: caption += f"\n\nID: `{submitted[1]}`" await msg.copy(app.owner, caption=caption, disable_notification=True) return + except SubmissionUnsupportedError: + await msg.reply_text( + "Unsupported.", + quote=True, + ) + return except SubmissionDuplicatesError as exp: await msg.reply_text( - locale( + app._( "sub_media_duplicates_list", "message", locale=user_locale ).format("\n • ".join(exp.duplicates)), quote=True, @@ -232,28 +244,31 @@ async def get_submission(app: PosterClient, msg: Message): return elif ( msg.from_user.id not in app.admins - and configGet("users", "submission", "require_confirmation") is False + and app.config["submission"]["require_confirmation"]["users"] is False ): try: submitted = await app.submit_photo(str(inserted.inserted_id)) await msg.reply_text( - locale("sub_yes_auto", "message", locale=user_locale), + app._("sub_yes_auto", "message", locale=user_locale), disable_notification=True, quote=True, ) - if configGet("send_uploaded_id", "submission"): + if app.config["submission"]["send_uploaded_id"]: caption += f"\n\nID: `{submitted[1]}`" await msg.copy(app.owner, caption=caption) return + except SubmissionUnsupportedError: + await msg.reply_text("Unsupported.", quote=True) + return except SubmissionDuplicatesError as exp: await msg.reply_text( - locale("sub_dup", "message", locale=user_locale), quote=True + app._("sub_dup", "message", locale=user_locale), quote=True ) return except Exception as exp: await app.send_message( app.owner, - locale("sub_error_admin", "message").format( + app._("sub_error_admin", "message").format( msg.from_user.id, format_exc() ), ) @@ -264,7 +279,7 @@ async def get_submission(app: PosterClient, msg: Message): buttons += [ [ InlineKeyboardButton( - text=locale("sub_block", "button", locale=configGet("locale")), + text=app._("sub_block", "button"), callback_data=f"sub_block_{msg.from_user.id}", ) ] @@ -274,7 +289,7 @@ async def get_submission(app: PosterClient, msg: Message): if msg.from_user.id != app.owner: await msg.reply_text( - locale("sub_sent", "message", locale=user_locale), + app._("sub_sent", "message", locale=user_locale), disable_notification=True, quote=True, ) @@ -284,4 +299,4 @@ async def get_submission(app: PosterClient, msg: Message): ) except AttributeError: - logWrite(f"from_user in function get_submission does not seem to contain id") + logger.error("'from_user' does not seem to contain 'id'") diff --git a/plugins/remove_commands.py b/plugins/remove_commands.py new file mode 100644 index 0000000..939dab5 --- /dev/null +++ b/plugins/remove_commands.py @@ -0,0 +1,13 @@ +from pyrogram import filters +from pyrogram.client import Client +from pyrogram.types import Message + +from classes.pyroclient import PyroClient + + +@Client.on_message( + ~filters.scheduled & filters.private & filters.command(["remove_commands"], prefixes=["/"]) # type: ignore +) +async def command_remove_commands(app: PyroClient, msg: Message): + await msg.reply_text("Okay.") + await app.remove_commands(command_sets=await app.collect_commands()) diff --git a/poster.py b/poster.py deleted file mode 100644 index b570fba..0000000 --- a/poster.py +++ /dev/null @@ -1,266 +0,0 @@ -from datetime import datetime -from os import getpid, path -from sys import exit -from time import time -from traceback import format_exc -from modules.api_client import authorize - -from modules.cli import * -from modules.http_client import http_session -from modules.logger import logWrite -from modules.scheduler import scheduler -from modules.utils import configGet, jsonLoad, jsonSave, locale - -# Import =================================================================================================================================== -try: - from dateutil.relativedelta import relativedelta - from pyrogram.errors import bad_request_400 - from pyrogram.sync import idle - - from modules.app import app -except ModuleNotFoundError: - print(locale("deps_missing", "console", locale=configGet("locale")), flush=True) - exit() -# =========================================================================================================================================== - - -pid = getpid() -version = 0.1 - -# Work in progress -# def check_forwards(app): - -# try: - -# index = jsonLoad(configGet("index", "locations")) -# channel = app.get_chat(configGet("channel", "posting")) - -# peer = app.resolve_peer(configGet("channel", "posting")) -# print(peer, flush=True) - -# posts_list = [i for i in range(index["last_id"]-100,index["last_id"])] -# last_posts = app.get_messages(configGet("channel", "posting"), message_ids=posts_list) - -# for post in last_posts: -# post_forwards = GetMessagePublicForwards(channel=peer, msg_id=post.id, offset_peer=peer, offset_rate=0, offset_id=0, limit=100) -# print(post_forwards, flush=True) -# for forward in post_forwards: -# print(forward, flush=True) - -# except Exception as exp: - -# logWrite("Could not get last posts forwards due to {0} with traceback {1}".format(str(exp), traceback.format_exc()), debug=True) - -# if configGet("error", "reports"): -# app.send_message(configGet("admin"), traceback.format_exc()) - -# pass - - -# Work in progress -# @app.on_message(~ filters.scheduled & filters.command(["forwards"], prefixes="/")) -# def cmd_forwards(app, msg): -# check_forwards(app) - - -from plugins.callbacks.shutdown import * - -# Imports ================================================================================================================================== -from plugins.commands.general import * - -if configGet("submit", "mode"): - from plugins.callbacks.nothing import * - from plugins.callbacks.submission import * - from plugins.commands.mode_submit import * - from plugins.handlers.submission import * - -if configGet("post", "mode"): - from plugins.commands.photos import * - -# if configGet("api_based", "mode"): -# from modules.api_client import authorize -# =========================================================================================================================================== - -# Work in progress -# Handle new forwards -# @app.on_raw_update() -# def fwd_got(app, update, users, chats): -# if isinstance(update, UpdateChannelMessageForwards): -# logWrite(f'Forward count increased to {update["forwards"]} on post {update["id"]} in channel {update["channel_id"]}') -# logWrite(str(users), debug=True) -# logWrite(str(chats), debug=True) -# # else: -# # logWrite(f"Got raw update of type {type(update)} with contents {update}", debug=True) - -# async def main(): - -# await app.start() - -# logWrite(locale("startup", "console", locale=configGet("locale")).format(str(pid))) - -# if configGet("startup", "reports"): -# await app.send_message(configGet("admin"), locale("startup", "message", locale=configGet("locale")).format(str(pid))) - -# if configGet("post", "mode"): -# scheduler.start() - -# if configGet("api_based", "mode"): -# token = authorize() -# if len(get(f'{configGet("address", "posting", "api")}/albums?q={configGet("queue", "posting", "api", "albums")}', headers={"Authorization": f"Bearer {token}"}).json()["results"]) == 0: -# post(f'{configGet("address", "posting", "api")}/albums?name={configGet("queue", "posting", "api", "albums")}&title={configGet("queue", "posting", "api", "albums")}', headers={"Authorization": f"Bearer {token}"}) - -# await idle() - -# await app.send_message(configGet("admin"), locale("shutdown", "message", locale=configGet("locale")).format(str(pid))) -# logWrite(locale("shutdown", "console", locale=configGet("locale")).format(str(pid))) - -# killProc(pid) - -# if __name__ == "__main__": -# if find_spec("uvloop") is not None: -# uvloop.install() -# asyncio.run(main()) - - -async def main(): - logWrite(locale("startup", "console").format(str(pid))) - - await app.start() - - if configGet("startup", "reports"): - try: - if path.exists(path.join(configGet("cache", "locations"), "shutdown_time")): - downtime = relativedelta( - datetime.now(), - datetime.fromtimestamp( - jsonLoad( - path.join(configGet("cache", "locations"), "shutdown_time") - )["timestamp"] - ), - ) - if downtime.days >= 1: - await app.send_message( - configGet("owner"), - locale( - "startup_downtime_days", - "message", - ).format(pid, downtime.days), - ) - elif downtime.hours >= 1: - await app.send_message( - configGet("owner"), - locale( - "startup_downtime_hours", - "message", - ).format(pid, downtime.hours), - ) - else: - await app.send_message( - configGet("owner"), - locale( - "startup_downtime_minutes", - "message", - locale=configGet("locale"), - ).format(pid, downtime.minutes), - ) - else: - await app.send_message( - configGet("owner"), - locale("startup", "message").format(pid), - ) - except bad_request_400.PeerIdInvalid: - logWrite( - f"Could not send startup message to bot owner. Perhaps user has not started the bot yet." - ) - - if configGet("update", "reports"): - try: - check_update = await http_session.get( - "https://git.end-play.xyz/api/v1/repos/profitroll/TelegramPoster/releases?page=1&limit=1" - ) - response = await check_update.json() - if len(response) == 0: - raise ValueError("No bot releases on git found.") - if float(response[0]["tag_name"].replace("v", "")) > version: - logWrite( - f'New version {response[0]["tag_name"].replace("v", "")} found (current {version})' - ) - await app.send_message( - configGet("owner"), - locale( - "update_available", - "message", - ).format( - response[0]["tag_name"], - response[0]["html_url"], - response[0]["body"], - ), - ) - else: - logWrite(f"No updates found, bot is up to date.") - except bad_request_400.PeerIdInvalid: - logWrite( - f"Could not send startup message to bot owner. Perhaps user has not started the bot yet." - ) - except Exception as exp: - logWrite(f"Update check failed due to {exp}: {format_exc()}") - - if configGet("post", "mode"): - scheduler.start() - - try: - token = await authorize() - - if ( - len( - ( - await ( - await http_session.get( - f'{configGet("address", "posting", "api")}/albums?q={configGet("album", "posting", "api")}', - headers={"Authorization": f"Bearer {token}"}, - ) - ).json() - )["results"] - ) - == 0 - ): - logWrite("Media album does not exist on API server. Trying to create it...") - try: - await http_session.post( - f'{configGet("address", "posting", "api")}/albums?name={configGet("album", "posting", "api")}&title={configGet("album", "posting", "api")}', - headers={"Authorization": f"Bearer {token}"}, - ) - logWrite("Created media album on API server.") - except Exception as exp: - logWrite( - f"Could not create media album on API server due to {exp}: {format_exc()}" - ) - except Exception as exp: - logWrite(f"Could not check if API album exists due to {exp}: {format_exc()}") - - await idle() - - try: - await app.send_message( - configGet("owner"), - locale("shutdown", "message").format(pid), - ) - except bad_request_400.PeerIdInvalid: - logWrite( - f"Could not send shutdown message to bot owner. Perhaps user has not started the bot yet." - ) - - makedirs(configGet("cache", "locations"), exist_ok=True) - jsonSave( - {"timestamp": time()}, - path.join(configGet("cache", "locations"), "shutdown_time"), - ) - - logWrite(locale("shutdown", "console").format(str(pid))) - - scheduler.shutdown() - - -if __name__ == "__main__": - loop = asyncio.get_event_loop() - loop.run_until_complete(main()) diff --git a/requirements-optional.txt b/requirements-optional.txt deleted file mode 100644 index e69de29..0000000 diff --git a/requirements.txt b/requirements.txt index 20ce50a..d6be978 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,17 @@ -python_dateutil==2.8.2 -apscheduler==3.10.1 -pytimeparse==1.1.8 -convopyro==0.5 -pyrogram==2.0.106 aiofiles~=23.1.0 -tgcrypto==1.2.5 aiohttp~=3.8.4 -psutil==5.9.5 -pymongo==4.3.3 -pillow~=9.5.0 -ujson==5.8.0 \ No newline at end of file +apscheduler~=3.10.1 +black~=23.3.0 +convopyro==0.5 +pillow~=9.4.0 +psutil~=5.9.4 +pymongo~=4.3.3 +pyrogram==2.0.106 +python_dateutil==2.8.2 +pytimeparse~=1.1.8 +tgcrypto==1.2.5 +ujson==5.8.0 +uvloop==0.17.0 +--extra-index-url https://git.end-play.xyz/api/packages/profitroll/pypi/simple +libbot[speed,pyrogram]==1.0 +photosapi_client==0.2.0 \ No newline at end of file -- 2.39.2 From 54af4399a62bb2886c2df841637e3b8107104fdf Mon Sep 17 00:00:00 2001 From: profitroll Date: Wed, 21 Jun 2023 19:28:17 +0200 Subject: [PATCH 02/16] Complete overhaul seems to be done --- classes/pyroclient.py | 24 +++++++++++++++++++++ modules/api_client.py | 10 ++++++--- modules/scheduler.py | 21 ------------------- modules/sender.py | 49 +++++++++++++++++++++++++++---------------- 4 files changed, 62 insertions(+), 42 deletions(-) diff --git a/classes/pyroclient.py b/classes/pyroclient.py index 2ca2662..0d03584 100644 --- a/classes/pyroclient.py +++ b/classes/pyroclient.py @@ -32,6 +32,7 @@ from pyrogram.types import ( BotCommandScopeDefault, Message, ) +from pytimeparse.timeparse import timeparse from ujson import dumps, loads from classes.commandset import CommandSet @@ -50,6 +51,7 @@ from modules.api_client import ( from modules.database import col_submitted from modules.http_client import http_session from modules.scheduler import scheduler +from modules.sender import send_content logger = logging.getLogger(__name__) @@ -89,6 +91,8 @@ class PyroClient(Client): self.in_all_locales = self.bot_locale.in_all_locales self.in_every_locale = self.bot_locale.in_every_locale + self.sender_session = ClientSession() + async def start(self): await super().start() @@ -189,6 +193,25 @@ class PyroClient(Client): kwargs={"command_sets": await self.collect_commands()}, ) + if self.config["mode"]["post"]: + if self.config["posting"]["use_interval"]: + scheduler.add_job( + send_content, + "interval", + seconds=timeparse(self.config["posting"]["interval"]), + args=[self, self.sender_session], + ) + else: + for entry in self.config["posting"]["time"]: + dt_obj = datetime.strptime(entry, "%H:%M") + scheduler.add_job( + send_content, + "cron", + hour=dt_obj.hour, + minute=dt_obj.minute, + args=[self, self.sender_session], + ) + scheduler.start() except BadRequest: logger.warning("Unable to send message to report chat.") @@ -208,6 +231,7 @@ class PyroClient(Client): except BadRequest: logger.warning("Unable to send message to report chat.") await http_session.close() + await self.sender_session.close() await super().stop() logger.warning("Bot stopped with PID %s.", getpid()) diff --git a/modules/api_client.py b/modules/api_client.py index fac6487..515f28d 100644 --- a/modules/api_client.py +++ b/modules/api_client.py @@ -3,8 +3,10 @@ import logging from base64 import b64decode, b64encode from os import makedirs, path, sep from pathlib import Path +from typing import Union import aiofiles +from aiohttp import ClientSession from libbot import config_get, i18n, sync from photosapi_client import AuthenticatedClient, Client from photosapi_client.api.default.album_create_albums_post import ( @@ -48,15 +50,17 @@ from modules.http_client import http_session logger = logging.getLogger(__name__) -async def authorize() -> str: +async def authorize(custom_session: Union[ClientSession, None] = None) -> str: makedirs(await config_get("cache", "locations"), exist_ok=True) + session = http_session if custom_session is None else custom_session + if path.exists(await config_get("cache", "locations") + sep + "api_access") is True: async with aiofiles.open( await config_get("cache", "locations") + sep + "api_access", "rb" ) as file: token = b64decode(await file.read()).decode("utf-8") if ( - await http_session.get( + await session.get( await config_get("address", "posting", "api") + "/users/me/", headers={"Authorization": f"Bearer {token}"}, ) @@ -68,7 +72,7 @@ async def authorize() -> str: "username": await config_get("username", "posting", "api"), "password": await config_get("password", "posting", "api"), } - response = await http_session.post( + response = await session.post( await config_get("address", "posting", "api") + "/token", data=payload ) if not response.ok: diff --git a/modules/scheduler.py b/modules/scheduler.py index b01330d..a5eb79d 100644 --- a/modules/scheduler.py +++ b/modules/scheduler.py @@ -1,24 +1,3 @@ -from datetime import datetime - from apscheduler.schedulers.asyncio import AsyncIOScheduler -from libbot import sync -from pytimeparse.timeparse import timeparse - -# from modules.sender import send_content scheduler = AsyncIOScheduler() - -# if sync.config_get("post", "mode"): -# if sync.config_get("use_interval", "posting"): -# scheduler.add_job( -# send_content, -# "interval", -# seconds=timeparse(sync.config_get("interval", "posting")), -# args=[app], -# ) -# else: -# for entry in sync.config_get("time", "posting"): -# dt_obj = datetime.strptime(entry, "%H:%M") -# scheduler.add_job( -# send_content, "cron", hour=dt_obj.hour, minute=dt_obj.minute, args=[app] -# ) diff --git a/modules/sender.py b/modules/sender.py index b4f2845..82bcff1 100644 --- a/modules/sender.py +++ b/modules/sender.py @@ -7,20 +7,22 @@ from traceback import format_exc from uuid import uuid4 from PIL import Image import aiofiles +from aiohttp import ClientSession -from classes.pyroclient import PyroClient +from pyrogram.client import Client -from modules.api_client import authorize, http_session, photo_patch, photo_find, client +from modules.api_client import authorize, photo_patch, photo_find, client from modules.database import col_sent, col_submitted from photosapi_client.errors import UnexpectedStatus + logger = logging.getLogger(__name__) -async def send_content(app: PyroClient) -> None: +async def send_content(app: Client, http_session: ClientSession) -> None: try: try: - token = await authorize() + token = await authorize(http_session) except ValueError: await app.send_message( app.owner, @@ -29,7 +31,16 @@ async def send_content(app: PyroClient) -> None: return try: - pic = choice((await photo_find(album=app.config["posting"]["api"]["album"], caption="queue", page_size=app.config["posting"]["page_size"], client=client)).results) + pic = choice( + ( + await photo_find( + album=app.config["posting"]["api"]["album"], + caption="queue", + page_size=app.config["posting"]["page_size"], + client=client, + ) + ).results + ) except (KeyError, AttributeError, TypeError): logger.info(app._("post_empty", "console")) if app.config["reports"]["error"]: @@ -67,42 +78,42 @@ async def send_content(app: PyroClient) -> None: tmp_dir = str(uuid4()) - makedirs(path.join(app.config['locations']['tmp'], tmp_dir), exist_ok=True) + makedirs(path.join(app.config["locations"]["tmp"], tmp_dir), exist_ok=True) tmp_path = path.join(tmp_dir, pic.filename) async with aiofiles.open( - path.join(app.config['locations']['tmp'], tmp_path), "wb" + path.join(app.config["locations"]["tmp"], tmp_path), "wb" ) as out_file: await out_file.write(await response.read()) logger.info( - f'Candidate {pic.filename} ({pic.id}) is {path.getsize(path.join(app.config['locations']['tmp'], tmp_path))} bytes big', + f"Candidate {pic.filename} ({pic.id}) is {path.getsize(path.join(app.config['locations']['tmp'], tmp_path))} bytes big", ) - if path.getsize(path.join(app.config['locations']['tmp'], tmp_path)) > 5242880: - image = Image.open(path.join(app.config['locations']['tmp'], tmp_path)) + if path.getsize(path.join(app.config["locations"]["tmp"], tmp_path)) > 5242880: + image = Image.open(path.join(app.config["locations"]["tmp"], tmp_path)) width, height = image.size image = image.resize((int(width / 2), int(height / 2)), Image.ANTIALIAS) if tmp_path.lower().endswith(".jpeg") or tmp_path.lower().endswith(".jpg"): image.save( - path.join(app.config['locations']['tmp'], tmp_path), + path.join(app.config["locations"]["tmp"], tmp_path), "JPEG", optimize=True, quality=50, ) elif tmp_path.lower().endswith(".png"): image.save( - path.join(app.config['locations']['tmp'], tmp_path), + path.join(app.config["locations"]["tmp"], tmp_path), "PNG", optimize=True, compress_level=8, ) image.close() - if path.getsize(path.join(app.config['locations']['tmp'], tmp_path)) > 5242880: + if path.getsize(path.join(app.config["locations"]["tmp"], tmp_path)) > 5242880: rmtree( - path.join(app.config['locations']['tmp'], tmp_dir), ignore_errors=True + path.join(app.config["locations"]["tmp"], tmp_dir), ignore_errors=True ) raise BytesWarning @@ -120,7 +131,9 @@ async def send_content(app: PyroClient) -> None: and app.config["posting"]["submitted_caption"]["enabled"] and ( (submitted["user"] not in app.admins) - or (app.config["posting"]["submitted_caption"]["ignore_admins"] is False) + or ( + app.config["posting"]["submitted_caption"]["ignore_admins"] is False + ) ) ): caption = ( @@ -140,7 +153,7 @@ async def send_content(app: PyroClient) -> None: try: sent = await app.send_photo( app.config["posting"]["channel"], - path.join(app.config['locations']['tmp'], tmp_path), + path.join(app.config["locations"]["tmp"], tmp_path), caption=caption, disable_notification=app.config["posting"]["silent"], ) @@ -168,7 +181,7 @@ async def send_content(app: PyroClient) -> None: await photo_patch(id=pic.id, client=client, caption="sent") - rmtree(path.join(app.config['locations']['tmp'], tmp_dir), ignore_errors=True) + rmtree(path.join(app.config["locations"]["tmp"], tmp_dir), ignore_errors=True) logger.info( app._("post_sent", "console").format( @@ -188,7 +201,7 @@ async def send_content(app: PyroClient) -> None: ) try: rmtree( - path.join(app.config['locations']['tmp'], tmp_dir), ignore_errors=True + path.join(app.config["locations"]["tmp"], tmp_dir), ignore_errors=True ) except: pass -- 2.39.2 From 7825d7ded300a69606b90622dfc8acafcbe96320 Mon Sep 17 00:00:00 2001 From: profitroll Date: Wed, 21 Jun 2023 21:43:23 +0200 Subject: [PATCH 03/16] Fixed missing IndexError handling --- modules/sender.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/sender.py b/modules/sender.py index 82bcff1..cdab5b7 100644 --- a/modules/sender.py +++ b/modules/sender.py @@ -41,7 +41,7 @@ async def send_content(app: Client, http_session: ClientSession) -> None: ) ).results ) - except (KeyError, AttributeError, TypeError): + except (KeyError, AttributeError, TypeError, IndexError): logger.info(app._("post_empty", "console")) if app.config["reports"]["error"]: await app.send_message( -- 2.39.2 From 592b83377b662b220e130ed645fe41962178ae12 Mon Sep 17 00:00:00 2001 From: profitroll Date: Wed, 21 Jun 2023 22:03:52 +0200 Subject: [PATCH 04/16] Sorted imports --- main.py | 5 ++--- modules/sender.py | 11 +++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/main.py b/main.py index 51629d7..2c25b5e 100644 --- a/main.py +++ b/main.py @@ -2,11 +2,10 @@ import contextlib import logging from os import getpid -from classes.pyroclient import PyroClient -from modules.scheduler import scheduler - from convopyro import Conversation +from classes.pyroclient import PyroClient +from modules.scheduler import scheduler logging.basicConfig( level=logging.INFO, diff --git a/modules/sender.py b/modules/sender.py index cdab5b7..3456446 100644 --- a/modules/sender.py +++ b/modules/sender.py @@ -1,21 +1,20 @@ -from datetime import datetime import logging +from datetime import datetime from os import makedirs, path from random import choice from shutil import rmtree from traceback import format_exc from uuid import uuid4 -from PIL import Image + import aiofiles from aiohttp import ClientSession - +from photosapi_client.errors import UnexpectedStatus +from PIL import Image from pyrogram.client import Client -from modules.api_client import authorize, photo_patch, photo_find, client +from modules.api_client import authorize, client, photo_find, photo_patch from modules.database import col_sent, col_submitted -from photosapi_client.errors import UnexpectedStatus - logger = logging.getLogger(__name__) -- 2.39.2 From 76855cd472c47fdfdaf5d8c67f51588be6665ad0 Mon Sep 17 00:00:00 2001 From: profitroll Date: Thu, 22 Jun 2023 08:41:42 +0000 Subject: [PATCH 05/16] Mime type unsupported message added --- plugins/callbacks/submission.py | 4 +++- plugins/handlers/submission.py | 10 ++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/plugins/callbacks/submission.py b/plugins/callbacks/submission.py index 0a7439e..f6a68a9 100644 --- a/plugins/callbacks/submission.py +++ b/plugins/callbacks/submission.py @@ -38,7 +38,9 @@ async def callback_query_yes(app: PyroClient, clb: CallbackQuery): return except SubmissionUnsupportedError: await clb.answer( - text="Unsupported.", + text=app._("mime_not_allowed", "message", locale=user_locale).format( + ", ".join(app.config["submission"]["mime_types"]), quote=True + ), show_alert=True, ) return diff --git a/plugins/handlers/submission.py b/plugins/handlers/submission.py index 3b27d25..b184657 100644 --- a/plugins/handlers/submission.py +++ b/plugins/handlers/submission.py @@ -227,7 +227,9 @@ async def get_submission(app: PyroClient, msg: Message): return except SubmissionUnsupportedError: await msg.reply_text( - "Unsupported.", + app._("mime_not_allowed", "message", locale=user_locale).format( + ", ".join(app.config["submission"]["mime_types"]), quote=True + ), quote=True, ) return @@ -258,7 +260,11 @@ async def get_submission(app: PyroClient, msg: Message): await msg.copy(app.owner, caption=caption) return except SubmissionUnsupportedError: - await msg.reply_text("Unsupported.", quote=True) + await msg.reply_text( + app._("mime_not_allowed", "message", locale=user_locale).format( + ", ".join(app.config["submission"]["mime_types"]), quote=True + ) + ) return except SubmissionDuplicatesError as exp: await msg.reply_text( -- 2.39.2 From 070acb474d5aae04fd7ab4b6aacee4ab4332ec42 Mon Sep 17 00:00:00 2001 From: profitroll Date: Thu, 22 Jun 2023 14:56:43 +0200 Subject: [PATCH 06/16] Bump PhotosAPI Client to 0.4.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d6be978..ad7eab8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,4 +14,4 @@ ujson==5.8.0 uvloop==0.17.0 --extra-index-url https://git.end-play.xyz/api/packages/profitroll/pypi/simple libbot[speed,pyrogram]==1.0 -photosapi_client==0.2.0 \ No newline at end of file +photosapi_client==0.4.0 \ No newline at end of file -- 2.39.2 From f29c1e7ae6b8db3c36c2206f1169fb5919fa3fad Mon Sep 17 00:00:00 2001 From: profitroll Date: Thu, 22 Jun 2023 15:17:44 +0200 Subject: [PATCH 07/16] WIP: Video support --- classes/pyroclient.py | 64 ++++++++++++++----- config_example.json | 5 ++ modules/api_client.py | 14 ++++- modules/sender.py | 111 ++++++++++++++++++++++----------- plugins/commands/photos.py | 4 +- plugins/handlers/submission.py | 2 +- 6 files changed, 144 insertions(+), 56 deletions(-) diff --git a/classes/pyroclient.py b/classes/pyroclient.py index 0d03584..38b900c 100644 --- a/classes/pyroclient.py +++ b/classes/pyroclient.py @@ -8,11 +8,13 @@ from shutil import rmtree from time import time from traceback import format_exc from typing import List, Tuple, Union +import aiofiles import pyrogram from aiohttp import ClientSession from bson import ObjectId from dateutil.relativedelta import relativedelta +from classes.enums.submission_types import SubmissionType from libbot import json_read, json_write from libbot.i18n import BotLocale from libbot.i18n.sync import _ @@ -43,10 +45,14 @@ from classes.exceptions import ( ) from classes.pyrocommand import PyroCommand from modules.api_client import ( - BodyPhotoUploadAlbumsAlbumPhotosPost, + BodyPhotoUpload, + BodyVideoUpload, File, + Photo, + Video, client, photo_upload, + video_upload, ) from modules.database import col_submitted from modules.http_client import http_session @@ -398,7 +404,7 @@ class PyroClient(Client): language_code=command_set.language_code, ) - async def submit_photo( + async def submit_media( self, id: str ) -> Tuple[Union[Message, None], Union[str, None]]: db_entry = col_submitted.find_one({"_id": ObjectId(id)}) @@ -431,24 +437,47 @@ class PyroClient(Client): db_entry["user"], db_entry["telegram"]["msg_id"] ) - with open(str(filepath), "rb") as fh: - photo_bytes = BytesIO(fh.read()) + async with aiofiles.open(str(filepath), "rb") as fh: + media_bytes = BytesIO(await fh.read()) try: - response = await photo_upload( - self.config["posting"]["api"]["album"], - client=client, - multipart_data=BodyPhotoUploadAlbumsAlbumPhotosPost( - File(photo_bytes, filepath.name, "image/jpeg") - ), - ignore_duplicates=self.config["submission"]["allow_duplicates"], - compress=False, - caption="queue", - ) + if db_entry["type"] == SubmissionType.PHOTO.value: + response = await photo_upload( + self.config["posting"]["api"]["album"], + client=client, + multipart_data=BodyPhotoUpload( + File(media_bytes, filepath.name, "image/jpeg") + ), + ignore_duplicates=self.config["submission"]["allow_duplicates"], + compress=False, + caption="queue", + ) + elif db_entry["type"] == SubmissionType.VIDEO.value: + response = await video_upload( + self.config["posting"]["api"]["album"], + client=client, + multipart_data=BodyVideoUpload( + File(media_bytes, filepath.name, "video/*") + ), + caption="queue", + ) + elif db_entry["type"] == SubmissionType.ANIMATION.value: + response = await video_upload( + self.config["posting"]["api"]["album"], + client=client, + multipart_data=BodyVideoUpload( + File(media_bytes, filepath.name, "video/*") + ), + caption="queue", + ) except UnexpectedStatus: raise SubmissionUnsupportedError(str(filepath)) - response_dict = loads(response.content.decode("utf-8")) + response_dict = ( + {} + if not hasattr(response, "content") + else loads(response.content.decode("utf-8")) + ) if "duplicates" in response_dict and len(response_dict["duplicates"]) > 0: duplicates = [] @@ -480,7 +509,10 @@ class PyroClient(Client): except (FileNotFoundError, NotADirectoryError): logger.error("Could not delete '%s' on submission accepted", filepath) - return submission, response.parsed.id + return ( + submission, + response.id if not hasattr(response, "parsed") else response.parsed.id, + ) async def ban_user(self, id: int) -> None: pass diff --git a/config_example.json b/config_example.json index 8c9fded..0d0f248 100644 --- a/config_example.json +++ b/config_example.json @@ -56,6 +56,11 @@ "ignore_admins": true, "text": "#submitted" }, + "types": { + "photo": true, + "video": false, + "animation": false + }, "extensions": { "photo": [ "jpg", diff --git a/modules/api_client.py b/modules/api_client.py index 515f28d..8192499 100644 --- a/modules/api_client.py +++ b/modules/api_client.py @@ -35,14 +35,26 @@ from photosapi_client.api.default.user_me_users_me_get import sync as user_me from photosapi_client.api.default.video_find_albums_album_videos_get import ( asyncio as video_find, ) +from photosapi_client.api.default.video_get_videos_id_get import asyncio as video_get +from photosapi_client.api.default.video_patch_videos_id_patch import ( + asyncio as video_patch, +) +from photosapi_client.api.default.video_upload_albums_album_videos_post import ( + asyncio as video_upload, +) from photosapi_client.models.body_login_for_access_token_token_post import ( BodyLoginForAccessTokenTokenPost, ) from photosapi_client.models.body_photo_upload_albums_album_photos_post import ( - BodyPhotoUploadAlbumsAlbumPhotosPost, + BodyPhotoUploadAlbumsAlbumPhotosPost as BodyPhotoUpload, +) +from photosapi_client.models.body_video_upload_albums_album_videos_post import ( + BodyVideoUploadAlbumsAlbumVideosPost as BodyVideoUpload, ) from photosapi_client.models.http_validation_error import HTTPValidationError +from photosapi_client.models.photo import Photo from photosapi_client.models.token import Token +from photosapi_client.models.video import Video from photosapi_client.types import File from modules.http_client import http_session diff --git a/modules/sender.py b/modules/sender.py index 3456446..ca1db19 100644 --- a/modules/sender.py +++ b/modules/sender.py @@ -3,7 +3,7 @@ from datetime import datetime from os import makedirs, path from random import choice from shutil import rmtree -from traceback import format_exc +from traceback import format_exc, print_exc from uuid import uuid4 import aiofiles @@ -12,7 +12,16 @@ from photosapi_client.errors import UnexpectedStatus from PIL import Image from pyrogram.client import Client -from modules.api_client import authorize, client, photo_find, photo_patch +from modules.api_client import ( + authorize, + client, + photo_find, + photo_get, + photo_patch, + video_find, + video_get, + video_patch, +) from modules.database import col_sent, col_submitted logger = logging.getLogger(__name__) @@ -30,9 +39,19 @@ async def send_content(app: Client, http_session: ClientSession) -> None: return try: - pic = choice( + funcs = [] + if app.config["posting"]["types"]["photo"]: + funcs.append(photo_find) + if ( + app.config["posting"]["types"]["video"] + or app.config["posting"]["types"]["animation"] + ): + funcs.append(video_find) + func = choice(funcs) + + media = choice( ( - await photo_find( + await func( album=app.config["posting"]["api"]["album"], caption="queue", page_size=app.config["posting"]["page_size"], @@ -56,41 +75,48 @@ async def send_content(app: Client, http_session: ClientSession) -> None: ) return - response = await http_session.get( - f"{app.config['posting']['api']['address']}/photos/{pic.id}", - headers={"Authorization": f"Bearer {token}"}, - ) - - if response.status != 200: + try: + if func is photo_find: + response = await photo_get(id=media.id, client=client) + else: + response = await video_get(id=media.id, client=client) + except Exception as exp: + print_exc() logger.warning( - app._("post_invalid_pic", "console").format( - response.status, str(await response.json()) - ) + "Media is invalid: %s", + exp + # app._("post_invalid_pic", "console").format( + # response.status, str(await response.json()) + # ) ) if app.config["reports"]["error"]: - await app.send_message( - app.owner, - app._("post_invalid_pic", "message").format( - response.status, await response.json() - ), - ) + await app.send_message(app.owner, f"Media is invalid: {exp}") + return + # await app.send_message( + # app.owner, + # app._("post_invalid_pic", "message").format( + # response.status, await response.json() + # ), + # ) tmp_dir = str(uuid4()) makedirs(path.join(app.config["locations"]["tmp"], tmp_dir), exist_ok=True) - tmp_path = path.join(tmp_dir, pic.filename) + tmp_path = path.join(tmp_dir, media.filename) async with aiofiles.open( path.join(app.config["locations"]["tmp"], tmp_path), "wb" ) as out_file: - await out_file.write(await response.read()) + await out_file.write(response.payload.read()) logger.info( - f"Candidate {pic.filename} ({pic.id}) is {path.getsize(path.join(app.config['locations']['tmp'], tmp_path))} bytes big", + f"Candidate {media.filename} ({media.id}) is {path.getsize(path.join(app.config['locations']['tmp'], tmp_path))} bytes big", ) - if path.getsize(path.join(app.config["locations"]["tmp"], tmp_path)) > 5242880: + if ( + path.getsize(path.join(app.config["locations"]["tmp"], tmp_path)) > 5242880 + ) and func is photo_find: image = Image.open(path.join(app.config["locations"]["tmp"], tmp_path)) width, height = image.size image = image.resize((int(width / 2), int(height / 2)), Image.ANTIALIAS) @@ -110,7 +136,9 @@ async def send_content(app: Client, http_session: ClientSession) -> None: ) image.close() - if path.getsize(path.join(app.config["locations"]["tmp"], tmp_path)) > 5242880: + if ( + path.getsize(path.join(app.config["locations"]["tmp"], tmp_path)) > 5242880 + ) and func is photo_find: rmtree( path.join(app.config["locations"]["tmp"], tmp_dir), ignore_errors=True ) @@ -118,7 +146,7 @@ async def send_content(app: Client, http_session: ClientSession) -> None: del response - submitted = col_submitted.find_one({"temp.file": pic.filename}) + submitted = col_submitted.find_one({"temp.file": media.filename}) if submitted is not None and submitted["caption"] is not None: caption = submitted["caption"].strip() @@ -150,14 +178,24 @@ async def send_content(app: Client, http_session: ClientSession) -> None: caption = caption try: - sent = await app.send_photo( - app.config["posting"]["channel"], - path.join(app.config["locations"]["tmp"], tmp_path), - caption=caption, - disable_notification=app.config["posting"]["silent"], - ) + if func is photo_find: + sent = await app.send_photo( + app.config["posting"]["channel"], + path.join(app.config["locations"]["tmp"], tmp_path), + caption=caption, + disable_notification=app.config["posting"]["silent"], + ) + else: + sent = await app.send_video( + app.config["posting"]["channel"], + path.join(app.config["locations"]["tmp"], tmp_path), + caption=caption, + disable_notification=app.config["posting"]["silent"], + ) except Exception as exp: - logger.error(f"Could not send image {pic.filename} ({pic.id}) due to {exp}") + logger.error( + f"Could not send media {media.filename} ({media.id}) due to {exp}" + ) if app.config["reports"]["error"]: await app.send_message( app.owner, @@ -169,8 +207,8 @@ async def send_content(app: Client, http_session: ClientSession) -> None: col_sent.insert_one( { "date": datetime.now(), - "image": pic.id, - "filename": pic.filename, + "image": media.id, + "filename": media.filename, "channel": app.config["posting"]["channel"], "caption": None if (submitted is None or submitted["caption"] is None) @@ -178,13 +216,14 @@ async def send_content(app: Client, http_session: ClientSession) -> None: } ) - await photo_patch(id=pic.id, client=client, caption="sent") + func_patch = photo_patch if func is photo_find else video_patch + await func_patch(id=media.id, client=client, caption="sent") rmtree(path.join(app.config["locations"]["tmp"], tmp_dir), ignore_errors=True) logger.info( app._("post_sent", "console").format( - pic.id, + media.id, str(app.config["posting"]["channel"]), caption.replace("\n", "%n"), str(app.config["posting"]["silent"]), diff --git a/plugins/commands/photos.py b/plugins/commands/photos.py index 8e9123b..9a17fad 100644 --- a/plugins/commands/photos.py +++ b/plugins/commands/photos.py @@ -18,7 +18,7 @@ from ujson import loads from classes.pyroclient import PyroClient from modules.api_client import ( - BodyPhotoUploadAlbumsAlbumPhotosPost, + BodyPhotoUpload, File, client, photo_delete, @@ -158,7 +158,7 @@ async def cmd_import(app: PyroClient, msg: Message): uploaded = await photo_upload( app.config["posting"]["api"]["album"], client=client, - multipart_data=BodyPhotoUploadAlbumsAlbumPhotosPost( + multipart_data=BodyPhotoUpload( File(photo_bytes, Path(filename).name, "image/jpeg") ), ignore_duplicates=app.config["submission"]["allow_duplicates"], diff --git a/plugins/handlers/submission.py b/plugins/handlers/submission.py index b184657..a7b1faf 100644 --- a/plugins/handlers/submission.py +++ b/plugins/handlers/submission.py @@ -215,7 +215,7 @@ async def get_submission(app: PyroClient, msg: Message): and app.config["submission"]["require_confirmation"]["admins"] is False ): try: - submitted = await app.submit_photo(str(inserted.inserted_id)) + submitted = await app.submit_media(str(inserted.inserted_id)) await msg.reply_text( app._("sub_yes_auto", "message", locale=user_locale), disable_notification=True, -- 2.39.2 From a871773a810a52c5a4c5cfe01bd93b63eb7ab345 Mon Sep 17 00:00:00 2001 From: profitroll Date: Mon, 26 Jun 2023 13:34:58 +0200 Subject: [PATCH 08/16] Updated to libbot 1.5 --- classes/commandset.py | 29 --- classes/pyroclient.py | 414 ++++++++--------------------------------- classes/pyrocommand.py | 9 - locale/en.json | 1 + locale/uk.json | 1 + main.py | 5 +- requirements.txt | 8 +- 7 files changed, 83 insertions(+), 384 deletions(-) delete mode 100644 classes/commandset.py delete mode 100644 classes/pyrocommand.py diff --git a/classes/commandset.py b/classes/commandset.py deleted file mode 100644 index e50620d..0000000 --- a/classes/commandset.py +++ /dev/null @@ -1,29 +0,0 @@ -from dataclasses import dataclass -from typing import List, Union -from pyrogram.types import ( - BotCommandScopeAllChatAdministrators, - BotCommandScopeAllGroupChats, - BotCommandScopeAllPrivateChats, - BotCommandScopeChat, - BotCommandScopeChatAdministrators, - BotCommandScopeChatMember, - BotCommandScopeDefault, - BotCommand, -) - - -@dataclass -class CommandSet: - """Command stored in PyroClient's 'commands' attribute""" - - commands: List[BotCommand] - scope: Union[ - BotCommandScopeDefault, - BotCommandScopeAllPrivateChats, - BotCommandScopeAllGroupChats, - BotCommandScopeAllChatAdministrators, - BotCommandScopeChat, - BotCommandScopeChatAdministrators, - BotCommandScopeChatMember, - ] = BotCommandScopeDefault - language_code: str = "" diff --git a/classes/pyroclient.py b/classes/pyroclient.py index 38b900c..4377a8b 100644 --- a/classes/pyroclient.py +++ b/classes/pyroclient.py @@ -1,49 +1,31 @@ import contextlib import logging -from datetime import datetime, timedelta +from datetime import datetime from io import BytesIO -from os import getpid, makedirs, remove, sep +from os import makedirs, remove, sep from pathlib import Path from shutil import rmtree from time import time from traceback import format_exc -from typing import List, Tuple, Union -import aiofiles +from typing import Dict, List, Tuple, Union -import pyrogram +import aiofiles from aiohttp import ClientSession from bson import ObjectId -from dateutil.relativedelta import relativedelta -from classes.enums.submission_types import SubmissionType -from libbot import json_read, json_write -from libbot.i18n import BotLocale +from libbot import json_write from libbot.i18n.sync import _ from photosapi_client.errors import UnexpectedStatus -from pyrogram.client import Client -from pyrogram.errors import BadRequest, bad_request_400 -from pyrogram.handlers.message_handler import MessageHandler -from pyrogram.raw.all import layer -from pyrogram.types import ( - BotCommand, - BotCommandScopeAllChatAdministrators, - BotCommandScopeAllGroupChats, - BotCommandScopeAllPrivateChats, - BotCommandScopeChat, - BotCommandScopeChatAdministrators, - BotCommandScopeChatMember, - BotCommandScopeDefault, - Message, -) +from pyrogram.errors import bad_request_400 +from pyrogram.types import Message from pytimeparse.timeparse import timeparse from ujson import dumps, loads -from classes.commandset import CommandSet +from classes.enums.submission_types import SubmissionType from classes.exceptions import ( SubmissionDuplicatesError, SubmissionUnavailableError, SubmissionUnsupportedError, ) -from classes.pyrocommand import PyroCommand from modules.api_client import ( BodyPhotoUpload, BodyVideoUpload, @@ -56,28 +38,21 @@ from modules.api_client import ( ) from modules.database import col_submitted from modules.http_client import http_session -from modules.scheduler import scheduler from modules.sender import send_content logger = logging.getLogger(__name__) -class PyroClient(Client): - def __init__(self): - with open("config.json", "r", encoding="utf-8") as f: - self.config: dict = loads(f.read()) +from datetime import datetime +from typing import List, Union - super().__init__( - name="bot_client", - api_id=self.config["bot"]["api_id"], - api_hash=self.config["bot"]["api_hash"], - bot_token=self.config["bot"]["bot_token"], - plugins=dict(root="plugins", exclude=self.config["disabled_plugins"]), - sleep_threshold=120, - max_concurrent_transmissions=self.config["bot"][ - "max_concurrent_transmissions" - ], - ) +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from libbot.pyrogram.classes import PyroClient + + +class PyroClient(PyroClient): + def __init__(self, scheduler: AsyncIOScheduler): + super().__init__(scheduler=scheduler) self.version: float = 0.2 @@ -85,142 +60,75 @@ class PyroClient(Client): self.admins: List[int] = self.config["bot"]["admins"] + [ self.config["bot"]["owner"] ] - self.commands: List[PyroCommand] = [] - self.scoped_commands: bool = self.config["bot"]["scoped_commands"] - self.start_time: float = 0 - - self.bot_locale: BotLocale = BotLocale(Path(self.config["locations"]["locale"])) - self.default_locale: str = self.bot_locale.default - self.locales: dict = self.bot_locale.locales - - self._ = self.bot_locale._ - self.in_all_locales = self.bot_locale.in_all_locales - self.in_every_locale = self.bot_locale.in_every_locale self.sender_session = ClientSession() + self.scopes_placeholders: Dict[str, int] = { + "owner": self.owner, + "comments": self.config["posting"]["comments"], + } + async def start(self): await super().start() - self.start_time = time() + if self.config["reports"]["update"]: + try: + async with ClientSession( + json_serialize=dumps, + ) as http_session: + check_update = await http_session.get( + "https://git.end-play.xyz/api/v1/repos/profitroll/TelegramPoster/releases?page=1&limit=1" + ) - logger.info( - "Bot is running with Pyrogram v%s (Layer %s) and has started as @%s on PID %s.", - pyrogram.__version__, - layer, - self.me.username, - getpid(), - ) + response = await check_update.json() - try: - if Path(f"{self.config['locations']['cache']}/shutdown_time").exists(): - downtime = relativedelta( - datetime.now(), - datetime.fromtimestamp( - ( - await json_read( - Path( - f"{self.config['locations']['cache']}/shutdown_time" - ) - ) - )["timestamp"] - ), - ) + if len(response) == 0: + raise ValueError("No bot releases on git found.") - if downtime.days >= 1: - startup_message = self._( - "startup_downtime_days", - "message", - ).format(getpid(), downtime.days) - elif downtime.hours >= 1: - startup_message = self._( - "startup_downtime_hours", - "message", - ).format(getpid(), downtime.hours) + if float(response[0]["tag_name"].replace("v", "")) > self.version: + logger.info( + "New version %s found (current %s)", + response[0]["tag_name"].replace("v", ""), + self.version, + ) + await self.send_message( + self.owner, + self._( + "update_available", + "message", + ).format( + response[0]["tag_name"], + response[0]["html_url"], + response[0]["body"], + ), + ) else: - startup_message = self._( - "startup_downtime_minutes", - "message", - ).format(getpid(), downtime.minutes) + logger.info("No updates found, bot is up to date.") + except bad_request_400.PeerIdInvalid: + logger.warning( + "Could not send startup message to bot owner. Perhaps user has not started the bot yet." + ) + except Exception as exp: + logger.exception("Update check failed due to %s: %s", exp, format_exc()) + + if self.config["mode"]["post"]: + if self.config["posting"]["use_interval"]: + self.scheduler.add_job( + send_content, + "interval", + seconds=timeparse(self.config["posting"]["interval"]), + args=[self, self.sender_session], + ) else: - startup_message = (self._("startup", "message").format(getpid()),) - - await self.send_message( - chat_id=self.config["reports"]["chat_id"], - text=startup_message, - ) - - if self.config["reports"]["update"]: - try: - async with ClientSession( - json_serialize=dumps, - ) as http_session: - check_update = await http_session.get( - "https://git.end-play.xyz/api/v1/repos/profitroll/TelegramPoster/releases?page=1&limit=1" - ) - - response = await check_update.json() - - if len(response) == 0: - raise ValueError("No bot releases on git found.") - - if float(response[0]["tag_name"].replace("v", "")) > self.version: - logger.info( - "New version %s found (current %s)", - response[0]["tag_name"].replace("v", ""), - self.version, - ) - await self.send_message( - self.owner, - self._( - "update_available", - "message", - ).format( - response[0]["tag_name"], - response[0]["html_url"], - response[0]["body"], - ), - ) - else: - logger.info("No updates found, bot is up to date.") - except bad_request_400.PeerIdInvalid: - logger.warning( - "Could not send startup message to bot owner. Perhaps user has not started the bot yet." - ) - except Exception as exp: - logger.exception( - "Update check failed due to %s: %s", exp, format_exc() - ) - - scheduler.add_job( - self.register_commands, - trigger="date", - run_date=datetime.now() + timedelta(seconds=5), - kwargs={"command_sets": await self.collect_commands()}, - ) - - if self.config["mode"]["post"]: - if self.config["posting"]["use_interval"]: - scheduler.add_job( + for entry in self.config["posting"]["time"]: + dt_obj = datetime.strptime(entry, "%H:%M") + self.scheduler.add_job( send_content, - "interval", - seconds=timeparse(self.config["posting"]["interval"]), + "cron", + hour=dt_obj.hour, + minute=dt_obj.minute, args=[self, self.sender_session], ) - else: - for entry in self.config["posting"]["time"]: - dt_obj = datetime.strptime(entry, "%H:%M") - scheduler.add_job( - send_content, - "cron", - hour=dt_obj.hour, - minute=dt_obj.minute, - args=[self, self.sender_session], - ) - - scheduler.start() - except BadRequest: - logger.warning("Unable to send message to report chat.") async def stop(self): makedirs(self.config["locations"]["cache"], exist_ok=True) @@ -229,180 +137,10 @@ class PyroClient(Client): Path(f"{self.config['locations']['cache']}/shutdown_time"), ) - try: - await self.send_message( - chat_id=self.config["reports"]["chat_id"], - text=f"Bot stopped with PID `{getpid()}`", - ) - except BadRequest: - logger.warning("Unable to send message to report chat.") await http_session.close() await self.sender_session.close() + await super().stop() - logger.warning("Bot stopped with PID %s.", getpid()) - - async def collect_commands(self) -> Union[List[CommandSet], None]: - """Gather list of the bot's commands - - ### Returns: - * `List[CommandSet]`: List of the commands' sets - """ - command_sets = None - - # If config get bot.scoped_commands is true - more complicated - # scopes system will be used instead of simple global commands - if self.scoped_commands: - scopes = {} - command_sets = [] - - # Iterate through all commands in config - for command, contents in self.config["commands"].items(): - # Iterate through all scopes of a command - for scope in contents["scopes"]: - if dumps(scope) not in scopes: - scopes[dumps(scope)] = {"_": []} - - # Add command to the scope's flattened key in scopes dict - scopes[dumps(scope)]["_"].append( - BotCommand(command, _(command, "commands")) - ) - - for locale, string in ( - self.in_every_locale(command, "commands") - ).items(): - if locale not in scopes[dumps(scope)]: - scopes[dumps(scope)][locale] = [] - - scopes[dumps(scope)][locale].append(BotCommand(command, string)) - - # Iterate through all scopes and its commands - for scope, locales in scopes.items(): - # Make flat key a dict again - scope_dict = loads(scope) - - # Replace "owner" in the bot scope with owner's id - if "chat_id" in scope_dict and scope_dict["chat_id"] == "owner": - scope_dict["chat_id"] = self.owner - - # Create object with the same name and args from the dict - try: - scope_obj = globals()[scope_dict["name"]]( - **{ - key: value - for key, value in scope_dict.items() - if key != "name" - } - ) - except NameError: - logger.error( - "Could not register commands of the scope '%s' due to an invalid scope class provided!", - scope_dict["name"], - ) - continue - except TypeError: - logger.error( - "Could not register commands of the scope '%s' due to an invalid class arguments provided!", - scope_dict["name"], - ) - continue - - # Add set of commands to the list of the command sets - for locale, commands in locales.items(): - if locale == "_": - command_sets.append( - CommandSet(commands, scope=scope_obj, language_code="") - ) - continue - command_sets.append( - CommandSet(commands, scope=scope_obj, language_code=locale) - ) - - logger.info("Registering the following command sets: %s", command_sets) - - else: - # This part here looks into the handlers and looks for commands - # in it, if there are any. Then adds them to self.commands - for handler in self.dispatcher.groups[0]: - if isinstance(handler, MessageHandler): - for entry in [handler.filters.base, handler.filters.other]: - if hasattr(entry, "commands"): - for command in entry.commands: - logger.info("I see a command %s in my filters", command) - self.add_command(command) - - return command_sets - - def add_command( - self, - command: str, - ): - """Add command to the bot's internal commands list - - ### Args: - * command (`str`) - """ - self.commands.append( - PyroCommand( - command, - _(command, "commands"), - ) - ) - logger.info( - "Added command '%s' to the bot's internal commands list", - command, - ) - - async def register_commands( - self, command_sets: Union[List[CommandSet], None] = None - ): - """Register commands stored in bot's 'commands' attribute""" - - if command_sets is None: - commands = [ - BotCommand(command=command.command, description=command.description) - for command in self.commands - ] - - logger.info( - "Registering commands %s with a default scope 'BotCommandScopeDefault'" - ) - - await self.set_bot_commands(commands) - return - - for command_set in command_sets: - logger.info( - "Registering command set with commands %s and scope '%s' (%s)", - command_set.commands, - command_set.scope, - command_set.language_code, - ) - await self.set_bot_commands( - command_set.commands, - command_set.scope, - language_code=command_set.language_code, - ) - - async def remove_commands(self, command_sets: Union[List[CommandSet], None] = None): - """Remove commands stored in bot's 'commands' attribute""" - - if command_sets is None: - logger.info( - "Removing commands with a default scope 'BotCommandScopeDefault'" - ) - await self.delete_bot_commands(BotCommandScopeDefault()) - return - - for command_set in command_sets: - logger.info( - "Removing command set with scope '%s' (%s)", - command_set.scope, - command_set.language_code, - ) - await self.delete_bot_commands( - command_set.scope, - language_code=command_set.language_code, - ) async def submit_media( self, id: str @@ -422,7 +160,7 @@ class PyroClient(Client): submission, file_name=self.config["locations"]["tmp"] + sep ) except Exception as exp: - raise SubmissionUnavailableError() + raise SubmissionUnavailableError() from exp elif not Path( f"{self.config['locations']['data']}/submissions/{db_entry['temp']['uuid']}/{db_entry['temp']['file']}", @@ -470,8 +208,8 @@ class PyroClient(Client): ), caption="queue", ) - except UnexpectedStatus: - raise SubmissionUnsupportedError(str(filepath)) + except UnexpectedStatus as exp: + raise SubmissionUnsupportedError(str(filepath)) from exp response_dict = ( {} diff --git a/classes/pyrocommand.py b/classes/pyrocommand.py deleted file mode 100644 index 7b01180..0000000 --- a/classes/pyrocommand.py +++ /dev/null @@ -1,9 +0,0 @@ -from dataclasses import dataclass - - -@dataclass -class PyroCommand: - """Command stored in PyroClient's 'commands' attribute""" - - command: str - description: str diff --git a/locale/en.json b/locale/en.json index 8dfda65..cd12fb2 100644 --- a/locale/en.json +++ b/locale/en.json @@ -2,6 +2,7 @@ "commands": { "start": "Start using the bot", "rules": "Photos submission rules", + "report": "Report this post", "forwards": "Check post forwards", "import": "Submit .zip archive with photos", "export": "Get .zip archive with all photos", diff --git a/locale/uk.json b/locale/uk.json index 6d461a5..39ce5fc 100644 --- a/locale/uk.json +++ b/locale/uk.json @@ -2,6 +2,7 @@ "commands": { "start": "Почати користуватись ботом", "rules": "Правила пропонування фото", + "report": "Поскаржитись на цей пост", "forwards": "Переглянути репости", "import": "Надати боту .zip архів з фотографіями", "export": "Отримати .zip архів з усіма фотографіями", diff --git a/main.py b/main.py index 2c25b5e..c590bca 100644 --- a/main.py +++ b/main.py @@ -22,7 +22,7 @@ with contextlib.suppress(ImportError): def main(): - client = PyroClient() + client = PyroClient(scheduler=scheduler) Conversation(client) try: @@ -30,7 +30,8 @@ def main(): except KeyboardInterrupt: logger.warning("Forcefully shutting down with PID %s...", getpid()) finally: - scheduler.shutdown() + if client.scheduler is not None: + client.scheduler.shutdown() exit() diff --git a/requirements.txt b/requirements.txt index ad7eab8..e0f01e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,17 +1,13 @@ -aiofiles~=23.1.0 aiohttp~=3.8.4 -apscheduler~=3.10.1 black~=23.3.0 convopyro==0.5 pillow~=9.4.0 psutil~=5.9.4 -pymongo~=4.3.3 -pyrogram==2.0.106 +pymongo~=4.4.0 python_dateutil==2.8.2 pytimeparse~=1.1.8 tgcrypto==1.2.5 -ujson==5.8.0 uvloop==0.17.0 --extra-index-url https://git.end-play.xyz/api/packages/profitroll/pypi/simple -libbot[speed,pyrogram]==1.0 +libbot[speed,pyrogram]==1.5 photosapi_client==0.4.0 \ No newline at end of file -- 2.39.2 From adc7228a717450f79cf748715388e98c856d5d44 Mon Sep 17 00:00:00 2001 From: profitroll Date: Mon, 26 Jun 2023 14:06:53 +0200 Subject: [PATCH 09/16] Okay, pyrogram should indeed be in requirements --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index e0f01e7..8f18da1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ convopyro==0.5 pillow~=9.4.0 psutil~=5.9.4 pymongo~=4.4.0 +pyrogram==2.0.106 python_dateutil==2.8.2 pytimeparse~=1.1.8 tgcrypto==1.2.5 -- 2.39.2 From eb78a75a887f48e540fc5be37a0ceb3d783b520d Mon Sep 17 00:00:00 2001 From: profitroll Date: Mon, 26 Jun 2023 23:02:38 +0200 Subject: [PATCH 10/16] WIP: /report command --- config_example.json | 9 +++++++++ locale/en.json | 4 +++- locale/uk.json | 4 +++- plugins/commands/report.py | 28 ++++++++++++++++++++++++++++ plugins/handlers/submission.py | 3 +++ 5 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 plugins/commands/report.py diff --git a/config_example.json b/config_example.json index 0d0f248..c91d8bd 100644 --- a/config_example.json +++ b/config_example.json @@ -46,6 +46,7 @@ "disabled_plugins": [], "posting": { "channel": 0, + "comments": 0, "silent": false, "move_sent": false, "use_interval": false, @@ -142,6 +143,14 @@ } ] }, + "report": { + "scopes": [ + { + "name": "BotCommandScopeChat", + "chat_id": "comments" + } + ] + }, "forwards": { "scopes": [ { diff --git a/locale/en.json b/locale/en.json index cd12fb2..533bd33 100644 --- a/locale/en.json +++ b/locale/en.json @@ -60,7 +60,9 @@ "remove_success": "Removed media with ID `{0}`.", "remove_failure": "Could not remove media with ID `{0}`. Check if provided ID is correct and if it is - you can also check bot's log for details.", "update_available": "**New version found**\nThere's a newer version of a bot found. You can update your bot to [{0}]({1}) using command line of your host.\n\n**Release notes**\n{2}\n\nRead more about updating you bot on the [wiki page](https://git.end-play.xyz/profitroll/TelegramPoster/wiki/Updating-Instance).\n\nPlease not that you can also disable this notification by editing `reports.update` key of the config.", - "shutdown_confirm": "There are {0} unfinished users' contexts. If you turn off the bot, those will be lost. Please confirm shutdown using a button below." + "shutdown_confirm": "There are {0} unfinished users' contexts. If you turn off the bot, those will be lost. Please confirm shutdown using a button below.", + "report_sent": "We've notified admins about presumable violation. Thank you for cooperation.", + "report_received": "This message has been reported by {0} (@{1}, `{2}`)" }, "button": { "sub_yes": "✅ Accept", diff --git a/locale/uk.json b/locale/uk.json index 39ce5fc..84a0371 100644 --- a/locale/uk.json +++ b/locale/uk.json @@ -60,7 +60,9 @@ "remove_success": "Видалено медіа з ID `{0}`.", "remove_failure": "Не вдалося видалити медіа з ID `{0}`. Перевірте, чи вказано правильний ID, і якщо він правильний, ви також можете переглянути логи бота для отримання більш детальної інформації.", "update_available": "**Знайдено нову версію**\nЗнайдено нову версію бота. Ви можете оновити бота до [{0}]({1}) за допомогою командного рядка вашого хосту.\n\n**Примітки до релізу**\n{2}\n\nДетальніше про оновлення бота можна знайти на [вікі-сторінці](https://git.end-play.xyz/profitroll/TelegramPoster/wiki/Updating-Instance).\n\nЗверніть увагу, що ви також можете вимкнути це сповіщення, відредагувавши ключ `reports.update` у конфігурації.", - "shutdown_confirm": "Існує {0} незавершених контекстів користувачів. Якщо ви вимкнете бота, вони будуть втрачені. Будь ласка, підтвердіть вимкнення за допомогою кнопки нижче." + "shutdown_confirm": "Існує {0} незавершених контекстів користувачів. Якщо ви вимкнете бота, вони будуть втрачені. Будь ласка, підтвердіть вимкнення за допомогою кнопки нижче.", + "report_sent": "Ми повідомили адміністрацію про потенційне порушення. Дякую за співпрацю.", + "report_received": "На це повідомлення було отримано скаргу від {0} (@{1}, `{2}`)" }, "button": { "sub_yes": "✅ Прийняти", diff --git a/plugins/commands/report.py b/plugins/commands/report.py new file mode 100644 index 0000000..2f88752 --- /dev/null +++ b/plugins/commands/report.py @@ -0,0 +1,28 @@ +from pyrogram.client import Client +from pyrogram import filters +from pyrogram.types import Message +from libbot import sync +from classes.pyroclient import PyroClient + + +@Client.on_message( + ~filters.scheduled + & filters.chat(sync.config_get("comments", "posting")) + & filters.reply + & filters.command(["report"], prefixes=["", "/"]) +) +async def command_report(app: PyroClient, msg: Message): + if msg.reply_to_message.forward_from_chat.id == app.config["posting"]["channel"]: + await msg.reply_text(app._("report_send", "message", locale=msg.from_user.language_code)) + + report_sent = await msg.reply_to_message.forward(app.owner) + sender = msg.from_user if msg.from_user is not None else msg.sender_chat + + sender_name = sender.first_name if hasattr(sender, "first_name") else sender.title + + # ACTION NEEDED + # Name and username are somehow None + await report_sent.reply_text( + app._("report_received", "message").format(sender_name, sender.username, sender.id), + quote=True, + ) \ No newline at end of file diff --git a/plugins/handlers/submission.py b/plugins/handlers/submission.py index a7b1faf..8dcb270 100644 --- a/plugins/handlers/submission.py +++ b/plugins/handlers/submission.py @@ -29,6 +29,9 @@ logger = logging.getLogger(__name__) async def get_submission(app: PyroClient, msg: Message): global USERS_WITH_CONTEXT + if not hasattr(msg.from_user, "id"): + return + if msg.from_user.id in USERS_WITH_CONTEXT: return -- 2.39.2 From f4661e7404d8246c434b37945f79022cbf89c170 Mon Sep 17 00:00:00 2001 From: profitroll Date: Mon, 26 Jun 2023 23:10:09 +0200 Subject: [PATCH 11/16] /report command added --- locale/en.json | 2 +- locale/uk.json | 2 +- plugins/commands/report.py | 24 +++++++++++++++++++----- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/locale/en.json b/locale/en.json index 533bd33..f07791b 100644 --- a/locale/en.json +++ b/locale/en.json @@ -62,7 +62,7 @@ "update_available": "**New version found**\nThere's a newer version of a bot found. You can update your bot to [{0}]({1}) using command line of your host.\n\n**Release notes**\n{2}\n\nRead more about updating you bot on the [wiki page](https://git.end-play.xyz/profitroll/TelegramPoster/wiki/Updating-Instance).\n\nPlease not that you can also disable this notification by editing `reports.update` key of the config.", "shutdown_confirm": "There are {0} unfinished users' contexts. If you turn off the bot, those will be lost. Please confirm shutdown using a button below.", "report_sent": "We've notified admins about presumable violation. Thank you for cooperation.", - "report_received": "This message has been reported by {0} (@{1}, `{2}`)" + "report_received": "This message has been reported by **{0}** (@{1}, `{2}`)" }, "button": { "sub_yes": "✅ Accept", diff --git a/locale/uk.json b/locale/uk.json index 84a0371..128e39a 100644 --- a/locale/uk.json +++ b/locale/uk.json @@ -62,7 +62,7 @@ "update_available": "**Знайдено нову версію**\nЗнайдено нову версію бота. Ви можете оновити бота до [{0}]({1}) за допомогою командного рядка вашого хосту.\n\n**Примітки до релізу**\n{2}\n\nДетальніше про оновлення бота можна знайти на [вікі-сторінці](https://git.end-play.xyz/profitroll/TelegramPoster/wiki/Updating-Instance).\n\nЗверніть увагу, що ви також можете вимкнути це сповіщення, відредагувавши ключ `reports.update` у конфігурації.", "shutdown_confirm": "Існує {0} незавершених контекстів користувачів. Якщо ви вимкнете бота, вони будуть втрачені. Будь ласка, підтвердіть вимкнення за допомогою кнопки нижче.", "report_sent": "Ми повідомили адміністрацію про потенційне порушення. Дякую за співпрацю.", - "report_received": "На це повідомлення було отримано скаргу від {0} (@{1}, `{2}`)" + "report_received": "На це повідомлення було отримано скаргу від **{0}** (@{1}, `{2}`)" }, "button": { "sub_yes": "✅ Прийняти", diff --git a/plugins/commands/report.py b/plugins/commands/report.py index 2f88752..063fe4b 100644 --- a/plugins/commands/report.py +++ b/plugins/commands/report.py @@ -1,6 +1,6 @@ from pyrogram.client import Client from pyrogram import filters -from pyrogram.types import Message +from pyrogram.types import Message, User from libbot import sync from classes.pyroclient import PyroClient @@ -13,16 +13,30 @@ from classes.pyroclient import PyroClient ) async def command_report(app: PyroClient, msg: Message): if msg.reply_to_message.forward_from_chat.id == app.config["posting"]["channel"]: - await msg.reply_text(app._("report_send", "message", locale=msg.from_user.language_code)) + await msg.reply_text( + app._( + "report_sent", + "message", + locale=msg.from_user.language_code + if msg.from_user is not None + else None, + ) + ) + + print(msg) report_sent = await msg.reply_to_message.forward(app.owner) sender = msg.from_user if msg.from_user is not None else msg.sender_chat - sender_name = sender.first_name if hasattr(sender, "first_name") else sender.title + sender_name = ( + sender.first_name if isinstance(sender, User) else sender.title + ) # ACTION NEEDED # Name and username are somehow None await report_sent.reply_text( - app._("report_received", "message").format(sender_name, sender.username, sender.id), + app._("report_received", "message").format( + sender_name, sender.username, sender.id + ), quote=True, - ) \ No newline at end of file + ) -- 2.39.2 From ce6363cb989ddd5bcab2e3e203927ff900fc125c Mon Sep 17 00:00:00 2001 From: profitroll Date: Tue, 27 Jun 2023 14:07:01 +0200 Subject: [PATCH 12/16] Bumpt PhotosAPI_Client to 0.5.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8f18da1..19b25b1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ tgcrypto==1.2.5 uvloop==0.17.0 --extra-index-url https://git.end-play.xyz/api/packages/profitroll/pypi/simple libbot[speed,pyrogram]==1.5 -photosapi_client==0.4.0 \ No newline at end of file +photosapi_client==0.5.0 \ No newline at end of file -- 2.39.2 From 39b9c365fb35d9657f4d07a63a5fdad0a4fb1f12 Mon Sep 17 00:00:00 2001 From: profitroll Date: Tue, 27 Jun 2023 20:03:14 +0200 Subject: [PATCH 13/16] Added support for random media --- modules/api_client.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modules/api_client.py b/modules/api_client.py index 8192499..2680635 100644 --- a/modules/api_client.py +++ b/modules/api_client.py @@ -27,6 +27,9 @@ from photosapi_client.api.default.photo_get_photos_id_get import asyncio as phot from photosapi_client.api.default.photo_patch_photos_id_patch import ( asyncio as photo_patch, ) +from photosapi_client.api.default.photo_random_albums_album_photos_random_get import ( + asyncio as photo_random, +) from photosapi_client.api.default.photo_upload_albums_album_photos_post import ( asyncio_detailed as photo_upload, ) @@ -39,6 +42,9 @@ from photosapi_client.api.default.video_get_videos_id_get import asyncio as vide from photosapi_client.api.default.video_patch_videos_id_patch import ( asyncio as video_patch, ) +from photosapi_client.api.default.video_random_albums_album_videos_random_get import ( + asyncio as video_random, +) from photosapi_client.api.default.video_upload_albums_album_videos_post import ( asyncio as video_upload, ) -- 2.39.2 From f0cad86dd64316725096c8198d2d04e9881845c8 Mon Sep 17 00:00:00 2001 From: profitroll Date: Tue, 27 Jun 2023 20:04:03 +0200 Subject: [PATCH 14/16] Temp: Removed animation support --- classes/enums/submission_types.py | 2 +- classes/pyroclient.py | 18 ++++++------- config_example.json | 6 +---- plugins/handlers/submission.py | 44 +++++++++++++++---------------- 4 files changed, 33 insertions(+), 37 deletions(-) diff --git a/classes/enums/submission_types.py b/classes/enums/submission_types.py index 265ce7a..2bd043f 100644 --- a/classes/enums/submission_types.py +++ b/classes/enums/submission_types.py @@ -4,5 +4,5 @@ from enum import Enum class SubmissionType(Enum): DOCUMENT = "document" VIDEO = "video" - ANIMATION = "animation" + # ANIMATION = "animation" PHOTO = "photo" diff --git a/classes/pyroclient.py b/classes/pyroclient.py index 4377a8b..54f2edc 100644 --- a/classes/pyroclient.py +++ b/classes/pyroclient.py @@ -199,15 +199,15 @@ class PyroClient(PyroClient): ), caption="queue", ) - elif db_entry["type"] == SubmissionType.ANIMATION.value: - response = await video_upload( - self.config["posting"]["api"]["album"], - client=client, - multipart_data=BodyVideoUpload( - File(media_bytes, filepath.name, "video/*") - ), - caption="queue", - ) + # elif db_entry["type"] == SubmissionType.ANIMATION.value: + # response = await video_upload( + # self.config["posting"]["api"]["album"], + # client=client, + # multipart_data=BodyVideoUpload( + # File(media_bytes, filepath.name, "video/*") + # ), + # caption="queue", + # ) except UnexpectedStatus as exp: raise SubmissionUnsupportedError(str(filepath)) from exp diff --git a/config_example.json b/config_example.json index c91d8bd..481317d 100644 --- a/config_example.json +++ b/config_example.json @@ -51,7 +51,6 @@ "move_sent": false, "use_interval": false, "interval": "1h30m", - "page_size": 300, "submitted_caption": { "enabled": true, "ignore_admins": true, @@ -59,14 +58,12 @@ }, "types": { "photo": true, - "video": false, - "animation": false + "video": false }, "extensions": { "photo": [ "jpg", "png", - "gif", "jpeg" ], "video": [ @@ -114,7 +111,6 @@ }, "mime_types": [ "image/png", - "image/gif", "image/jpeg", "video/mp4", "video/quicktime" diff --git a/plugins/handlers/submission.py b/plugins/handlers/submission.py index 8dcb270..43e8113 100644 --- a/plugins/handlers/submission.py +++ b/plugins/handlers/submission.py @@ -23,7 +23,7 @@ logger = logging.getLogger(__name__) @Client.on_message( ~filters.scheduled & filters.private & filters.photo | filters.video - | filters.animation + # | filters.animation | filters.document ) async def get_submission(app: PyroClient, msg: Message): @@ -103,27 +103,27 @@ async def get_submission(app: PyroClient, msg: Message): save_tmp = False contents = msg.video.file_id, SubmissionType.VIDEO # , msg.video.file_name - if msg.animation is not None: - logger.info( - "User %s is trying to submit an animation with name '%s' and size of %s MB", - msg.from_user.id, - msg.animation.file_name, - msg.animation.file_size / 1024 / 1024, - ) - if msg.animation.file_size > app.config["submission"]["file_size"]: - await msg.reply_text( - app._("document_too_large", "message", locale=user_locale).format( - str(app.config["submission"]["file_size"] / 1024 / 1024) - ), - quote=True, - ) - return - if msg.animation.file_size > app.config["submission"]["tmp_size"]: - save_tmp = False - contents = ( - msg.animation.file_id, - SubmissionType.ANIMATION, - ) # , msg.animation.file_name + # if msg.animation is not None: + # logger.info( + # "User %s is trying to submit an animation with name '%s' and size of %s MB", + # msg.from_user.id, + # msg.animation.file_name, + # msg.animation.file_size / 1024 / 1024, + # ) + # if msg.animation.file_size > app.config["submission"]["file_size"]: + # await msg.reply_text( + # app._("document_too_large", "message", locale=user_locale).format( + # str(app.config["submission"]["file_size"] / 1024 / 1024) + # ), + # quote=True, + # ) + # return + # if msg.animation.file_size > app.config["submission"]["tmp_size"]: + # save_tmp = False + # contents = ( + # msg.animation.file_id, + # SubmissionType.ANIMATION, + # ) # , msg.animation.file_name if msg.photo is not None: logger.info( -- 2.39.2 From 66d9956026da064d8cad60df48e9ad6a19147351 Mon Sep 17 00:00:00 2001 From: profitroll Date: Tue, 27 Jun 2023 23:05:37 +0200 Subject: [PATCH 15/16] Photo and Video support --- modules/sender.py | 132 +++++++++++++++++++++++++--------------------- 1 file changed, 71 insertions(+), 61 deletions(-) diff --git a/modules/sender.py b/modules/sender.py index ca1db19..02163f9 100644 --- a/modules/sender.py +++ b/modules/sender.py @@ -1,33 +1,33 @@ import logging from datetime import datetime from os import makedirs, path -from random import choice +from random import choice, sample from shutil import rmtree from traceback import format_exc, print_exc from uuid import uuid4 import aiofiles from aiohttp import ClientSession +from libbot.pyrogram.classes import PyroClient from photosapi_client.errors import UnexpectedStatus from PIL import Image -from pyrogram.client import Client from modules.api_client import ( authorize, client, - photo_find, photo_get, photo_patch, - video_find, + photo_random, video_get, video_patch, + video_random, ) from modules.database import col_sent, col_submitted logger = logging.getLogger(__name__) -async def send_content(app: Client, http_session: ClientSession) -> None: +async def send_content(app: PyroClient, http_session: ClientSession) -> None: try: try: token = await authorize(http_session) @@ -40,25 +40,67 @@ async def send_content(app: Client, http_session: ClientSession) -> None: try: funcs = [] - if app.config["posting"]["types"]["photo"]: - funcs.append(photo_find) - if ( - app.config["posting"]["types"]["video"] - or app.config["posting"]["types"]["animation"] - ): - funcs.append(video_find) - func = choice(funcs) - media = choice( - ( - await func( + print(funcs) + + if app.config["posting"]["types"]["photo"]: + funcs.append((photo_random, photo_get, app.send_photo, photo_patch)) + + if app.config["posting"]["types"]["video"]: + funcs.append((video_random, video_get, app.send_video, video_patch)) + + print(funcs) + + if not funcs: + raise KeyError( + "No media source provided: all seem to be disabled in config" + ) + + if len(funcs) > 1: + found = False + for func_iter in sample(funcs, len(funcs)): + func = func_iter + media = ( + await func_iter[0]( + album=app.config["posting"]["api"]["album"], + caption="queue", + client=client, + limit=1, + ) + ).results[0] + try: + response = await func_iter[1](id=media.id, client=client) + except Exception as exp: + print_exc() + logger.error("Media is invalid: %s", exp) + if app.config["reports"]["error"]: + await app.send_message( + app.owner, f"Media is invalid: {exp}" + ) + return + found = True + break + if not found: + raise KeyError("No media found") + else: + func = funcs[0] + media = ( + await func[0]( album=app.config["posting"]["api"]["album"], caption="queue", - page_size=app.config["posting"]["page_size"], client=client, + limit=1, ) - ).results - ) + ).results[0] + try: + response = await func[1](id=media.id, client=client) + except Exception as exp: + print_exc() + logger.error("Media is invalid: %s", exp) + if app.config["reports"]["error"]: + await app.send_message(app.owner, f"Media is invalid: {exp}") + return + except (KeyError, AttributeError, TypeError, IndexError): logger.info(app._("post_empty", "console")) if app.config["reports"]["error"]: @@ -67,6 +109,7 @@ async def send_content(app: Client, http_session: ClientSession) -> None: app._("api_queue_empty", "message"), ) return + except (ValueError, UnexpectedStatus): if app.config["reports"]["error"]: await app.send_message( @@ -75,30 +118,6 @@ async def send_content(app: Client, http_session: ClientSession) -> None: ) return - try: - if func is photo_find: - response = await photo_get(id=media.id, client=client) - else: - response = await video_get(id=media.id, client=client) - except Exception as exp: - print_exc() - logger.warning( - "Media is invalid: %s", - exp - # app._("post_invalid_pic", "console").format( - # response.status, str(await response.json()) - # ) - ) - if app.config["reports"]["error"]: - await app.send_message(app.owner, f"Media is invalid: {exp}") - return - # await app.send_message( - # app.owner, - # app._("post_invalid_pic", "message").format( - # response.status, await response.json() - # ), - # ) - tmp_dir = str(uuid4()) makedirs(path.join(app.config["locations"]["tmp"], tmp_dir), exist_ok=True) @@ -116,7 +135,7 @@ async def send_content(app: Client, http_session: ClientSession) -> None: if ( path.getsize(path.join(app.config["locations"]["tmp"], tmp_path)) > 5242880 - ) and func is photo_find: + ) and func[0] is photo_random: image = Image.open(path.join(app.config["locations"]["tmp"], tmp_path)) width, height = image.size image = image.resize((int(width / 2), int(height / 2)), Image.ANTIALIAS) @@ -138,7 +157,7 @@ async def send_content(app: Client, http_session: ClientSession) -> None: if ( path.getsize(path.join(app.config["locations"]["tmp"], tmp_path)) > 5242880 - ) and func is photo_find: + ) and func[0] is photo_random: rmtree( path.join(app.config["locations"]["tmp"], tmp_dir), ignore_errors=True ) @@ -178,20 +197,12 @@ async def send_content(app: Client, http_session: ClientSession) -> None: caption = caption try: - if func is photo_find: - sent = await app.send_photo( - app.config["posting"]["channel"], - path.join(app.config["locations"]["tmp"], tmp_path), - caption=caption, - disable_notification=app.config["posting"]["silent"], - ) - else: - sent = await app.send_video( - app.config["posting"]["channel"], - path.join(app.config["locations"]["tmp"], tmp_path), - caption=caption, - disable_notification=app.config["posting"]["silent"], - ) + sent = await func[2]( + app.config["posting"]["channel"], + path.join(app.config["locations"]["tmp"], tmp_path), + caption=caption, + disable_notification=app.config["posting"]["silent"], + ) except Exception as exp: logger.error( f"Could not send media {media.filename} ({media.id}) due to {exp}" @@ -216,8 +227,7 @@ async def send_content(app: Client, http_session: ClientSession) -> None: } ) - func_patch = photo_patch if func is photo_find else video_patch - await func_patch(id=media.id, client=client, caption="sent") + await func[3](id=media.id, client=client, caption="sent") rmtree(path.join(app.config["locations"]["tmp"], tmp_dir), ignore_errors=True) -- 2.39.2 From e2a73d5c4def1d2c9a38f37d7c0f9a72c0c21ffd Mon Sep 17 00:00:00 2001 From: profitroll Date: Tue, 27 Jun 2023 23:40:36 +0200 Subject: [PATCH 16/16] Bug fixes and improvements --- modules/api_client.py | 2 ++ modules/sender.py | 34 +++++++++++++++++++++++----------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/modules/api_client.py b/modules/api_client.py index 2680635..7404320 100644 --- a/modules/api_client.py +++ b/modules/api_client.py @@ -59,8 +59,10 @@ from photosapi_client.models.body_video_upload_albums_album_videos_post import ( ) from photosapi_client.models.http_validation_error import HTTPValidationError from photosapi_client.models.photo import Photo +from photosapi_client.models.photo_search import PhotoSearch from photosapi_client.models.token import Token from photosapi_client.models.video import Video +from photosapi_client.models.video_search import VideoSearch from photosapi_client.types import File from modules.http_client import http_session diff --git a/modules/sender.py b/modules/sender.py index 02163f9..9455ca9 100644 --- a/modules/sender.py +++ b/modules/sender.py @@ -4,6 +4,7 @@ from os import makedirs, path from random import choice, sample from shutil import rmtree from traceback import format_exc, print_exc +from typing import Union from uuid import uuid4 import aiofiles @@ -13,6 +14,9 @@ from photosapi_client.errors import UnexpectedStatus from PIL import Image from modules.api_client import ( + File, + PhotoSearch, + VideoSearch, authorize, client, photo_get, @@ -41,16 +45,12 @@ async def send_content(app: PyroClient, http_session: ClientSession) -> None: try: funcs = [] - print(funcs) - if app.config["posting"]["types"]["photo"]: funcs.append((photo_random, photo_get, app.send_photo, photo_patch)) if app.config["posting"]["types"]["video"]: funcs.append((video_random, video_get, app.send_video, video_patch)) - print(funcs) - if not funcs: raise KeyError( "No media source provided: all seem to be disabled in config" @@ -60,16 +60,23 @@ async def send_content(app: PyroClient, http_session: ClientSession) -> None: found = False for func_iter in sample(funcs, len(funcs)): func = func_iter - media = ( + + random_results = ( await func_iter[0]( album=app.config["posting"]["api"]["album"], caption="queue", client=client, limit=1, ) - ).results[0] + ).results + + if not random_results: + continue + + media: Union[PhotoSearch, VideoSearch] = random_results[0] + try: - response = await func_iter[1](id=media.id, client=client) + response: File = await func_iter[1](id=media.id, client=client) except Exception as exp: print_exc() logger.error("Media is invalid: %s", exp) @@ -78,13 +85,15 @@ async def send_content(app: PyroClient, http_session: ClientSession) -> None: app.owner, f"Media is invalid: {exp}" ) return + found = True break + if not found: raise KeyError("No media found") else: func = funcs[0] - media = ( + media: Union[PhotoSearch, VideoSearch] = ( await func[0]( album=app.config["posting"]["api"]["album"], caption="queue", @@ -93,7 +102,7 @@ async def send_content(app: PyroClient, http_session: ClientSession) -> None: ) ).results[0] try: - response = await func[1](id=media.id, client=client) + response: File = await func[1](id=media.id, client=client) except Exception as exp: print_exc() logger.error("Media is invalid: %s", exp) @@ -130,7 +139,10 @@ async def send_content(app: PyroClient, http_session: ClientSession) -> None: await out_file.write(response.payload.read()) logger.info( - f"Candidate {media.filename} ({media.id}) is {path.getsize(path.join(app.config['locations']['tmp'], tmp_path))} bytes big", + "Candidate %s (%s) is %s bytes big", + media.filename, + media.id, + path.getsize(path.join(app.config["locations"]["tmp"], tmp_path)), ) if ( @@ -205,7 +217,7 @@ async def send_content(app: PyroClient, http_session: ClientSession) -> None: ) except Exception as exp: logger.error( - f"Could not send media {media.filename} ({media.id}) due to {exp}" + "Could not send media %s (%s) due to %s", media.filename, media.id, exp ) if app.config["reports"]["error"]: await app.send_message( -- 2.39.2