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