From 5f9f7bbb394d040785197f3abd9094ab353e1eae Mon Sep 17 00:00:00 2001 From: ch3p4ll3 Date: Tue, 12 Dec 2023 20:37:17 +0100 Subject: [PATCH 01/25] add logger + add tqdm bar --- main.py | 13 +++++++++++ src/qbittorrent_bot.py | 46 +++++++++++++++++--------------------- src/qbittorrent_manager.py | 33 +++++++++++++++++++++------ 3 files changed, 59 insertions(+), 33 deletions(-) diff --git a/main.py b/main.py index f1b163b..4917970 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,17 @@ from src.qbittorrent_bot import app, scheduler +import logging +from logging import handlers + +# Create a file handler +handler = logging.handlers.TimedRotatingFileHandler('QbittorrentBot.log', when='midnight', backupCount=10) + +# Create a format +formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') +handler.setFormatter(formatter) + +logging.getLogger().addHandler(handler) +# Set logging level to DEBUG +logging.getLogger().setLevel(logging.DEBUG) if __name__ == '__main__': scheduler.start() diff --git a/src/qbittorrent_bot.py b/src/qbittorrent_bot.py index 6e319d0..f941882 100644 --- a/src/qbittorrent_bot.py +++ b/src/qbittorrent_bot.py @@ -1,6 +1,8 @@ import os import tempfile +from tqdm import tqdm + from pyrogram import Client, filters from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message, CallbackQuery from pyrogram.errors.exceptions import MessageIdInvalid @@ -351,34 +353,26 @@ async def delete_all_with_data_callback(client: Client, callback_query: Callback async def torrent_info_callback(client: Client, callback_query: CallbackQuery) -> None: with QbittorrentManagement() as qb: torrent = qb.get_torrent_info(data=callback_query.data.split("#")[1]) - progress = torrent.progress * 100 - text = "" - - if progress == 0: - text += f"{torrent.name}\n[ ] " \ - f"{round(progress, 2)}% completed\n" \ - f"State: {torrent.state.capitalize()}\n" \ - f"Download Speed: {convert_size(torrent.dlspeed)}/s\n" \ - f"Size: {convert_size(torrent.size)}\nETA: " \ - f"{convert_eta(int(torrent.eta))}\n" \ - f"Category: {torrent.category}\n" - - elif progress == 100: - text += f"{torrent.name}\n[completed] " \ - f"{round(progress, 2)}% completed\n" \ - f"State: {torrent.state.capitalize()}\n" \ - f"Upload Speed: {convert_size(torrent.upspeed)}/s\n" \ - f"Category: {torrent.category}\n" + + text = f"{torrent.name}\n" + + if torrent.progress == 1: + text += "**COMPLETED**\n" else: - text += f"{torrent.name}\n[{'=' * int(progress / 10)}" \ - f"{' ' * int(12 - (progress / 10))}]" \ - f" {round(progress, 2)}% completed\n" \ - f"State: {torrent.state.capitalize()} \n" \ - f"Download Speed: {convert_size(torrent.dlspeed)}/s\n" \ - f"Size: {convert_size(torrent.size)}\nETA: " \ - f"{convert_eta(int(torrent.eta))}\n" \ - f"Category: {torrent.category}\n" + text += f"{tqdm.format_meter(torrent.progress, 1, 0, bar_format='{l_bar}{bar}|')}\n" + + if "stalled" not in torrent.state: + text += (f"**State:** {torrent.state.capitalize()} \n" + f"**Download Speed:** {convert_size(torrent.dlspeed)}/s\n") + + text += f"**Size:** {convert_size(torrent.size)}\n" + + if "stalled" not in torrent.state: + text += f"**ETA:** {convert_eta(int(torrent.eta))}\n" + + if torrent.category: + text += f"**Category:** {torrent.category}\n" buttons = [[InlineKeyboardButton("⏸ Pause", f"pause#{callback_query.data.split('#')[1]}")], [InlineKeyboardButton("▶️ Resume", f"resume#{callback_query.data.split('#')[1]}")], diff --git a/src/qbittorrent_manager.py b/src/qbittorrent_manager.py index b066e22..8bf11a8 100644 --- a/src/qbittorrent_manager.py +++ b/src/qbittorrent_manager.py @@ -1,8 +1,11 @@ import qbittorrentapi +import logging from src.config import BOT_CONFIGS from typing import Union, List +logger = logging.getLogger(__name__) + class QbittorrentManagement: def __init__(self): @@ -16,8 +19,7 @@ def __enter__(self): try: self.qbt_client.auth_log_in() except qbittorrentapi.LoginFailed as e: - print(e) - + logger.exception("Qbittorrent Login Failed", exc_info=True) return self def __exit__(self, exc_type, exc_val, exc_tb): @@ -28,8 +30,10 @@ def add_magnet(self, magnet_link: Union[str, List[str]], category: str = None) - category = None if category is not None: + logger.debug(f"Adding magnet with category {category}") self.qbt_client.torrents_add(urls=magnet_link, category=category) else: + logger.debug("Adding magnet without category") self.qbt_client.torrents_add(urls=magnet_link) def add_torrent(self, file_name: str, category: str = None) -> None: @@ -38,38 +42,48 @@ def add_torrent(self, file_name: str, category: str = None) -> None: try: if category is not None: + logger.debug(f"Adding torrent with category {category}") self.qbt_client.torrents_add(torrent_files=file_name, category=category) else: + logger.debug("Adding torrent without category") self.qbt_client.torrents_add(torrent_files=file_name) except qbittorrentapi.exceptions.UnsupportedMediaType415Error: pass def resume_all(self) -> None: + logger.debug("Resuming all torrents") self.qbt_client.torrents.resume.all() def pause_all(self) -> None: + logger.debug("Pausing all torrents") self.qbt_client.torrents.pause.all() def resume(self, torrent_hash: str) -> None: + logger.debug(f"Resuming torrent with has {torrent_hash}") self.qbt_client.torrents_resume(torrent_hashes=torrent_hash) def pause(self, torrent_hash: str) -> None: + logger.debug(f"Pausing torrent with hash {torrent_hash}") self.qbt_client.torrents_pause(torrent_hashes=torrent_hash) def delete_one_no_data(self, torrent_hash: str) -> None: + logger.debug(f"Deleting torrent with hash {torrent_hash} without removing files") self.qbt_client.torrents_delete(delete_files=False, torrent_hashes=torrent_hash) def delete_one_data(self, torrent_hash: str) -> None: + logger.debug(f"Deleting torrent with hash {torrent_hash} + removing files") self.qbt_client.torrents_delete(delete_files=True, torrent_hashes=torrent_hash) def delete_all_no_data(self) -> None: + logger.debug(f"Deleting all torrents") for i in self.qbt_client.torrents_info(): self.qbt_client.torrents_delete(delete_files=False, hashes=i.hash) def delete_all_data(self) -> None: + logger.debug(f"Deleting all torrent + files") for i in self.qbt_client.torrents_info(): self.qbt_client.torrents_delete(delete_files=True, hashes=i.hash) @@ -81,18 +95,23 @@ def get_categories(self): else: return - def get_torrent_info(self, data: str = None, status_filter: str = None, ): - if data is None: + def get_torrent_info(self, torrent_hash: str = None, status_filter: str = None): + if torrent_hash is None: + logger.debug("Getting torrents infos") return self.qbt_client.torrents_info(status_filter=status_filter) - return next(iter(self.qbt_client.torrents_info(status_filter=status_filter, torrent_hashes=data)), None) + logger.debug(f"Getting infos for torrent with hash {torrent_hash}") + return next(iter(self.qbt_client.torrents_info(status_filter=status_filter, torrent_hashes=torrent_hash)), None) def edit_category(self, name: str, save_path: str) -> None: + logger.debug(f"Editing category {name}, new save path: {save_path}") self.qbt_client.torrents_edit_category(name=name, save_path=save_path) def create_category(self, name: str, save_path: str) -> None: + logger.debug(f"Creating new category {name} with save path: {save_path}") self.qbt_client.torrents_create_category(name=name, save_path=save_path) - def remove_category(self, data: str) -> None: - self.qbt_client.torrents_remove_categories(categories=data) + def remove_category(self, name: str) -> None: + logger.debug(f"Removing category {name}") + self.qbt_client.torrents_remove_categories(categories=name) From 18bd2a7ce7190ed8994280763796956fd38dba5e Mon Sep 17 00:00:00 2001 From: ch3p4ll3 Date: Wed, 13 Dec 2023 19:45:53 +0100 Subject: [PATCH 02/25] split bot into multiple folders --- main.py | 4 +- src/__init__.py | 0 src/bot/__init__.py | 23 + src/bot/plugins/__init__.py | 0 src/bot/plugins/callbacks/__init__.py | 0 .../callbacks/add_torrents_callbacks.py | 15 + .../plugins/callbacks/category_callbacks.py | 95 ++++ .../plugins/callbacks/delete_all_callbacks.py | 31 ++ .../callbacks/delete_single_callbacks.py | 44 ++ src/bot/plugins/callbacks/list_callbacks.py | 22 + src/bot/plugins/callbacks/pause_callbacks.py | 24 + src/bot/plugins/callbacks/resume_callbacks.py | 24 + src/bot/plugins/callbacks/torrent_info.py | 42 ++ src/bot/plugins/commands.py | 39 ++ src/bot/plugins/common.py | 76 +++ src/bot/plugins/on_message.py | 70 +++ src/qbittorrent_bot.py | 445 ------------------ 17 files changed, 507 insertions(+), 447 deletions(-) create mode 100644 src/__init__.py create mode 100644 src/bot/__init__.py create mode 100644 src/bot/plugins/__init__.py create mode 100644 src/bot/plugins/callbacks/__init__.py create mode 100644 src/bot/plugins/callbacks/add_torrents_callbacks.py create mode 100644 src/bot/plugins/callbacks/category_callbacks.py create mode 100644 src/bot/plugins/callbacks/delete_all_callbacks.py create mode 100644 src/bot/plugins/callbacks/delete_single_callbacks.py create mode 100644 src/bot/plugins/callbacks/list_callbacks.py create mode 100644 src/bot/plugins/callbacks/pause_callbacks.py create mode 100644 src/bot/plugins/callbacks/resume_callbacks.py create mode 100644 src/bot/plugins/callbacks/torrent_info.py create mode 100644 src/bot/plugins/commands.py create mode 100644 src/bot/plugins/common.py create mode 100644 src/bot/plugins/on_message.py delete mode 100644 src/qbittorrent_bot.py diff --git a/main.py b/main.py index 4917970..6e50aa9 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,9 @@ -from src.qbittorrent_bot import app, scheduler +from src.bot import app, scheduler import logging from logging import handlers # Create a file handler -handler = logging.handlers.TimedRotatingFileHandler('QbittorrentBot.log', when='midnight', backupCount=10) +handler = logging.handlers.TimedRotatingFileHandler('./logs/QbittorrentBot.log', when='midnight', backupCount=10) # Create a format formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/bot/__init__.py b/src/bot/__init__.py new file mode 100644 index 0000000..707adc8 --- /dev/null +++ b/src/bot/__init__.py @@ -0,0 +1,23 @@ +from pyrogram import Client +from pyrogram.enums.parse_mode import ParseMode +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from src.utils import torrent_finished +from src.config import BOT_CONFIGS + + +plugins = dict( + root=".plugins" +) + + +app = Client( + "qbittorrent_bot", + api_id=BOT_CONFIGS.telegram.api_id, + api_hash=BOT_CONFIGS.telegram.api_hash, + bot_token=BOT_CONFIGS.telegram.bot_token, + parse_mode=ParseMode.MARKDOWN, + plugins=plugins +) + +scheduler = AsyncIOScheduler() +scheduler.add_job(torrent_finished, "interval", args=[app], seconds=60) diff --git a/src/bot/plugins/__init__.py b/src/bot/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/bot/plugins/callbacks/__init__.py b/src/bot/plugins/callbacks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/bot/plugins/callbacks/add_torrents_callbacks.py b/src/bot/plugins/callbacks/add_torrents_callbacks.py new file mode 100644 index 0000000..9253688 --- /dev/null +++ b/src/bot/plugins/callbacks/add_torrents_callbacks.py @@ -0,0 +1,15 @@ +from pyrogram import Client +from pyrogram.types import CallbackQuery +from .... import custom_filters, db_management + + +@Client.on_callback_query(filters=custom_filters.add_magnet_filter) +async def add_magnet_callback(client: Client, callback_query: CallbackQuery) -> None: + db_management.write_support(f"magnet#{callback_query.data.split('#')[1]}", callback_query.from_user.id) + await client.answer_callback_query(callback_query.id, "Send a magnet link") + + +@Client.on_callback_query(filters=custom_filters.add_torrent_filter) +async def add_torrent_callback(client: Client, callback_query: CallbackQuery) -> None: + db_management.write_support(f"torrent#{callback_query.data.split('#')[1]}", callback_query.from_user.id) + await client.answer_callback_query(callback_query.id, "Send a torrent file") diff --git a/src/bot/plugins/callbacks/category_callbacks.py b/src/bot/plugins/callbacks/category_callbacks.py new file mode 100644 index 0000000..fd798cc --- /dev/null +++ b/src/bot/plugins/callbacks/category_callbacks.py @@ -0,0 +1,95 @@ +from pyrogram import Client +from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, CallbackQuery +from pyrogram.errors.exceptions import MessageIdInvalid + +from .... import custom_filters, db_management +from ....qbittorrent_manager import QbittorrentManagement + + +@Client.on_callback_query(filters=custom_filters.add_category_filter) +async def add_category_callback(client: Client, callback_query: CallbackQuery) -> None: + db_management.write_support("category_name", callback_query.from_user.id) + button = InlineKeyboardMarkup([[InlineKeyboardButton("🔙 Menu", "menu")]]) + try: + await client.edit_message_text(callback_query.from_user.id, callback_query.message.id, + "Send the category name", reply_markup=button) + except MessageIdInvalid: + await client.send_message(callback_query.from_user.id, "Send the category name", reply_markup=button) + + +@Client.on_callback_query(filters=custom_filters.select_category_filter) +async def list_categories(client: Client, callback_query: CallbackQuery): + buttons = [] + + with QbittorrentManagement() as qb: + categories = qb.get_categories() + + if categories is None: + buttons.append([InlineKeyboardButton("🔙 Menu", "menu")]) + await client.edit_message_text(callback_query.from_user.id, callback_query.message.id, + "There are no categories", reply_markup=InlineKeyboardMarkup(buttons)) + return + + for key, i in enumerate(categories): + buttons.append([InlineKeyboardButton(i, f"{callback_query.data.split('#')[1]}#{i}")]) + + buttons.append([InlineKeyboardButton("🔙 Menu", "menu")]) + + try: + await client.edit_message_text(callback_query.from_user.id, callback_query.message.id, + "Choose a category:", reply_markup=InlineKeyboardMarkup(buttons)) + except MessageIdInvalid: + await client.send_message(callback_query.from_user.id, "Choose a category:", + reply_markup=InlineKeyboardMarkup(buttons)) + + +@Client.on_callback_query(filters=custom_filters.remove_category_filter) +async def remove_category_callback(client: Client, callback_query: CallbackQuery) -> None: + buttons = [[InlineKeyboardButton("🔙 Menu", "menu")]] + + with QbittorrentManagement() as qb: + qb.remove_category(callback_query.data.split("#")[1]) + + await client.edit_message_text(callback_query.from_user.id, callback_query.message.id, + f"The category {callback_query.data.split('#')[1]} has been removed", + reply_markup=InlineKeyboardMarkup(buttons)) + + +@Client.on_callback_query(filters=custom_filters.modify_category_filter) +async def modify_category_callback(client: Client, callback_query: CallbackQuery) -> None: + buttons = [[InlineKeyboardButton("🔙 Menu", "menu")]] + + db_management.write_support(f"category_dir_modify#{callback_query.data.split('#')[1]}", callback_query.from_user.id) + await client.edit_message_text(callback_query.from_user.id, callback_query.message.id, + f"Send new path for category {callback_query.data.split('#')[1]}", + reply_markup=InlineKeyboardMarkup(buttons)) + + +@Client.on_callback_query(filters=custom_filters.category_filter) +async def category(client: Client, callback_query: CallbackQuery) -> None: + buttons = [] + + with QbittorrentManagement() as qb: + categories = qb.get_categories() + + if categories is None: + if "magnet" in callback_query.data: + await add_magnet_callback(client, callback_query) + + else: + await add_torrent_callback(client, callback_query) + + return + + for key, i in enumerate(categories): + buttons.append([InlineKeyboardButton(i, f"{callback_query.data.split('#')[1]}#{i}")]) + + buttons.append([InlineKeyboardButton("None", f"{callback_query.data.split('#')[1]}#None")]) + buttons.append([InlineKeyboardButton("🔙 Menu", "menu")]) + + try: + await client.edit_message_text(callback_query.from_user.id, callback_query.message.id, + "Choose a category:", reply_markup=InlineKeyboardMarkup(buttons)) + except MessageIdInvalid: + await client.send_message(callback_query.from_user.id, "Choose a category:", + reply_markup=InlineKeyboardMarkup(buttons)) diff --git a/src/bot/plugins/callbacks/delete_all_callbacks.py b/src/bot/plugins/callbacks/delete_all_callbacks.py new file mode 100644 index 0000000..306b784 --- /dev/null +++ b/src/bot/plugins/callbacks/delete_all_callbacks.py @@ -0,0 +1,31 @@ +from pyrogram import Client +from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, CallbackQuery + +from .... import custom_filters +from ....qbittorrent_manager import QbittorrentManagement +from ..common import send_menu + + +@Client.on_callback_query(filters=custom_filters.delete_all_filter) +async def delete_all_callback(client: Client, callback_query: CallbackQuery) -> None: + buttons = [[InlineKeyboardButton("🗑 Delete all torrents", "delete_all_no_data")], + [InlineKeyboardButton("🗑 Delete all torrents and data", "delete_all_data")], + [InlineKeyboardButton("🔙 Menu", "menu")]] + await client.edit_message_reply_markup(callback_query.from_user.id, callback_query.message.id, + reply_markup=InlineKeyboardMarkup(buttons)) + + +@Client.on_callback_query(filters=custom_filters.delete_all_no_data_filter) +async def delete_all_with_no_data_callback(client: Client, callback_query: CallbackQuery) -> None: + with QbittorrentManagement() as qb: + qb.delete_all_no_data() + await client.answer_callback_query(callback_query.id, "Deleted only torrents") + await send_menu(client, callback_query.message.id, callback_query.from_user.id) + + +@Client.on_callback_query(filters=custom_filters.delete_all_data_filter) +async def delete_all_with_data_callback(client: Client, callback_query: CallbackQuery) -> None: + with QbittorrentManagement() as qb: + qb.delete_all_data() + await client.answer_callback_query(callback_query.id, "Deleted All+Torrents") + await send_menu(client, callback_query.message.id, callback_query.from_user.id) diff --git a/src/bot/plugins/callbacks/delete_single_callbacks.py b/src/bot/plugins/callbacks/delete_single_callbacks.py new file mode 100644 index 0000000..03c60a2 --- /dev/null +++ b/src/bot/plugins/callbacks/delete_single_callbacks.py @@ -0,0 +1,44 @@ +from pyrogram import Client +from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, CallbackQuery + +from .... import custom_filters +from ....qbittorrent_manager import QbittorrentManagement +from ..common import list_active_torrents, send_menu + + +@Client.on_callback_query(filters=custom_filters.delete_one_filter) +async def delete_callback(client: Client, callback_query: CallbackQuery) -> None: + if callback_query.data.find("#") == -1: + await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "delete_one") + + else: + + buttons = [ + [InlineKeyboardButton("🗑 Delete torrent", f"delete_one_no_data#{callback_query.data.split('#')[1]}")], + [InlineKeyboardButton("🗑 Delete torrent and data", f"delete_one_data#{callback_query.data.split('#')[1]}")], + [InlineKeyboardButton("🔙 Menu", "menu")]] + + await client.edit_message_reply_markup(callback_query.from_user.id, callback_query.message.id, + reply_markup=InlineKeyboardMarkup(buttons)) + + +@Client.on_callback_query(filters=custom_filters.delete_one_no_data_filter) +async def delete_no_data_callback(client: Client, callback_query: CallbackQuery) -> None: + if callback_query.data.find("#") == -1: + await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "delete_one_no_data") + + else: + with QbittorrentManagement() as qb: + qb.delete_one_no_data(torrent_hash=callback_query.data.split("#")[1]) + await send_menu(client, callback_query.message.id, callback_query.from_user.id) + + +@Client.on_callback_query(filters=custom_filters.delete_one_data_filter) +async def delete_with_data_callback(client: Client, callback_query: CallbackQuery) -> None: + if callback_query.data.find("#") == -1: + await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "delete_one_data") + + else: + with QbittorrentManagement() as qb: + qb.delete_one_data(torrent_hash=callback_query.data.split("#")[1]) + await send_menu(client, callback_query.message.id, callback_query.from_user.id) diff --git a/src/bot/plugins/callbacks/list_callbacks.py b/src/bot/plugins/callbacks/list_callbacks.py new file mode 100644 index 0000000..da51f06 --- /dev/null +++ b/src/bot/plugins/callbacks/list_callbacks.py @@ -0,0 +1,22 @@ +from pyrogram import Client +from pyrogram.types import CallbackQuery +from .... import custom_filters +from .... import db_management +from ..common import list_active_torrents, send_menu + + +@Client.on_callback_query(filters=custom_filters.list_filter) +async def list_callback(client: Client, callback_query: CallbackQuery) -> None: + await list_active_torrents(client, 0, callback_query.from_user.id, callback_query.message.id, + db_management.read_support(callback_query.from_user.id)) + + +@Client.on_callback_query(filters=custom_filters.list_by_status_filter) +async def list_by_status_callback(client: Client, callback_query: CallbackQuery) -> None: + status_filter = callback_query.data.split("#")[1] + await list_active_torrents(client,0, callback_query.from_user.id, callback_query.message.id, + db_management.read_support(callback_query.from_user.id), status_filter=status_filter) + +@Client.on_callback_query(filters=custom_filters.menu_filter) +async def menu_callback(client: Client, callback_query: CallbackQuery) -> None: + await send_menu(client, callback_query.message.id, callback_query.from_user.id) diff --git a/src/bot/plugins/callbacks/pause_callbacks.py b/src/bot/plugins/callbacks/pause_callbacks.py new file mode 100644 index 0000000..63b5768 --- /dev/null +++ b/src/bot/plugins/callbacks/pause_callbacks.py @@ -0,0 +1,24 @@ +from pyrogram import Client +from pyrogram.types import CallbackQuery + +from .... import custom_filters +from ....qbittorrent_manager import QbittorrentManagement +from ..common import list_active_torrents, send_menu + + +@Client.on_callback_query(filters=custom_filters.pause_all_filter) +async def pause_all_callback(client: Client, callback_query: CallbackQuery) -> None: + with QbittorrentManagement() as qb: + qb.pause_all() + await client.answer_callback_query(callback_query.id, "Paused all torrents") + + +@Client.on_callback_query(filters=custom_filters.pause_filter) +async def pause_callback(client: Client, callback_query: CallbackQuery) -> None: + if callback_query.data.find("#") == -1: + await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "pause") + + else: + with QbittorrentManagement() as qb: + qb.pause(torrent_hash=callback_query.data.split("#")[1]) + await send_menu(client, callback_query.message.id, callback_query.from_user.id) diff --git a/src/bot/plugins/callbacks/resume_callbacks.py b/src/bot/plugins/callbacks/resume_callbacks.py new file mode 100644 index 0000000..8cd157e --- /dev/null +++ b/src/bot/plugins/callbacks/resume_callbacks.py @@ -0,0 +1,24 @@ +from pyrogram import Client +from pyrogram.types import CallbackQuery + +from .... import custom_filters +from ....qbittorrent_manager import QbittorrentManagement +from ..common import list_active_torrents, send_menu + + +@Client.on_callback_query(filters=custom_filters.resume_all_filter) +async def resume_all_callback(client: Client, callback_query: CallbackQuery) -> None: + with QbittorrentManagement() as qb: + qb.resume_all() + await client.answer_callback_query(callback_query.id, "Resumed all torrents") + + +@Client.on_callback_query(filters=custom_filters.resume_filter) +async def resume_callback(client: Client, callback_query: CallbackQuery) -> None: + if callback_query.data.find("#") == -1: + await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "resume") + + else: + with QbittorrentManagement() as qb: + qb.resume(torrent_hash=callback_query.data.split("#")[1]) + await send_menu(client, callback_query.message.id, callback_query.from_user.id) diff --git a/src/bot/plugins/callbacks/torrent_info.py b/src/bot/plugins/callbacks/torrent_info.py new file mode 100644 index 0000000..74e0efa --- /dev/null +++ b/src/bot/plugins/callbacks/torrent_info.py @@ -0,0 +1,42 @@ +from tqdm import tqdm + +from pyrogram import Client +from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, CallbackQuery + +from .... import custom_filters +from ....qbittorrent_manager import QbittorrentManagement +from ....utils import convert_size, convert_eta + + +@Client.on_callback_query(filters=custom_filters.torrentInfo_filter) +async def torrent_info_callback(client: Client, callback_query: CallbackQuery) -> None: + with QbittorrentManagement() as qb: + torrent = qb.get_torrent_info(callback_query.data.split("#")[1]) + + text = f"{torrent.name}\n" + + if torrent.progress == 1: + text += "**COMPLETED**\n" + + else: + text += f"{tqdm.format_meter(torrent.progress, 1, 0, bar_format='{l_bar}{bar}|')}\n" + + if "stalled" not in torrent.state: + text += (f"**State:** {torrent.state.capitalize()} \n" + f"**Download Speed:** {convert_size(torrent.dlspeed)}/s\n") + + text += f"**Size:** {convert_size(torrent.size)}\n" + + if "stalled" not in torrent.state: + text += f"**ETA:** {convert_eta(int(torrent.eta))}\n" + + if torrent.category: + text += f"**Category:** {torrent.category}\n" + + buttons = [[InlineKeyboardButton("⏸ Pause", f"pause#{callback_query.data.split('#')[1]}")], + [InlineKeyboardButton("▶️ Resume", f"resume#{callback_query.data.split('#')[1]}")], + [InlineKeyboardButton("🗑 Delete", f"delete_one#{callback_query.data.split('#')[1]}")], + [InlineKeyboardButton("🔙 Menu", "menu")]] + + await client.edit_message_text(callback_query.from_user.id, callback_query.message.id, text=text, + reply_markup=InlineKeyboardMarkup(buttons)) diff --git a/src/bot/plugins/commands.py b/src/bot/plugins/commands.py new file mode 100644 index 0000000..529b91b --- /dev/null +++ b/src/bot/plugins/commands.py @@ -0,0 +1,39 @@ +from pyrogram import Client, filters +from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message +import psutil + +from ...utils import convert_size +from ...config import BOT_CONFIGS +from .common import send_menu + + +@Client.on_message(filters=filters.command("start")) +async def start_command(client: Client, message: Message) -> None: + """Start the bot.""" + if message.from_user.id in [i.user_id for i in BOT_CONFIGS.users]: + await send_menu(client, message.id, message.chat.id) + + else: + button = InlineKeyboardMarkup([[InlineKeyboardButton("Github", + url="https://github.com/ch3p4ll3/QBittorrentBot/")]]) + await client.send_message(message.chat.id, "You are not authorized to use this bot", reply_markup=button) + + +@Client.on_message(filters=filters.command("stats")) +async def stats_command(client: Client, message: Message) -> None: + if message.from_user.id in [i.user_id for i in BOT_CONFIGS.users]: + + stats_text = f"**============SYSTEM============**\n" \ + f"**CPU Usage:** {psutil.cpu_percent(interval=None)}%\n" \ + f"**CPU Temp:** {psutil.sensors_temperatures()['coretemp'][0].current}°C\n" \ + f"**Free Memory:** {convert_size(psutil.virtual_memory().available)} of " \ + f"{convert_size(psutil.virtual_memory().total)} ({psutil.virtual_memory().percent}%)\n" \ + f"**Disks usage:** {convert_size(psutil.disk_usage('/mnt').used)} of " \ + f"{convert_size(psutil.disk_usage('/mnt').total)} ({psutil.disk_usage('/mnt').percent}%)" + + await client.send_message(message.chat.id, stats_text) + + else: + button = InlineKeyboardMarkup([[InlineKeyboardButton("Github", + url="https://github.com/ch3p4ll3/QBittorrentBot/")]]) + await client.send_message(message.chat.id, "You are not authorized to use this bot", reply_markup=button) diff --git a/src/bot/plugins/common.py b/src/bot/plugins/common.py new file mode 100644 index 0000000..6d398de --- /dev/null +++ b/src/bot/plugins/common.py @@ -0,0 +1,76 @@ +from pyrogram import Client +from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from pyrogram.errors.exceptions import MessageIdInvalid +from ... import db_management +from ...qbittorrent_manager import QbittorrentManagement + + +async def send_menu(client: Client, message, chat) -> None: + db_management.write_support("None", chat) + buttons = [[InlineKeyboardButton("📝 List", "list")], + [InlineKeyboardButton("➕ Add Magnet", "category#add_magnet"), + InlineKeyboardButton("➕ Add Torrent", "category#add_torrent")], + [InlineKeyboardButton("⏸ Pause", "pause"), + InlineKeyboardButton("▶️ Resume", "resume")], + [InlineKeyboardButton("⏸ Pause All", "pause_all"), + InlineKeyboardButton("▶️ Resume All", "resume_all")], + [InlineKeyboardButton("🗑 Delete", "delete_one"), + InlineKeyboardButton("🗑 Delete All", "delete_all")], + [InlineKeyboardButton("➕ Add Category", "add_category"), + InlineKeyboardButton("🗑 Remove Category", "select_category#remove_category")], + [InlineKeyboardButton("📝 Modify Category", "select_category#modify_category")]] + + try: + await client.edit_message_text(chat, message, text="Qbittorrent Control", + reply_markup=InlineKeyboardMarkup(buttons)) + + except MessageIdInvalid: + await client.send_message(chat, text="Qbittorrent Control", reply_markup=InlineKeyboardMarkup(buttons)) + + +async def list_active_torrents(client: Client, n, chat, message, callback, status_filter: str = None) -> None: + with QbittorrentManagement() as qb: + torrents = qb.get_torrent_info(status_filter=status_filter) + + def render_categories_buttons(): + return [ + InlineKeyboardButton(f"⏳ {'*' if status_filter == 'downloading' else ''} Downloading", + "by_status_list#downloading"), + InlineKeyboardButton(f"✔️ {'*' if status_filter == 'completed' else ''} Completed", + "by_status_list#completed"), + InlineKeyboardButton(f"⏸️ {'*' if status_filter == 'paused' else ''} Paused", "by_status_list#paused"), + ] + + categories_buttons = render_categories_buttons() + if not torrents: + buttons = [categories_buttons, [InlineKeyboardButton("🔙 Menu", "menu")]] + try: + await client.edit_message_text(chat, message, "There are no torrents", + reply_markup=InlineKeyboardMarkup(buttons)) + except MessageIdInvalid: + await client.send_message(chat, "There are no torrents", reply_markup=InlineKeyboardMarkup(buttons)) + return + + buttons = [categories_buttons] + + if n == 1: + for key, i in enumerate(torrents): + buttons.append([InlineKeyboardButton(i.name, f"{callback}#{i.info.hash}")]) + + buttons.append([InlineKeyboardButton("🔙 Menu", "menu")]) + + try: + await client.edit_message_reply_markup(chat, message, reply_markup=InlineKeyboardMarkup(buttons)) + except MessageIdInvalid: + await client.send_message(chat, "Qbittorrent Control", reply_markup=InlineKeyboardMarkup(buttons)) + + else: + for key, i in enumerate(torrents): + buttons.append([InlineKeyboardButton(i.name, f"torrentInfo#{i.info.hash}")]) + + buttons.append([InlineKeyboardButton("🔙 Menu", "menu")]) + + try: + await client.edit_message_reply_markup(chat, message, reply_markup=InlineKeyboardMarkup(buttons)) + except MessageIdInvalid: + await client.send_message(chat, "Qbittorrent Control", reply_markup=InlineKeyboardMarkup(buttons)) diff --git a/src/bot/plugins/on_message.py b/src/bot/plugins/on_message.py new file mode 100644 index 0000000..5ed3782 --- /dev/null +++ b/src/bot/plugins/on_message.py @@ -0,0 +1,70 @@ +import os +import tempfile + +from pyrogram import Client, filters +from pyrogram.types import Message +from ...qbittorrent_manager import QbittorrentManagement +from ... import db_management +from .common import send_menu + + +@Client.on_message(filters=~filters.me) +async def on_text(client: Client, message: Message) -> None: + action = db_management.read_support(message.from_user.id) + + if "magnet" in action: + if message.text.startswith("magnet:?xt"): + magnet_link = message.text.split("\n") + category = db_management.read_support(message.from_user.id).split("#")[1] + + with QbittorrentManagement() as qb: + qb.add_magnet(magnet_link=magnet_link, + category=category) + + await send_menu(client, message.id, message.from_user.id) + db_management.write_support("None", message.from_user.id) + + else: + await client.send_message(message.from_user.id, "This magnet link is invalid! Retry") + + elif "torrent" in action and message.document: + if ".torrent" in message.document.file_name: + with tempfile.TemporaryDirectory() as tempdir: + name = f"{tempdir}/{message.document.file_name}" + category = db_management.read_support(message.from_user.id).split("#")[1] + await message.download(name) + + with QbittorrentManagement() as qb: + qb.add_torrent(file_name=name, + category=category) + await send_menu(client, message.id, message.from_user.id) + db_management.write_support("None", message.from_user.id) + + else: + await client.send_message(message.from_user.id, "This is not a torrent file! Retry") + + elif action == "category_name": + db_management.write_support(f"category_dir#{message.text}", message.from_user.id) + await client.send_message(message.from_user.id, f"now send me the path for the category {message.text}") + + elif "category_dir" in action: + if os.path.exists(message.text): + name = db_management.read_support(message.from_user.id).split("#")[1] + + if "modify" in action: + with QbittorrentManagement() as qb: + qb.edit_category(name=name, + save_path=message.text) + await send_menu(client, message.id, message.from_user.id) + return + + with QbittorrentManagement() as qb: + qb.create_category(name=name, + save_path=message.text) + await send_menu(client, message.id, message.from_user.id) + + else: + await client.send_message(message.from_user.id, "The path entered does not exist! Retry") + + else: + await client.send_message(message.from_user.id, "The command does not exist") diff --git a/src/qbittorrent_bot.py b/src/qbittorrent_bot.py deleted file mode 100644 index f941882..0000000 --- a/src/qbittorrent_bot.py +++ /dev/null @@ -1,445 +0,0 @@ -import os -import tempfile - -from tqdm import tqdm - -from pyrogram import Client, filters -from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message, CallbackQuery -from pyrogram.errors.exceptions import MessageIdInvalid -from pyrogram.enums.parse_mode import ParseMode -import psutil - -from src import custom_filters -from src.qbittorrent_manager import QbittorrentManagement -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from src.utils import torrent_finished, convert_size, convert_eta -from src.config import BOT_CONFIGS -from src import db_management - -app = Client( - "qbittorrent_bot", - api_id=BOT_CONFIGS.telegram.api_id, - api_hash=BOT_CONFIGS.telegram.api_hash, - bot_token=BOT_CONFIGS.telegram.bot_token, - parse_mode=ParseMode.MARKDOWN -) - -scheduler = AsyncIOScheduler() -scheduler.add_job(torrent_finished, "interval", args=[app], seconds=60) - - -async def send_menu(client: Client, message, chat) -> None: - db_management.write_support("None", chat) - buttons = [[InlineKeyboardButton("📝 List", "list")], - [InlineKeyboardButton("➕ Add Magnet", "category#add_magnet"), - InlineKeyboardButton("➕ Add Torrent", "category#add_torrent")], - [InlineKeyboardButton("⏸ Pause", "pause"), - InlineKeyboardButton("▶️ Resume", "resume")], - [InlineKeyboardButton("⏸ Pause All", "pause_all"), - InlineKeyboardButton("▶️ Resume All", "resume_all")], - [InlineKeyboardButton("🗑 Delete", "delete_one"), - InlineKeyboardButton("🗑 Delete All", "delete_all")], - [InlineKeyboardButton("➕ Add Category", "add_category"), - InlineKeyboardButton("🗑 Remove Category", "select_category#remove_category")], - [InlineKeyboardButton("📝 Modify Category", "select_category#modify_category")]] - - try: - await client.edit_message_text(chat, message, text="Qbittorrent Control", - reply_markup=InlineKeyboardMarkup(buttons)) - - except MessageIdInvalid: - await client.send_message(chat, text="Qbittorrent Control", reply_markup=InlineKeyboardMarkup(buttons)) - - -async def list_active_torrents(client: Client, n, chat, message, callback, status_filter: str = None) -> None: - with QbittorrentManagement() as qb: - torrents = qb.get_torrent_info(status_filter=status_filter) - - def render_categories_buttons(): - return [ - InlineKeyboardButton(f"⏳ {'*' if status_filter == 'downloading' else ''} Downloading", - "by_status_list#downloading"), - InlineKeyboardButton(f"✔️ {'*' if status_filter == 'completed' else ''} Completed", - "by_status_list#completed"), - InlineKeyboardButton(f"⏸️ {'*' if status_filter == 'paused' else ''} Paused", "by_status_list#paused"), - ] - - categories_buttons = render_categories_buttons() - if not torrents: - buttons = [categories_buttons, [InlineKeyboardButton("🔙 Menu", "menu")]] - try: - await client.edit_message_text(chat, message, "There are no torrents", - reply_markup=InlineKeyboardMarkup(buttons)) - except MessageIdInvalid: - await client.send_message(chat, "There are no torrents", reply_markup=InlineKeyboardMarkup(buttons)) - return - - buttons = [categories_buttons] - - if n == 1: - for key, i in enumerate(torrents): - buttons.append([InlineKeyboardButton(i.name, f"{callback}#{i.info.hash}")]) - - buttons.append([InlineKeyboardButton("🔙 Menu", "menu")]) - - try: - await client.edit_message_reply_markup(chat, message, reply_markup=InlineKeyboardMarkup(buttons)) - except MessageIdInvalid: - await client.send_message(chat, "Qbittorrent Control", reply_markup=InlineKeyboardMarkup(buttons)) - - else: - for key, i in enumerate(torrents): - buttons.append([InlineKeyboardButton(i.name, f"torrentInfo#{i.info.hash}")]) - - buttons.append([InlineKeyboardButton("🔙 Menu", "menu")]) - - try: - await client.edit_message_reply_markup(chat, message, reply_markup=InlineKeyboardMarkup(buttons)) - except MessageIdInvalid: - await client.send_message(chat, "Qbittorrent Control", reply_markup=InlineKeyboardMarkup(buttons)) - - -@app.on_message(filters=filters.command("start")) -async def start_command(client: Client, message: Message) -> None: - """Start the bot.""" - if message.from_user.id in [i.user_id for i in BOT_CONFIGS.users]: - await send_menu(client, message.id, message.chat.id) - - else: - button = InlineKeyboardMarkup([[InlineKeyboardButton("Github", - url="https://github.com/ch3p4ll3/QBittorrentBot/")]]) - await client.send_message(message.chat.id, "You are not authorized to use this bot", reply_markup=button) - - -@app.on_message(filters=filters.command("stats")) -async def stats_command(client: Client, message: Message) -> None: - if message.from_user.id in [i.user_id for i in BOT_CONFIGS.users]: - - stats_text = f"**============SYSTEM============**\n" \ - f"**CPU Usage:** {psutil.cpu_percent(interval=None)}%\n" \ - f"**CPU Temp:** {psutil.sensors_temperatures()['coretemp'][0].current}°C\n" \ - f"**Free Memory:** {convert_size(psutil.virtual_memory().available)} of " \ - f"{convert_size(psutil.virtual_memory().total)} ({psutil.virtual_memory().percent}%)\n" \ - f"**Disks usage:** {convert_size(psutil.disk_usage('/mnt').used)} of " \ - f"{convert_size(psutil.disk_usage('/mnt').total)} ({psutil.disk_usage('/mnt').percent}%)" - - await client.send_message(message.chat.id, stats_text) - - else: - button = InlineKeyboardMarkup([[InlineKeyboardButton("Github", - url="https://github.com/ch3p4ll3/QBittorrentBot/")]]) - await client.send_message(message.chat.id, "You are not authorized to use this bot", reply_markup=button) - - -@app.on_callback_query(filters=custom_filters.add_category_filter) -async def add_category_callback(client: Client, callback_query: CallbackQuery) -> None: - db_management.write_support("category_name", callback_query.from_user.id) - button = InlineKeyboardMarkup([[InlineKeyboardButton("🔙 Menu", "menu")]]) - try: - await client.edit_message_text(callback_query.from_user.id, callback_query.message.id, - "Send the category name", reply_markup=button) - except MessageIdInvalid: - await client.send_message(callback_query.from_user.id, "Send the category name", reply_markup=button) - - -@app.on_callback_query(filters=custom_filters.select_category_filter) -async def list_categories(client: Client, callback_query: CallbackQuery): - buttons = [] - - with QbittorrentManagement() as qb: - categories = qb.get_categories() - - if categories is None: - buttons.append([InlineKeyboardButton("🔙 Menu", "menu")]) - await client.edit_message_text(callback_query.from_user.id, callback_query.message.id, - "There are no categories", reply_markup=InlineKeyboardMarkup(buttons)) - return - - for key, i in enumerate(categories): - buttons.append([InlineKeyboardButton(i, f"{callback_query.data.split('#')[1]}#{i}")]) - - buttons.append([InlineKeyboardButton("🔙 Menu", "menu")]) - - try: - await client.edit_message_text(callback_query.from_user.id, callback_query.message.id, - "Choose a category:", reply_markup=InlineKeyboardMarkup(buttons)) - except MessageIdInvalid: - await client.send_message(callback_query.from_user.id, "Choose a category:", - reply_markup=InlineKeyboardMarkup(buttons)) - - -@app.on_callback_query(filters=custom_filters.remove_category_filter) -async def remove_category_callback(client: Client, callback_query: CallbackQuery) -> None: - buttons = [[InlineKeyboardButton("🔙 Menu", "menu")]] - - with QbittorrentManagement() as qb: - qb.remove_category(data=callback_query.data.split("#")[1]) - - await client.edit_message_text(callback_query.from_user.id, callback_query.message.id, - f"The category {callback_query.data.split('#')[1]} has been removed", - reply_markup=InlineKeyboardMarkup(buttons)) - - -@app.on_callback_query(filters=custom_filters.modify_category_filter) -async def modify_category_callback(client: Client, callback_query: CallbackQuery) -> None: - buttons = [[InlineKeyboardButton("🔙 Menu", "menu")]] - - db_management.write_support(f"category_dir_modify#{callback_query.data.split('#')[1]}", callback_query.from_user.id) - await client.edit_message_text(callback_query.from_user.id, callback_query.message.id, - f"Send new path for category {callback_query.data.split('#')[1]}", - reply_markup=InlineKeyboardMarkup(buttons)) - - -@app.on_callback_query(filters=custom_filters.category_filter) -async def category(client: Client, callback_query: CallbackQuery) -> None: - buttons = [] - - with QbittorrentManagement() as qb: - categories = qb.get_categories() - - if categories is None: - if "magnet" in callback_query.data: - await add_magnet_callback(client, callback_query) - - else: - await add_torrent_callback(client, callback_query) - - return - - for key, i in enumerate(categories): - buttons.append([InlineKeyboardButton(i, f"{callback_query.data.split('#')[1]}#{i}")]) - - buttons.append([InlineKeyboardButton("None", f"{callback_query.data.split('#')[1]}#None")]) - buttons.append([InlineKeyboardButton("🔙 Menu", "menu")]) - - try: - await client.edit_message_text(callback_query.from_user.id, callback_query.message.id, - "Choose a category:", reply_markup=InlineKeyboardMarkup(buttons)) - except MessageIdInvalid: - await client.send_message(callback_query.from_user.id, "Choose a category:", - reply_markup=InlineKeyboardMarkup(buttons)) - - -@app.on_callback_query(filters=custom_filters.menu_filter) -async def menu_callback(client: Client, callback_query: CallbackQuery) -> None: - await send_menu(client, callback_query.message.id, callback_query.from_user.id) - - -@app.on_callback_query(filters=custom_filters.list_filter) -async def list_callback(client: Client, callback_query: CallbackQuery) -> None: - await list_active_torrents(client, 0, callback_query.from_user.id, callback_query.message.id, - db_management.read_support(callback_query.from_user.id)) - - -@app.on_callback_query(filters=custom_filters.list_by_status_filter) -async def list_by_status_callback(client: Client, callback_query: CallbackQuery) -> None: - status_filter = callback_query.data.split("#")[1] - await list_active_torrents(client,0, callback_query.from_user.id, callback_query.message.id, - db_management.read_support(callback_query.from_user.id), status_filter=status_filter) - - -@app.on_callback_query(filters=custom_filters.add_magnet_filter) -async def add_magnet_callback(client: Client, callback_query: CallbackQuery) -> None: - db_management.write_support(f"magnet#{callback_query.data.split('#')[1]}", callback_query.from_user.id) - await client.answer_callback_query(callback_query.id, "Send a magnet link") - - -@app.on_callback_query(filters=custom_filters.add_torrent_filter) -async def add_torrent_callback(client: Client, callback_query: CallbackQuery) -> None: - db_management.write_support(f"torrent#{callback_query.data.split('#')[1]}", callback_query.from_user.id) - await client.answer_callback_query(callback_query.id, "Send a torrent file") - - -@app.on_callback_query(filters=custom_filters.pause_all_filter) -async def pause_all_callback(client: Client, callback_query: CallbackQuery) -> None: - with QbittorrentManagement() as qb: - qb.pause_all() - await client.answer_callback_query(callback_query.id, "Paused all torrents") - - -@app.on_callback_query(filters=custom_filters.resume_all_filter) -async def resume_all_callback(client: Client, callback_query: CallbackQuery) -> None: - with QbittorrentManagement() as qb: - qb.resume_all() - await client.answer_callback_query(callback_query.id, "Resumed all torrents") - - -@app.on_callback_query(filters=custom_filters.pause_filter) -async def pause_callback(client: Client, callback_query: CallbackQuery) -> None: - if callback_query.data.find("#") == -1: - await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "pause") - - else: - with QbittorrentManagement() as qb: - qb.pause(torrent_hash=callback_query.data.split("#")[1]) - await send_menu(client, callback_query.message.id, callback_query.from_user.id) - - -@app.on_callback_query(filters=custom_filters.resume_filter) -async def resume_callback(client: Client, callback_query: CallbackQuery) -> None: - if callback_query.data.find("#") == -1: - await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "resume") - - else: - with QbittorrentManagement() as qb: - qb.resume(torrent_hash=callback_query.data.split("#")[1]) - await send_menu(client, callback_query.message.id, callback_query.from_user.id) - - -@app.on_callback_query(filters=custom_filters.delete_one_filter) -async def delete_callback(client: Client, callback_query: CallbackQuery) -> None: - if callback_query.data.find("#") == -1: - await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "delete_one") - - else: - - buttons = [ - [InlineKeyboardButton("🗑 Delete torrent", f"delete_one_no_data#{callback_query.data.split('#')[1]}")], - [InlineKeyboardButton("🗑 Delete torrent and data", f"delete_one_data#{callback_query.data.split('#')[1]}")], - [InlineKeyboardButton("🔙 Menu", "menu")]] - - await client.edit_message_reply_markup(callback_query.from_user.id, callback_query.message.id, - reply_markup=InlineKeyboardMarkup(buttons)) - - -@app.on_callback_query(filters=custom_filters.delete_one_no_data_filter) -async def delete_no_data_callback(client: Client, callback_query: CallbackQuery) -> None: - if callback_query.data.find("#") == -1: - await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "delete_one_no_data") - - else: - with QbittorrentManagement() as qb: - qb.delete_one_no_data(torrent_hash=callback_query.data.split("#")[1]) - await send_menu(client, callback_query.message.id, callback_query.from_user.id) - - -@app.on_callback_query(filters=custom_filters.delete_one_data_filter) -async def delete_with_data_callback(client: Client, callback_query: CallbackQuery) -> None: - if callback_query.data.find("#") == -1: - await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "delete_one_data") - - else: - with QbittorrentManagement() as qb: - qb.delete_one_data(torrent_hash=callback_query.data.split("#")[1]) - await send_menu(client, callback_query.message.id, callback_query.from_user.id) - - -@app.on_callback_query(filters=custom_filters.delete_all_filter) -async def delete_all_callback(client: Client, callback_query: CallbackQuery) -> None: - buttons = [[InlineKeyboardButton("🗑 Delete all torrents", "delete_all_no_data")], - [InlineKeyboardButton("🗑 Delete all torrents and data", "delete_all_data")], - [InlineKeyboardButton("🔙 Menu", "menu")]] - await client.edit_message_reply_markup(callback_query.from_user.id, callback_query.message.id, - reply_markup=InlineKeyboardMarkup(buttons)) - - -@app.on_callback_query(filters=custom_filters.delete_all_no_data_filter) -async def delete_all_with_no_data_callback(client: Client, callback_query: CallbackQuery) -> None: - with QbittorrentManagement() as qb: - qb.delete_all_no_data() - await client.answer_callback_query(callback_query.id, "Deleted only torrents") - await send_menu(client, callback_query.message.id, callback_query.from_user.id) - - -@app.on_callback_query(filters=custom_filters.delete_all_data_filter) -async def delete_all_with_data_callback(client: Client, callback_query: CallbackQuery) -> None: - with QbittorrentManagement() as qb: - qb.delete_all_data() - await client.answer_callback_query(callback_query.id, "Deleted All+Torrents") - await send_menu(client, callback_query.message.id, callback_query.from_user.id) - - -@app.on_callback_query(filters=custom_filters.torrentInfo_filter) -async def torrent_info_callback(client: Client, callback_query: CallbackQuery) -> None: - with QbittorrentManagement() as qb: - torrent = qb.get_torrent_info(data=callback_query.data.split("#")[1]) - - text = f"{torrent.name}\n" - - if torrent.progress == 1: - text += "**COMPLETED**\n" - - else: - text += f"{tqdm.format_meter(torrent.progress, 1, 0, bar_format='{l_bar}{bar}|')}\n" - - if "stalled" not in torrent.state: - text += (f"**State:** {torrent.state.capitalize()} \n" - f"**Download Speed:** {convert_size(torrent.dlspeed)}/s\n") - - text += f"**Size:** {convert_size(torrent.size)}\n" - - if "stalled" not in torrent.state: - text += f"**ETA:** {convert_eta(int(torrent.eta))}\n" - - if torrent.category: - text += f"**Category:** {torrent.category}\n" - - buttons = [[InlineKeyboardButton("⏸ Pause", f"pause#{callback_query.data.split('#')[1]}")], - [InlineKeyboardButton("▶️ Resume", f"resume#{callback_query.data.split('#')[1]}")], - [InlineKeyboardButton("🗑 Delete", f"delete_one#{callback_query.data.split('#')[1]}")], - [InlineKeyboardButton("🔙 Menu", "menu")]] - - await client.edit_message_text(callback_query.from_user.id, callback_query.message.id, text=text, - reply_markup=InlineKeyboardMarkup(buttons)) - - -@app.on_message(filters=~filters.me) -async def on_text(client: Client, message: Message) -> None: - action = db_management.read_support(message.from_user.id) - - if "magnet" in action: - if message.text.startswith("magnet:?xt"): - magnet_link = message.text.split("\n") - category = db_management.read_support(message.from_user.id).split("#")[1] - - with QbittorrentManagement() as qb: - qb.add_magnet(magnet_link=magnet_link, - category=category) - - await send_menu(client, message.id, message.from_user.id) - db_management.write_support("None", message.from_user.id) - - else: - await client.send_message(message.from_user.id, "This magnet link is invalid! Retry") - - elif "torrent" in action and message.document: - if ".torrent" in message.document.file_name: - with tempfile.TemporaryDirectory() as tempdir: - name = f"{tempdir}/{message.document.file_name}" - category = db_management.read_support(message.from_user.id).split("#")[1] - await message.download(name) - - with QbittorrentManagement() as qb: - qb.add_torrent(file_name=name, - category=category) - await send_menu(client, message.id, message.from_user.id) - db_management.write_support("None", message.from_user.id) - - else: - await client.send_message(message.from_user.id, "This is not a torrent file! Retry") - - elif action == "category_name": - db_management.write_support(f"category_dir#{message.text}", message.from_user.id) - await client.send_message(message.from_user.id, f"now send me the path for the category {message.text}") - - elif "category_dir" in action: - if os.path.exists(message.text): - name = db_management.read_support(message.from_user.id).split("#")[1] - - if "modify" in action: - with QbittorrentManagement() as qb: - qb.edit_category(name=name, - save_path=message.text) - await send_menu(client, message.id, message.from_user.id) - return - - with QbittorrentManagement() as qb: - qb.create_category(name=name, - save_path=message.text) - await send_menu(client, message.id, message.from_user.id) - - else: - await client.send_message(message.from_user.id, "The path entered does not exist! Retry") - - else: - await client.send_message(message.from_user.id, "The command does not exist") From 87defbcaeb52f55018d119e6a5cd9d1f715cc1bb Mon Sep 17 00:00:00 2001 From: ch3p4ll3 Date: Wed, 13 Dec 2023 20:13:44 +0100 Subject: [PATCH 03/25] move custom filters + add contributors --- .all-contributorsrc | 4 ++++ README.md | 11 +++++++++++ logs/.gitignore | 4 ++++ src/bot/__init__.py | 3 +-- src/bot/__main__.py | 3 +++ src/{ => bot}/custom_filters.py | 0 src/bot/plugins/__init__.py | 4 ++++ src/bot/plugins/callbacks/__init__.py | 8 ++++++++ src/bot/plugins/callbacks/add_torrents_callbacks.py | 3 ++- src/bot/plugins/callbacks/category_callbacks.py | 3 ++- src/bot/plugins/callbacks/delete_all_callbacks.py | 2 +- src/bot/plugins/callbacks/delete_single_callbacks.py | 2 +- src/bot/plugins/callbacks/list_callbacks.py | 2 +- src/bot/plugins/callbacks/pause_callbacks.py | 2 +- src/bot/plugins/callbacks/resume_callbacks.py | 2 +- src/bot/plugins/callbacks/torrent_info.py | 2 +- 16 files changed, 45 insertions(+), 10 deletions(-) create mode 100644 .all-contributorsrc create mode 100644 logs/.gitignore create mode 100644 src/bot/__main__.py rename src/{ => bot}/custom_filters.py (100%) diff --git a/.all-contributorsrc b/.all-contributorsrc new file mode 100644 index 0000000..a57a0de --- /dev/null +++ b/.all-contributorsrc @@ -0,0 +1,4 @@ +{ + "projectName": "QBittorrentBot", + "projectOwner": "ch3p4ll3" +} \ No newline at end of file diff --git a/README.md b/README.md index a85bc55..089ce2b 100755 --- a/README.md +++ b/README.md @@ -71,3 +71,14 @@ You can activate it by going on the menu bar, go to **Tools > Options** qBittorr - Set username and password (by default username: admin / password: adminadmin) Click on Ok to save settings. + +## Contributors + + + + + + + + + \ No newline at end of file diff --git a/logs/.gitignore b/logs/.gitignore new file mode 100644 index 0000000..86d0cb2 --- /dev/null +++ b/logs/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore \ No newline at end of file diff --git a/src/bot/__init__.py b/src/bot/__init__.py index 707adc8..150b6db 100644 --- a/src/bot/__init__.py +++ b/src/bot/__init__.py @@ -6,10 +6,9 @@ plugins = dict( - root=".plugins" + root="src.bot.plugins" ) - app = Client( "qbittorrent_bot", api_id=BOT_CONFIGS.telegram.api_id, diff --git a/src/bot/__main__.py b/src/bot/__main__.py new file mode 100644 index 0000000..1d669db --- /dev/null +++ b/src/bot/__main__.py @@ -0,0 +1,3 @@ +from . import app + +app.run() \ No newline at end of file diff --git a/src/custom_filters.py b/src/bot/custom_filters.py similarity index 100% rename from src/custom_filters.py rename to src/bot/custom_filters.py diff --git a/src/bot/plugins/__init__.py b/src/bot/plugins/__init__.py index e69de29..b8285b3 100644 --- a/src/bot/plugins/__init__.py +++ b/src/bot/plugins/__init__.py @@ -0,0 +1,4 @@ +from .commands import * +from .common import * +from .on_message import * +from .callbacks import * diff --git a/src/bot/plugins/callbacks/__init__.py b/src/bot/plugins/callbacks/__init__.py index e69de29..7ccdef2 100644 --- a/src/bot/plugins/callbacks/__init__.py +++ b/src/bot/plugins/callbacks/__init__.py @@ -0,0 +1,8 @@ +from .add_torrents_callbacks import * +from .category_callbacks import * +from .delete_all_callbacks import * +from .delete_single_callbacks import * +from .list_callbacks import * +from .pause_callbacks import * +from .resume_callbacks import * +from .torrent_info import * diff --git a/src/bot/plugins/callbacks/add_torrents_callbacks.py b/src/bot/plugins/callbacks/add_torrents_callbacks.py index 9253688..9ef9cf6 100644 --- a/src/bot/plugins/callbacks/add_torrents_callbacks.py +++ b/src/bot/plugins/callbacks/add_torrents_callbacks.py @@ -1,6 +1,7 @@ from pyrogram import Client from pyrogram.types import CallbackQuery -from .... import custom_filters, db_management +from .... import db_management +from ... import custom_filters @Client.on_callback_query(filters=custom_filters.add_magnet_filter) diff --git a/src/bot/plugins/callbacks/category_callbacks.py b/src/bot/plugins/callbacks/category_callbacks.py index fd798cc..0a76b1e 100644 --- a/src/bot/plugins/callbacks/category_callbacks.py +++ b/src/bot/plugins/callbacks/category_callbacks.py @@ -2,7 +2,8 @@ from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, CallbackQuery from pyrogram.errors.exceptions import MessageIdInvalid -from .... import custom_filters, db_management +from .... import db_management +from ... import custom_filters from ....qbittorrent_manager import QbittorrentManagement diff --git a/src/bot/plugins/callbacks/delete_all_callbacks.py b/src/bot/plugins/callbacks/delete_all_callbacks.py index 306b784..93aa364 100644 --- a/src/bot/plugins/callbacks/delete_all_callbacks.py +++ b/src/bot/plugins/callbacks/delete_all_callbacks.py @@ -1,7 +1,7 @@ from pyrogram import Client from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, CallbackQuery -from .... import custom_filters +from ... import custom_filters from ....qbittorrent_manager import QbittorrentManagement from ..common import send_menu diff --git a/src/bot/plugins/callbacks/delete_single_callbacks.py b/src/bot/plugins/callbacks/delete_single_callbacks.py index 03c60a2..191a3ad 100644 --- a/src/bot/plugins/callbacks/delete_single_callbacks.py +++ b/src/bot/plugins/callbacks/delete_single_callbacks.py @@ -1,7 +1,7 @@ from pyrogram import Client from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, CallbackQuery -from .... import custom_filters +from ... import custom_filters from ....qbittorrent_manager import QbittorrentManagement from ..common import list_active_torrents, send_menu diff --git a/src/bot/plugins/callbacks/list_callbacks.py b/src/bot/plugins/callbacks/list_callbacks.py index da51f06..aaaa64a 100644 --- a/src/bot/plugins/callbacks/list_callbacks.py +++ b/src/bot/plugins/callbacks/list_callbacks.py @@ -1,6 +1,6 @@ from pyrogram import Client from pyrogram.types import CallbackQuery -from .... import custom_filters +from ... import custom_filters from .... import db_management from ..common import list_active_torrents, send_menu diff --git a/src/bot/plugins/callbacks/pause_callbacks.py b/src/bot/plugins/callbacks/pause_callbacks.py index 63b5768..1503a21 100644 --- a/src/bot/plugins/callbacks/pause_callbacks.py +++ b/src/bot/plugins/callbacks/pause_callbacks.py @@ -1,7 +1,7 @@ from pyrogram import Client from pyrogram.types import CallbackQuery -from .... import custom_filters +from ... import custom_filters from ....qbittorrent_manager import QbittorrentManagement from ..common import list_active_torrents, send_menu diff --git a/src/bot/plugins/callbacks/resume_callbacks.py b/src/bot/plugins/callbacks/resume_callbacks.py index 8cd157e..88071f4 100644 --- a/src/bot/plugins/callbacks/resume_callbacks.py +++ b/src/bot/plugins/callbacks/resume_callbacks.py @@ -1,7 +1,7 @@ from pyrogram import Client from pyrogram.types import CallbackQuery -from .... import custom_filters +from ... import custom_filters from ....qbittorrent_manager import QbittorrentManagement from ..common import list_active_torrents, send_menu diff --git a/src/bot/plugins/callbacks/torrent_info.py b/src/bot/plugins/callbacks/torrent_info.py index 74e0efa..562a64f 100644 --- a/src/bot/plugins/callbacks/torrent_info.py +++ b/src/bot/plugins/callbacks/torrent_info.py @@ -3,7 +3,7 @@ from pyrogram import Client from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, CallbackQuery -from .... import custom_filters +from ... import custom_filters from ....qbittorrent_manager import QbittorrentManagement from ....utils import convert_size, convert_eta From fbdaaea05993570b362499cba283f1d82afe25d3 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Wed, 13 Dec 2023 19:21:10 +0000 Subject: [PATCH 04/25] docs: update README.md [skip ci] --- README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.md b/README.md index a85bc55..a7cca53 100755 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ [![Codacy Badge](https://api.codacy.com/project/badge/Grade/259099080ca24e029a910e3249d32041)](https://app.codacy.com/gh/ch3p4ll3/QBittorrentBot?utm_source=github.com&utm_medium=referral&utm_content=ch3p4ll3/QBittorrentBot&utm_campaign=Badge_Grade) + +[![All Contributors](https://img.shields.io/badge/all_contributors-1-orange.svg?style=flat-square)](#contributors-) + # QBittorrentBot @@ -71,3 +74,25 @@ You can activate it by going on the menu bar, go to **Tools > Options** qBittorr - Set username and password (by default username: admin / password: adminadmin) Click on Ok to save settings. + +## Contributors ✨ + +Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): + + + + + + + + + + +
Bogdan
Bogdan

💻
+ + + + + + +This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! \ No newline at end of file From 753845d6b52cb38aece6be869964d9ca9c038d50 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Wed, 13 Dec 2023 19:21:11 +0000 Subject: [PATCH 05/25] docs: create .all-contributorsrc [skip ci] --- .all-contributorsrc | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .all-contributorsrc diff --git a/.all-contributorsrc b/.all-contributorsrc new file mode 100644 index 0000000..79c3086 --- /dev/null +++ b/.all-contributorsrc @@ -0,0 +1,26 @@ +{ + "files": [ + "README.md" + ], + "imageSize": 100, + "commit": false, + "commitType": "docs", + "commitConvention": "angular", + "contributors": [ + { + "login": "bushig", + "name": "Bogdan", + "avatar_url": "https://avatars.githubusercontent.com/u/2815779?v=4", + "profile": "https://github.com/bushig", + "contributions": [ + "code" + ] + } + ], + "contributorsPerLine": 7, + "skipCi": true, + "repoType": "github", + "repoHost": "https://github.com", + "projectName": "QBittorrentBot", + "projectOwner": "ch3p4ll3" +} From d86b6c8628623da973881c0022e74b6be83de2b2 Mon Sep 17 00:00:00 2001 From: ch3p4ll3 Date: Wed, 13 Dec 2023 20:43:49 +0100 Subject: [PATCH 06/25] fix move files --- src/bot/plugins/__init__.py | 4 ---- src/bot/plugins/callbacks/add_torrents_callbacks.py | 4 ++-- src/bot/plugins/callbacks/category_callbacks.py | 2 +- src/bot/plugins/callbacks/delete_all_callbacks.py | 6 +++--- src/bot/plugins/callbacks/delete_single_callbacks.py | 6 +++--- src/bot/plugins/callbacks/list_callbacks.py | 7 ++++--- src/bot/plugins/callbacks/pause_callbacks.py | 4 ++-- src/bot/plugins/callbacks/resume_callbacks.py | 4 ++-- src/bot/plugins/callbacks/torrent_info.py | 2 +- src/bot/plugins/commands.py | 4 ++-- src/bot/plugins/on_message.py | 2 +- 11 files changed, 21 insertions(+), 24 deletions(-) diff --git a/src/bot/plugins/__init__.py b/src/bot/plugins/__init__.py index b8285b3..e69de29 100644 --- a/src/bot/plugins/__init__.py +++ b/src/bot/plugins/__init__.py @@ -1,4 +0,0 @@ -from .commands import * -from .common import * -from .on_message import * -from .callbacks import * diff --git a/src/bot/plugins/callbacks/add_torrents_callbacks.py b/src/bot/plugins/callbacks/add_torrents_callbacks.py index 9ef9cf6..675ce81 100644 --- a/src/bot/plugins/callbacks/add_torrents_callbacks.py +++ b/src/bot/plugins/callbacks/add_torrents_callbacks.py @@ -4,13 +4,13 @@ from ... import custom_filters -@Client.on_callback_query(filters=custom_filters.add_magnet_filter) +@Client.on_callback_query(custom_filters.add_magnet_filter) async def add_magnet_callback(client: Client, callback_query: CallbackQuery) -> None: db_management.write_support(f"magnet#{callback_query.data.split('#')[1]}", callback_query.from_user.id) await client.answer_callback_query(callback_query.id, "Send a magnet link") -@Client.on_callback_query(filters=custom_filters.add_torrent_filter) +@Client.on_callback_query(custom_filters.add_torrent_filter) async def add_torrent_callback(client: Client, callback_query: CallbackQuery) -> None: db_management.write_support(f"torrent#{callback_query.data.split('#')[1]}", callback_query.from_user.id) await client.answer_callback_query(callback_query.id, "Send a torrent file") diff --git a/src/bot/plugins/callbacks/category_callbacks.py b/src/bot/plugins/callbacks/category_callbacks.py index 0a76b1e..269a6a2 100644 --- a/src/bot/plugins/callbacks/category_callbacks.py +++ b/src/bot/plugins/callbacks/category_callbacks.py @@ -7,7 +7,7 @@ from ....qbittorrent_manager import QbittorrentManagement -@Client.on_callback_query(filters=custom_filters.add_category_filter) +@Client.on_callback_query(custom_filters.add_category_filter) async def add_category_callback(client: Client, callback_query: CallbackQuery) -> None: db_management.write_support("category_name", callback_query.from_user.id) button = InlineKeyboardMarkup([[InlineKeyboardButton("🔙 Menu", "menu")]]) diff --git a/src/bot/plugins/callbacks/delete_all_callbacks.py b/src/bot/plugins/callbacks/delete_all_callbacks.py index 93aa364..b2ec3a7 100644 --- a/src/bot/plugins/callbacks/delete_all_callbacks.py +++ b/src/bot/plugins/callbacks/delete_all_callbacks.py @@ -6,7 +6,7 @@ from ..common import send_menu -@Client.on_callback_query(filters=custom_filters.delete_all_filter) +@Client.on_callback_query(custom_filters.delete_all_filter) async def delete_all_callback(client: Client, callback_query: CallbackQuery) -> None: buttons = [[InlineKeyboardButton("🗑 Delete all torrents", "delete_all_no_data")], [InlineKeyboardButton("🗑 Delete all torrents and data", "delete_all_data")], @@ -15,7 +15,7 @@ async def delete_all_callback(client: Client, callback_query: CallbackQuery) -> reply_markup=InlineKeyboardMarkup(buttons)) -@Client.on_callback_query(filters=custom_filters.delete_all_no_data_filter) +@Client.on_callback_query(custom_filters.delete_all_no_data_filter) async def delete_all_with_no_data_callback(client: Client, callback_query: CallbackQuery) -> None: with QbittorrentManagement() as qb: qb.delete_all_no_data() @@ -23,7 +23,7 @@ async def delete_all_with_no_data_callback(client: Client, callback_query: Callb await send_menu(client, callback_query.message.id, callback_query.from_user.id) -@Client.on_callback_query(filters=custom_filters.delete_all_data_filter) +@Client.on_callback_query(custom_filters.delete_all_data_filter) async def delete_all_with_data_callback(client: Client, callback_query: CallbackQuery) -> None: with QbittorrentManagement() as qb: qb.delete_all_data() diff --git a/src/bot/plugins/callbacks/delete_single_callbacks.py b/src/bot/plugins/callbacks/delete_single_callbacks.py index 191a3ad..7ddb026 100644 --- a/src/bot/plugins/callbacks/delete_single_callbacks.py +++ b/src/bot/plugins/callbacks/delete_single_callbacks.py @@ -6,7 +6,7 @@ from ..common import list_active_torrents, send_menu -@Client.on_callback_query(filters=custom_filters.delete_one_filter) +@Client.on_callback_query(custom_filters.delete_one_filter) async def delete_callback(client: Client, callback_query: CallbackQuery) -> None: if callback_query.data.find("#") == -1: await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "delete_one") @@ -22,7 +22,7 @@ async def delete_callback(client: Client, callback_query: CallbackQuery) -> None reply_markup=InlineKeyboardMarkup(buttons)) -@Client.on_callback_query(filters=custom_filters.delete_one_no_data_filter) +@Client.on_callback_query(custom_filters.delete_one_no_data_filter) async def delete_no_data_callback(client: Client, callback_query: CallbackQuery) -> None: if callback_query.data.find("#") == -1: await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "delete_one_no_data") @@ -33,7 +33,7 @@ async def delete_no_data_callback(client: Client, callback_query: CallbackQuery) await send_menu(client, callback_query.message.id, callback_query.from_user.id) -@Client.on_callback_query(filters=custom_filters.delete_one_data_filter) +@Client.on_callback_query(custom_filters.delete_one_data_filter) async def delete_with_data_callback(client: Client, callback_query: CallbackQuery) -> None: if callback_query.data.find("#") == -1: await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "delete_one_data") diff --git a/src/bot/plugins/callbacks/list_callbacks.py b/src/bot/plugins/callbacks/list_callbacks.py index aaaa64a..a33bde3 100644 --- a/src/bot/plugins/callbacks/list_callbacks.py +++ b/src/bot/plugins/callbacks/list_callbacks.py @@ -5,18 +5,19 @@ from ..common import list_active_torrents, send_menu -@Client.on_callback_query(filters=custom_filters.list_filter) +@Client.on_callback_query(custom_filters.list_filter) async def list_callback(client: Client, callback_query: CallbackQuery) -> None: await list_active_torrents(client, 0, callback_query.from_user.id, callback_query.message.id, db_management.read_support(callback_query.from_user.id)) -@Client.on_callback_query(filters=custom_filters.list_by_status_filter) +@Client.on_callback_query(custom_filters.list_by_status_filter) async def list_by_status_callback(client: Client, callback_query: CallbackQuery) -> None: status_filter = callback_query.data.split("#")[1] await list_active_torrents(client,0, callback_query.from_user.id, callback_query.message.id, db_management.read_support(callback_query.from_user.id), status_filter=status_filter) -@Client.on_callback_query(filters=custom_filters.menu_filter) + +@Client.on_callback_query(custom_filters.menu_filter) async def menu_callback(client: Client, callback_query: CallbackQuery) -> None: await send_menu(client, callback_query.message.id, callback_query.from_user.id) diff --git a/src/bot/plugins/callbacks/pause_callbacks.py b/src/bot/plugins/callbacks/pause_callbacks.py index 1503a21..56e3e8d 100644 --- a/src/bot/plugins/callbacks/pause_callbacks.py +++ b/src/bot/plugins/callbacks/pause_callbacks.py @@ -6,14 +6,14 @@ from ..common import list_active_torrents, send_menu -@Client.on_callback_query(filters=custom_filters.pause_all_filter) +@Client.on_callback_query(custom_filters.pause_all_filter) async def pause_all_callback(client: Client, callback_query: CallbackQuery) -> None: with QbittorrentManagement() as qb: qb.pause_all() await client.answer_callback_query(callback_query.id, "Paused all torrents") -@Client.on_callback_query(filters=custom_filters.pause_filter) +@Client.on_callback_query(custom_filters.pause_filter) async def pause_callback(client: Client, callback_query: CallbackQuery) -> None: if callback_query.data.find("#") == -1: await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "pause") diff --git a/src/bot/plugins/callbacks/resume_callbacks.py b/src/bot/plugins/callbacks/resume_callbacks.py index 88071f4..05887ab 100644 --- a/src/bot/plugins/callbacks/resume_callbacks.py +++ b/src/bot/plugins/callbacks/resume_callbacks.py @@ -6,14 +6,14 @@ from ..common import list_active_torrents, send_menu -@Client.on_callback_query(filters=custom_filters.resume_all_filter) +@Client.on_callback_query(custom_filters.resume_all_filter) async def resume_all_callback(client: Client, callback_query: CallbackQuery) -> None: with QbittorrentManagement() as qb: qb.resume_all() await client.answer_callback_query(callback_query.id, "Resumed all torrents") -@Client.on_callback_query(filters=custom_filters.resume_filter) +@Client.on_callback_query(custom_filters.resume_filter) async def resume_callback(client: Client, callback_query: CallbackQuery) -> None: if callback_query.data.find("#") == -1: await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "resume") diff --git a/src/bot/plugins/callbacks/torrent_info.py b/src/bot/plugins/callbacks/torrent_info.py index 562a64f..185998a 100644 --- a/src/bot/plugins/callbacks/torrent_info.py +++ b/src/bot/plugins/callbacks/torrent_info.py @@ -8,7 +8,7 @@ from ....utils import convert_size, convert_eta -@Client.on_callback_query(filters=custom_filters.torrentInfo_filter) +@Client.on_callback_query(custom_filters.torrentInfo_filter) async def torrent_info_callback(client: Client, callback_query: CallbackQuery) -> None: with QbittorrentManagement() as qb: torrent = qb.get_torrent_info(callback_query.data.split("#")[1]) diff --git a/src/bot/plugins/commands.py b/src/bot/plugins/commands.py index 529b91b..2e28033 100644 --- a/src/bot/plugins/commands.py +++ b/src/bot/plugins/commands.py @@ -7,7 +7,7 @@ from .common import send_menu -@Client.on_message(filters=filters.command("start")) +@Client.on_message(filters.command("start")) async def start_command(client: Client, message: Message) -> None: """Start the bot.""" if message.from_user.id in [i.user_id for i in BOT_CONFIGS.users]: @@ -19,7 +19,7 @@ async def start_command(client: Client, message: Message) -> None: await client.send_message(message.chat.id, "You are not authorized to use this bot", reply_markup=button) -@Client.on_message(filters=filters.command("stats")) +@Client.on_message(filters.command("stats")) async def stats_command(client: Client, message: Message) -> None: if message.from_user.id in [i.user_id for i in BOT_CONFIGS.users]: diff --git a/src/bot/plugins/on_message.py b/src/bot/plugins/on_message.py index 5ed3782..6de3390 100644 --- a/src/bot/plugins/on_message.py +++ b/src/bot/plugins/on_message.py @@ -8,7 +8,7 @@ from .common import send_menu -@Client.on_message(filters=~filters.me) +@Client.on_message(~filters.me) async def on_text(client: Client, message: Message) -> None: action = db_management.read_support(message.from_user.id) From bc46ed6c63eaa70d76d877fcb20cc5cd2f5e2b5e Mon Sep 17 00:00:00 2001 From: ch3p4ll3 Date: Thu, 14 Dec 2023 19:34:47 +0100 Subject: [PATCH 07/25] split config module + refactor config.json template --- README.md | 8 +++-- config.json.template | 5 +-- main.py | 7 ++++- src/bot/__init__.py | 5 ++- src/bot/plugins/commands.py | 5 ++- src/bot/plugins/common.py | 29 +++++++++-------- src/config.py | 63 ------------------------------------- src/configs/__init__.py | 18 +++++++++++ src/configs/client.py | 46 +++++++++++++++++++++++++++ src/configs/config.py | 11 +++++++ src/configs/enums.py | 5 +++ src/configs/telegram.py | 19 +++++++++++ src/configs/users.py | 7 +++++ src/qbittorrent_manager.py | 10 ++---- src/utils.py | 5 ++- 15 files changed, 152 insertions(+), 91 deletions(-) delete mode 100644 src/config.py create mode 100644 src/configs/__init__.py create mode 100644 src/configs/client.py create mode 100644 src/configs/config.py create mode 100644 src/configs/enums.py create mode 100644 src/configs/telegram.py create mode 100644 src/configs/users.py diff --git a/README.md b/README.md index 089ce2b..63995eb 100755 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ magnet:?xt=... ``` You can also pause, resume, delete and add/remove and modify categories. +## Warning! +Since version V2, the mapping of the configuration file has been changed. Make sure you have modified it correctly before starting the bot + ## Configuration ### Retrieve Telegram API ID and API HASH With the change of library to [pyrogram](https://docs.pyrogram.org/) you will need the API_ID and API_HASH. Check [here](https://docs.pyrogram.org/intro/quickstart) to find out how to recover them. @@ -19,8 +22,9 @@ The config file is stored in the mounted /app/config/ volume ``` { - "qbittorrent": { - "ip": "192.168.178.102", + "clients": { + "type": "qbittorrent", + "host": "192.168.178.102", "port": 8080, "user": "admin", "password": "admin" diff --git a/config.json.template b/config.json.template index ebd14a2..9e96c0b 100644 --- a/config.json.template +++ b/config.json.template @@ -1,6 +1,7 @@ { - "qbittorrent": { - "ip": "192.168.178.102", + "clients": { + "type": "qbittorrent", + "host": "192.168.178.102", "port": 8080, "user": "admin", "password": "admin" diff --git a/main.py b/main.py index 6e50aa9..b879425 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,14 @@ from src.bot import app, scheduler import logging from logging import handlers +from os import getenv # Create a file handler -handler = logging.handlers.TimedRotatingFileHandler('./logs/QbittorrentBot.log', when='midnight', backupCount=10) +handler = logging.handlers.TimedRotatingFileHandler( + f'{"/app/config/" if getenv("IS_DOCKER", False) else "./"}logs/QbittorrentBot.log', + when='midnight', + backupCount=10 +) # Create a format formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') diff --git a/src/bot/__init__.py b/src/bot/__init__.py index 150b6db..aef01d6 100644 --- a/src/bot/__init__.py +++ b/src/bot/__init__.py @@ -2,7 +2,10 @@ from pyrogram.enums.parse_mode import ParseMode from apscheduler.schedulers.asyncio import AsyncIOScheduler from src.utils import torrent_finished -from src.config import BOT_CONFIGS +from ..configs import Configs + + +BOT_CONFIGS = Configs.load_config() plugins = dict( diff --git a/src/bot/plugins/commands.py b/src/bot/plugins/commands.py index 2e28033..2b179ed 100644 --- a/src/bot/plugins/commands.py +++ b/src/bot/plugins/commands.py @@ -3,10 +3,13 @@ import psutil from ...utils import convert_size -from ...config import BOT_CONFIGS +from ...configs import Configs from .common import send_menu +BOT_CONFIGS = Configs.load_config() + + @Client.on_message(filters.command("start")) async def start_command(client: Client, message: Message) -> None: """Start the bot.""" diff --git a/src/bot/plugins/common.py b/src/bot/plugins/common.py index 6d398de..1fc4d56 100644 --- a/src/bot/plugins/common.py +++ b/src/bot/plugins/common.py @@ -1,24 +1,27 @@ from pyrogram import Client -from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup from pyrogram.errors.exceptions import MessageIdInvalid +from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup + from ... import db_management from ...qbittorrent_manager import QbittorrentManagement async def send_menu(client: Client, message, chat) -> None: db_management.write_support("None", chat) - buttons = [[InlineKeyboardButton("📝 List", "list")], - [InlineKeyboardButton("➕ Add Magnet", "category#add_magnet"), - InlineKeyboardButton("➕ Add Torrent", "category#add_torrent")], - [InlineKeyboardButton("⏸ Pause", "pause"), - InlineKeyboardButton("▶️ Resume", "resume")], - [InlineKeyboardButton("⏸ Pause All", "pause_all"), - InlineKeyboardButton("▶️ Resume All", "resume_all")], - [InlineKeyboardButton("🗑 Delete", "delete_one"), - InlineKeyboardButton("🗑 Delete All", "delete_all")], - [InlineKeyboardButton("➕ Add Category", "add_category"), - InlineKeyboardButton("🗑 Remove Category", "select_category#remove_category")], - [InlineKeyboardButton("📝 Modify Category", "select_category#modify_category")]] + buttons = [ + [InlineKeyboardButton("📝 List", "list")], + [InlineKeyboardButton("➕ Add Magnet", "category#add_magnet"), + InlineKeyboardButton("➕ Add Torrent", "category#add_torrent")], + [InlineKeyboardButton("⏸ Pause", "pause"), + InlineKeyboardButton("▶️ Resume", "resume")], + [InlineKeyboardButton("⏸ Pause All", "pause_all"), + InlineKeyboardButton("▶️ Resume All", "resume_all")], + [InlineKeyboardButton("🗑 Delete", "delete_one"), + InlineKeyboardButton("🗑 Delete All", "delete_all")], + [InlineKeyboardButton("➕ Add Category", "add_category"), + InlineKeyboardButton("🗑 Remove Category", "select_category#remove_category")], + [InlineKeyboardButton("📝 Modify Category", "select_category#modify_category")] + ] try: await client.edit_message_text(chat, message, text="Qbittorrent Control", diff --git a/src/config.py b/src/config.py deleted file mode 100644 index cfa4b06..0000000 --- a/src/config.py +++ /dev/null @@ -1,63 +0,0 @@ -import ipaddress -from pydantic import BaseModel, field_validator -from typing import Optional -import json -from os import getenv - - -class Qbittorrent(BaseModel): - ip: ipaddress.IPv4Network - port: int - user: str - password: str - - @field_validator('port') - def port_validator(cls, v): - if v <= 0: - raise ValueError('Port must be >= 0') - return v - - @field_validator('user') - def user_validator(cls, v): - if not v or not v.strip(): - raise ValueError('User cannot be empty') - return v - - @field_validator('password') - def password_validator(cls, v): - if not v or not v.strip(): - raise ValueError('Password cannot be empty') - return v - - -class Telegram(BaseModel): - bot_token: str - api_id: int - api_hash: str - - @field_validator('bot_token') - def bot_token_validator(cls, v): - if not v or not v.strip(): - raise ValueError('Bot token cannot be empty') - return v - - @field_validator('api_hash') - def api_hash_validator(cls, v): - if not v or not v.strip(): - raise ValueError('API HASH cannot be empty') - return v - - -class Users(BaseModel): - user_id: int - notify: Optional[bool] = True - - -class Main(BaseModel): - qbittorrent: Qbittorrent - telegram: Telegram - users: list[Users] - - -with open(f'{ "/app/config/" if getenv("IS_DOCKER", False) else "./"}config.json', 'r') as config_json: - BOT_CONFIGS = Main(**(json.load(config_json))) diff --git a/src/configs/__init__.py b/src/configs/__init__.py new file mode 100644 index 0000000..2b59fdb --- /dev/null +++ b/src/configs/__init__.py @@ -0,0 +1,18 @@ +from .config import MainConfig +from os import getenv +from json import load +from typing import Union + + +class Configs: + @classmethod + def load_config(cls) -> Union[MainConfig, None]: + with open(f'{ "/app/config/" if getenv("IS_DOCKER", False) else "./"}config.json', 'r') as config_json: + configs = MainConfig(**(load(config_json))) + + return configs + + @classmethod + def update_config(cls, edited_config: MainConfig) -> Union[MainConfig, None]: + # TODO: update config from bot + pass diff --git a/src/configs/client.py b/src/configs/client.py new file mode 100644 index 0000000..e6cdd9b --- /dev/null +++ b/src/configs/client.py @@ -0,0 +1,46 @@ +import ipaddress +from pydantic import BaseModel, field_validator, IPvAnyAddress +from .enums import ClientTypeEnum + + +class Client(BaseModel): + type: ClientTypeEnum = ClientTypeEnum.QBittorrent + host: IPvAnyAddress + port: int + user: str + password: str + + @field_validator('port') + def port_validator(cls, v): + if v <= 0: + raise ValueError('Port must be >= 0') + return v + + @field_validator('user') + def user_validator(cls, v): + if not v or not v.strip(): + raise ValueError('User cannot be empty') + return v + + @field_validator('password') + def password_validator(cls, v): + if not v or not v.strip(): + raise ValueError('Password cannot be empty') + return v + + @property + def parsed_host(self) -> str: + return f"http://{self.host}" + + @property + def full_host_string(self) -> str: + return f"{self.parsed_host}:{self.port}" + + @property + def connection_string(self) -> dict: + if self.type is ClientTypeEnum.QBittorrent: + return dict( + host=self.full_host_string, + username=self.user, + password=self.password + ) diff --git a/src/configs/config.py b/src/configs/config.py new file mode 100644 index 0000000..81d28bf --- /dev/null +++ b/src/configs/config.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + +from .client import Client +from .users import Users +from .telegram import Telegram + + +class MainConfig(BaseModel): + clients: Client + telegram: Telegram + users: list[Users] diff --git a/src/configs/enums.py b/src/configs/enums.py new file mode 100644 index 0000000..9f7548c --- /dev/null +++ b/src/configs/enums.py @@ -0,0 +1,5 @@ +from enum import Enum + + +class ClientTypeEnum(str, Enum): + QBittorrent = 'qbittorrent' diff --git a/src/configs/telegram.py b/src/configs/telegram.py new file mode 100644 index 0000000..64ff2de --- /dev/null +++ b/src/configs/telegram.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel, field_validator + + +class Telegram(BaseModel): + bot_token: str + api_id: int + api_hash: str + + @field_validator('bot_token') + def bot_token_validator(cls, v): + if not v or not v.strip(): + raise ValueError('Bot token cannot be empty') + return v + + @field_validator('api_hash') + def api_hash_validator(cls, v): + if not v or not v.strip(): + raise ValueError('API HASH cannot be empty') + return v diff --git a/src/configs/users.py b/src/configs/users.py new file mode 100644 index 0000000..bc93e8b --- /dev/null +++ b/src/configs/users.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel +from typing import Optional + + +class Users(BaseModel): + user_id: int + notify: Optional[bool] = True diff --git a/src/qbittorrent_manager.py b/src/qbittorrent_manager.py index 8bf11a8..cf19b9d 100644 --- a/src/qbittorrent_manager.py +++ b/src/qbittorrent_manager.py @@ -1,19 +1,15 @@ import qbittorrentapi import logging -from src.config import BOT_CONFIGS - +from .configs import Configs from typing import Union, List +BOT_CONFIGS = Configs.load_config() logger = logging.getLogger(__name__) class QbittorrentManagement: def __init__(self): - self.qbt_client = qbittorrentapi.Client( - host=f'http://{BOT_CONFIGS.qbittorrent.ip.network_address}:' - f'{BOT_CONFIGS.qbittorrent.port}', - username=BOT_CONFIGS.qbittorrent.user, - password=BOT_CONFIGS.qbittorrent.password) + self.qbt_client = qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) def __enter__(self): try: diff --git a/src/utils.py b/src/utils.py index 2e83eef..19b40d9 100644 --- a/src/utils.py +++ b/src/utils.py @@ -4,7 +4,10 @@ from src import db_management from src.qbittorrent_manager import QbittorrentManagement -from src.config import BOT_CONFIGS +from .configs import Configs + + +BOT_CONFIGS = Configs.load_config() async def torrent_finished(app): From 98464a2d0183c87ce1a61166ed19955ddf3539c6 Mon Sep 17 00:00:00 2001 From: ch3p4ll3 Date: Thu, 14 Dec 2023 20:42:48 +0100 Subject: [PATCH 08/25] start to create user settings --- requirements.txt | 17 ++--- src/bot/custom_filters.py | 4 ++ .../plugins/callbacks/category_callbacks.py | 9 +-- .../plugins/callbacks/settings/__init__.py | 29 ++++++++ .../settings/users_settings_callbacks.py | 71 +++++++++++++++++++ src/bot/plugins/common.py | 3 +- src/configs/__init__.py | 4 +- src/configs/config.py | 4 +- src/configs/{users.py => user.py} | 2 +- src/utils.py | 9 +++ 10 files changed, 135 insertions(+), 17 deletions(-) create mode 100644 src/bot/plugins/callbacks/settings/__init__.py create mode 100644 src/bot/plugins/callbacks/settings/users_settings_callbacks.py rename src/configs/{users.py => user.py} (82%) diff --git a/requirements.txt b/requirements.txt index 6b75ca4..5fbd17e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,25 +1,26 @@ annotated-types==0.6.0 APScheduler==3.10.4 async-lru==2.0.4 -certifi==2023.7.22 -charset-normalizer==3.3.1 -idna==3.4 +certifi==2023.11.17 +charset-normalizer==3.3.2 +idna==3.6 packaging==23.2 pony==0.7.17 psutil==5.9.6 pyaes==1.6.1 -pydantic==2.4.2 -pydantic_core==2.10.1 +pydantic==2.5.2 +pydantic_core==2.14.5 Pyrogram==2.0.106 PySocks==1.7.1 pytz==2023.3.post1 pytz-deprecation-shim==0.1.0.post0 -qbittorrent-api==2023.10.54 +qbittorrent-api==2023.11.57 requests==2.31.0 six==1.16.0 TgCrypto==1.2.5 -typing_extensions==4.8.0 +tqdm==4.66.1 +typing_extensions==4.9.0 tzdata==2023.3 tzlocal==5.2 -urllib3==2.0.7 +urllib3==2.1.0 uvloop==0.19.0 diff --git a/src/bot/custom_filters.py b/src/bot/custom_filters.py index 93db12e..eaeafb5 100644 --- a/src/bot/custom_filters.py +++ b/src/bot/custom_filters.py @@ -21,3 +21,7 @@ delete_all_data_filter = filters.create(lambda _, __, query: query.data.startswith("delete_all_data")) torrentInfo_filter = filters.create(lambda _, __, query: query.data.startswith("torrentInfo")) select_category_filter = filters.create(lambda _, __, query: query.data.startswith("select_category")) +settings_filter = filters.create(lambda _, __, query: query.data == "settings") +get_users_filter = filters.create(lambda _, __, query: query.data == "get_users") +user_info_filter = filters.create(lambda _, __, query: query.data.startswith("user_info")) +edit_user_filter = filters.create(lambda _, __, query: query.data.startswith("edit_user")) diff --git a/src/bot/plugins/callbacks/category_callbacks.py b/src/bot/plugins/callbacks/category_callbacks.py index 269a6a2..4356096 100644 --- a/src/bot/plugins/callbacks/category_callbacks.py +++ b/src/bot/plugins/callbacks/category_callbacks.py @@ -2,6 +2,7 @@ from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, CallbackQuery from pyrogram.errors.exceptions import MessageIdInvalid +from . import add_magnet_callback, add_torrent_callback from .... import db_management from ... import custom_filters from ....qbittorrent_manager import QbittorrentManagement @@ -18,7 +19,7 @@ async def add_category_callback(client: Client, callback_query: CallbackQuery) - await client.send_message(callback_query.from_user.id, "Send the category name", reply_markup=button) -@Client.on_callback_query(filters=custom_filters.select_category_filter) +@Client.on_callback_query(custom_filters.select_category_filter) async def list_categories(client: Client, callback_query: CallbackQuery): buttons = [] @@ -44,7 +45,7 @@ async def list_categories(client: Client, callback_query: CallbackQuery): reply_markup=InlineKeyboardMarkup(buttons)) -@Client.on_callback_query(filters=custom_filters.remove_category_filter) +@Client.on_callback_query(custom_filters.remove_category_filter) async def remove_category_callback(client: Client, callback_query: CallbackQuery) -> None: buttons = [[InlineKeyboardButton("🔙 Menu", "menu")]] @@ -56,7 +57,7 @@ async def remove_category_callback(client: Client, callback_query: CallbackQuery reply_markup=InlineKeyboardMarkup(buttons)) -@Client.on_callback_query(filters=custom_filters.modify_category_filter) +@Client.on_callback_query(custom_filters.modify_category_filter) async def modify_category_callback(client: Client, callback_query: CallbackQuery) -> None: buttons = [[InlineKeyboardButton("🔙 Menu", "menu")]] @@ -66,7 +67,7 @@ async def modify_category_callback(client: Client, callback_query: CallbackQuery reply_markup=InlineKeyboardMarkup(buttons)) -@Client.on_callback_query(filters=custom_filters.category_filter) +@Client.on_callback_query(custom_filters.category_filter) async def category(client: Client, callback_query: CallbackQuery) -> None: buttons = [] diff --git a/src/bot/plugins/callbacks/settings/__init__.py b/src/bot/plugins/callbacks/settings/__init__.py new file mode 100644 index 0000000..62d7e45 --- /dev/null +++ b/src/bot/plugins/callbacks/settings/__init__.py @@ -0,0 +1,29 @@ +from pyrogram import Client +from pyrogram.types import CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton +from .... import custom_filters + + +@Client.on_callback_query(custom_filters.settings_filter) +async def settings_callback(client: Client, callback_query: CallbackQuery) -> None: + await callback_query.edit_message_text( + "QBittorrentBot Settings", + reply_markup=InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton("🫂 Users Settings", "get_users") + ], + [ + InlineKeyboardButton("📥 Client Settings", "menu") + ], + [ + InlineKeyboardButton("🇮🇹 Language Settings", "menu") + ], + [ + InlineKeyboardButton("🔄 Reload Settings", "menu") + ], + [ + InlineKeyboardButton("🔙 Menu", "menu") + ] + ] + ) + ) diff --git a/src/bot/plugins/callbacks/settings/users_settings_callbacks.py b/src/bot/plugins/callbacks/settings/users_settings_callbacks.py new file mode 100644 index 0000000..1f0d853 --- /dev/null +++ b/src/bot/plugins/callbacks/settings/users_settings_callbacks.py @@ -0,0 +1,71 @@ +from pyrogram import Client +from pyrogram.types import CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton +from .... import custom_filters +from .....utils import get_user_from_config +from .....configs import Configs + +BOT_CONFIGS = Configs.load_config() + + +@Client.on_callback_query(custom_filters.get_users_filter) +async def get_users_callback(client: Client, callback_query: CallbackQuery) -> None: + users = [ + [InlineKeyboardButton(f"User #{i.user_id}", f"user_info#{i.user_id}")] + for i in BOT_CONFIGS.users + ] + + await callback_query.edit_message_text( + "Authorized users", + reply_markup=InlineKeyboardMarkup( + users + + [ + [ + InlineKeyboardButton("🔙 Menu", "menu") + ] + ] + ) + ) + + +@Client.on_callback_query(custom_filters.user_info_filter) +async def get_user_info_callback(client: Client, callback_query: CallbackQuery) -> None: + user_id = int(callback_query.data.split("#")[1]) + + user_info = get_user_from_config(user_id) + + # get all fields of the model dynamically + fields = [ + [InlineKeyboardButton(f"Edit {key.replace('_', ' ').capitalize()}", f"edit_user#{user_id}-{key}-{item.annotation}")] + for key, item in user_info.model_fields.items() + ] + + await callback_query.edit_message_text( + f"Edit User #{user_id}", + reply_markup=InlineKeyboardMarkup( + fields + + [ + [ + InlineKeyboardButton("🔙 Menu", "menu") + ] + ] + ) + ) + + +@Client.on_callback_query(custom_filters.edit_user_filter) +async def edit_user_callback(client: Client, callback_query: CallbackQuery) -> None: + data = callback_query.data.split("#")[1] + user_id = int(data.split("-")[0]) + field_to_edit = data.split("-")[1] + data_type = data.split("-")[2] + + await callback_query.edit_message_text( + f"Edit User #{user_id}", + reply_markup=InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton("🔙 Menu", "menu") + ] + ] + ) + ) diff --git a/src/bot/plugins/common.py b/src/bot/plugins/common.py index 1fc4d56..f6a3802 100644 --- a/src/bot/plugins/common.py +++ b/src/bot/plugins/common.py @@ -20,7 +20,8 @@ async def send_menu(client: Client, message, chat) -> None: InlineKeyboardButton("🗑 Delete All", "delete_all")], [InlineKeyboardButton("➕ Add Category", "add_category"), InlineKeyboardButton("🗑 Remove Category", "select_category#remove_category")], - [InlineKeyboardButton("📝 Modify Category", "select_category#modify_category")] + [InlineKeyboardButton("📝 Modify Category", "select_category#modify_category")], + [InlineKeyboardButton("⚙️ Settings", "settings")] ] try: diff --git a/src/configs/__init__.py b/src/configs/__init__.py index 2b59fdb..d0bd03d 100644 --- a/src/configs/__init__.py +++ b/src/configs/__init__.py @@ -5,9 +5,11 @@ class Configs: + config_path = f'{ "/app/config/" if getenv("IS_DOCKER", False) else "./"}config.json' + @classmethod def load_config(cls) -> Union[MainConfig, None]: - with open(f'{ "/app/config/" if getenv("IS_DOCKER", False) else "./"}config.json', 'r') as config_json: + with open(cls.config_path, 'r') as config_json: configs = MainConfig(**(load(config_json))) return configs diff --git a/src/configs/config.py b/src/configs/config.py index 81d28bf..905ac2a 100644 --- a/src/configs/config.py +++ b/src/configs/config.py @@ -1,11 +1,11 @@ from pydantic import BaseModel from .client import Client -from .users import Users +from .user import User from .telegram import Telegram class MainConfig(BaseModel): clients: Client telegram: Telegram - users: list[Users] + users: list[User] diff --git a/src/configs/users.py b/src/configs/user.py similarity index 82% rename from src/configs/users.py rename to src/configs/user.py index bc93e8b..8f667ed 100644 --- a/src/configs/users.py +++ b/src/configs/user.py @@ -2,6 +2,6 @@ from typing import Optional -class Users(BaseModel): +class User(BaseModel): user_id: int notify: Optional[bool] = True diff --git a/src/utils.py b/src/utils.py index 19b40d9..752f19d 100644 --- a/src/utils.py +++ b/src/utils.py @@ -5,6 +5,7 @@ from src import db_management from src.qbittorrent_manager import QbittorrentManagement from .configs import Configs +from .configs.user import User BOT_CONFIGS = Configs.load_config() @@ -24,6 +25,14 @@ async def torrent_finished(app): db_management.write_completed_torrents(i.hash) +def get_user_from_config(user_id: int) -> User: + return next( + iter( + [i for i in BOT_CONFIGS.users if i.user_id == user_id] + ) + ) + + def convert_size(size_bytes) -> str: if size_bytes == 0: return "0B" From 6606792bad1c575ae0e08a9d7334a36ccc81a69f Mon Sep 17 00:00:00 2001 From: ch3p4ll3 Date: Sat, 16 Dec 2023 02:08:41 +0100 Subject: [PATCH 09/25] fix user settings --- src/bot/__init__.py | 2 +- src/bot/custom_filters.py | 1 + .../settings/users_settings_callbacks.py | 74 +++++++++++++++++-- src/bot/plugins/commands.py | 2 +- src/bot/plugins/on_message.py | 34 ++++++++- src/configs/__init__.py | 27 +++++-- src/qbittorrent_manager.py | 2 +- src/utils.py | 16 +++- 8 files changed, 141 insertions(+), 17 deletions(-) diff --git a/src/bot/__init__.py b/src/bot/__init__.py index aef01d6..1194ff8 100644 --- a/src/bot/__init__.py +++ b/src/bot/__init__.py @@ -5,7 +5,7 @@ from ..configs import Configs -BOT_CONFIGS = Configs.load_config() +BOT_CONFIGS = Configs.config plugins = dict( diff --git a/src/bot/custom_filters.py b/src/bot/custom_filters.py index eaeafb5..f3823c0 100644 --- a/src/bot/custom_filters.py +++ b/src/bot/custom_filters.py @@ -25,3 +25,4 @@ get_users_filter = filters.create(lambda _, __, query: query.data == "get_users") user_info_filter = filters.create(lambda _, __, query: query.data.startswith("user_info")) edit_user_filter = filters.create(lambda _, __, query: query.data.startswith("edit_user")) +toggle_user_var_filter = filters.create(lambda _, __, query: query.data.startswith("toggle_user_var")) \ No newline at end of file diff --git a/src/bot/plugins/callbacks/settings/users_settings_callbacks.py b/src/bot/plugins/callbacks/settings/users_settings_callbacks.py index 1f0d853..69f5fdb 100644 --- a/src/bot/plugins/callbacks/settings/users_settings_callbacks.py +++ b/src/bot/plugins/callbacks/settings/users_settings_callbacks.py @@ -1,10 +1,14 @@ +import typing + from pyrogram import Client from pyrogram.types import CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton + from .... import custom_filters -from .....utils import get_user_from_config from .....configs import Configs +from .....db_management import write_support +from .....utils import get_user_from_config -BOT_CONFIGS = Configs.load_config() +BOT_CONFIGS = Configs.config @Client.on_callback_query(custom_filters.get_users_filter) @@ -33,9 +37,12 @@ async def get_user_info_callback(client: Client, callback_query: CallbackQuery) user_info = get_user_from_config(user_id) + write_support("None", callback_query.from_user.id) + # get all fields of the model dynamically fields = [ - [InlineKeyboardButton(f"Edit {key.replace('_', ' ').capitalize()}", f"edit_user#{user_id}-{key}-{item.annotation}")] + [InlineKeyboardButton(f"Edit {key.replace('_', ' ').capitalize()}", + f"edit_user#{user_id}-{key}-{item.annotation}")] for key, item in user_info.model_fields.items() ] @@ -57,14 +64,69 @@ async def edit_user_callback(client: Client, callback_query: CallbackQuery) -> N data = callback_query.data.split("#")[1] user_id = int(data.split("-")[0]) field_to_edit = data.split("-")[1] - data_type = data.split("-")[2] + data_type = eval(data.split("-")[2].replace("", "")) + + user_info = get_user_from_config(user_id) + + if data_type == bool or data_type == typing.Optional[bool]: + await callback_query.edit_message_text( + f"Edit User #{user_id} {field_to_edit} Field", + reply_markup=InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton( + f"{'✅' if user_info.notify else '❌'} Toggle", + f"toggle_user_var#{user_id}-{field_to_edit}") + ], + [ + InlineKeyboardButton("🔙 Menu", f"user_info#{user_id}") + ] + ] + ) + ) + + return + + write_support(callback_query.data, callback_query.from_user.id) await callback_query.edit_message_text( - f"Edit User #{user_id}", + f"Send the new value for {field_to_edit} for user #{user_id}. \n\n**Note:** the field type is {data_type}", reply_markup=InlineKeyboardMarkup( [ [ - InlineKeyboardButton("🔙 Menu", "menu") + InlineKeyboardButton("🔙 Menu", f"user_info#{user_id}") + ] + ] + ) + ) + + +@Client.on_callback_query(custom_filters.toggle_user_var_filter) +async def toggle_user_var(client: Client, callback_query: CallbackQuery) -> None: + data = callback_query.data.split("#")[1] + user_id = int(data.split("-")[0]) + field_to_edit = data.split("-")[1] + + user_info = get_user_from_config(user_id) + user_from_configs = BOT_CONFIGS.users.index(user_info) + + if user_from_configs == -1: + return + + BOT_CONFIGS.users[user_from_configs].notify = not BOT_CONFIGS.users[user_from_configs].notify + Configs.update_config(BOT_CONFIGS) + + await callback_query.edit_message_text( + f"Edit User #{user_id} {field_to_edit} Field", + reply_markup=InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton( + f"{'✅' if BOT_CONFIGS.users[user_from_configs].notify else '❌'} Toggle", + f"toggle_user_var#{user_id}-{field_to_edit}") + ], + [ + InlineKeyboardButton("🔙 Menu", f"user_info#{user_id}") ] ] ) diff --git a/src/bot/plugins/commands.py b/src/bot/plugins/commands.py index 2b179ed..120805e 100644 --- a/src/bot/plugins/commands.py +++ b/src/bot/plugins/commands.py @@ -7,7 +7,7 @@ from .common import send_menu -BOT_CONFIGS = Configs.load_config() +BOT_CONFIGS = Configs.config @Client.on_message(filters.command("start")) diff --git a/src/bot/plugins/on_message.py b/src/bot/plugins/on_message.py index 6de3390..2f37f57 100644 --- a/src/bot/plugins/on_message.py +++ b/src/bot/plugins/on_message.py @@ -1,11 +1,16 @@ +import logging import os import tempfile - from pyrogram import Client, filters from pyrogram.types import Message from ...qbittorrent_manager import QbittorrentManagement from ... import db_management from .common import send_menu +from ...configs import Configs +from ...utils import get_user_from_config, convert_type_from_string + +BOT_CONFIGS = Configs.config +logger = logging.getLogger(__name__) @Client.on_message(~filters.me) @@ -66,5 +71,32 @@ async def on_text(client: Client, message: Message) -> None: else: await client.send_message(message.from_user.id, "The path entered does not exist! Retry") + elif "edit_user" in action: + data = db_management.read_support(message.from_user.id).split("#")[1] + user_id = int(data.split("-")[0]) + field_to_edit = data.split("-")[1] + data_type = convert_type_from_string(data.split("-")[2].replace("", "")) + + try: + new_value = data_type(message.text) + + user_info = get_user_from_config(user_id) + user_from_configs = BOT_CONFIGS.users.index(user_info) + + if user_from_configs == -1: + return + + setattr(BOT_CONFIGS.users[user_from_configs], field_to_edit, new_value) + Configs.update_config(BOT_CONFIGS) + logger.debug(f"Updating User #{user_id} {field_to_edit} settings to {new_value}") + db_management.write_support("None", message.from_user.id) + + await send_menu(client, message, message.chat) + except Exception: + await message.reply_text( + f"Error: unable to convert value \"{message.text}\" to type \"{data_type}\"" + ) + logger.exception(f"Error converting value \"{message.text}\" to type \"{data_type}\"", exc_info=True) + else: await client.send_message(message.from_user.id, "The command does not exist") diff --git a/src/configs/__init__.py b/src/configs/__init__.py index d0bd03d..94eb31b 100644 --- a/src/configs/__init__.py +++ b/src/configs/__init__.py @@ -1,20 +1,35 @@ from .config import MainConfig from os import getenv -from json import load +from json import load, dumps from typing import Union +config_path = f'{ "/app/config/" if getenv("IS_DOCKER", False) else "./"}config.json' + + +def load_config() -> Union[MainConfig, None, int]: + with open(config_path, 'r') as config_json: + configs = MainConfig(**(load(config_json))) + + return configs + + class Configs: - config_path = f'{ "/app/config/" if getenv("IS_DOCKER", False) else "./"}config.json' + config_path = config_path + config = load_config() @classmethod - def load_config(cls) -> Union[MainConfig, None]: + def reload_config(cls) -> Union[MainConfig, None, int]: with open(cls.config_path, 'r') as config_json: - configs = MainConfig(**(load(config_json))) + cls.config = MainConfig(**(load(config_json))) - return configs + return cls.config @classmethod def update_config(cls, edited_config: MainConfig) -> Union[MainConfig, None]: # TODO: update config from bot - pass + with open(cls.config_path, "w") as json_file: + json_file.write( + edited_config.model_dump_json(indent=4) + ) + return edited_config diff --git a/src/qbittorrent_manager.py b/src/qbittorrent_manager.py index cf19b9d..519ea17 100644 --- a/src/qbittorrent_manager.py +++ b/src/qbittorrent_manager.py @@ -3,7 +3,7 @@ from .configs import Configs from typing import Union, List -BOT_CONFIGS = Configs.load_config() +BOT_CONFIGS = Configs.config logger = logging.getLogger(__name__) diff --git a/src/utils.py b/src/utils.py index 752f19d..00cdad8 100644 --- a/src/utils.py +++ b/src/utils.py @@ -1,14 +1,17 @@ from math import log, floor import datetime + +from pydantic import IPvAnyAddress from pyrogram.errors.exceptions import UserIsBlocked from src import db_management from src.qbittorrent_manager import QbittorrentManagement from .configs import Configs +from .configs.enums import ClientTypeEnum from .configs.user import User -BOT_CONFIGS = Configs.load_config() +BOT_CONFIGS = Configs.config async def torrent_finished(app): @@ -45,3 +48,14 @@ def convert_size(size_bytes) -> str: def convert_eta(n) -> str: return str(datetime.timedelta(seconds=n)) + + +def convert_type_from_string(input_type: str): + if "int" in input_type: + return int + elif "IPvAnyAddress" in input_type: + return IPvAnyAddress + elif "ClientTypeEnum" in input_type: + return ClientTypeEnum + elif "str" in input_type: + return str From 9a91e8eaea959d05fb9ce2d0282baeae6fa748bf Mon Sep 17 00:00:00 2001 From: ch3p4ll3 Date: Sat, 16 Dec 2023 02:47:11 +0100 Subject: [PATCH 10/25] edit client settings + reload settings --- src/bot/custom_filters.py | 5 +- .../plugins/callbacks/settings/__init__.py | 4 +- .../settings/client_settings_callbacks.py | 49 +++++++++++++++++++ .../settings/reload_settings_callbacks.py | 12 +++++ .../settings/users_settings_callbacks.py | 16 +++--- src/bot/plugins/commands.py | 7 +-- src/bot/plugins/on_message.py | 36 +++++++++++--- src/utils.py | 7 +-- 8 files changed, 106 insertions(+), 30 deletions(-) create mode 100644 src/bot/plugins/callbacks/settings/client_settings_callbacks.py create mode 100644 src/bot/plugins/callbacks/settings/reload_settings_callbacks.py diff --git a/src/bot/custom_filters.py b/src/bot/custom_filters.py index f3823c0..423f9b4 100644 --- a/src/bot/custom_filters.py +++ b/src/bot/custom_filters.py @@ -25,4 +25,7 @@ get_users_filter = filters.create(lambda _, __, query: query.data == "get_users") user_info_filter = filters.create(lambda _, __, query: query.data.startswith("user_info")) edit_user_filter = filters.create(lambda _, __, query: query.data.startswith("edit_user")) -toggle_user_var_filter = filters.create(lambda _, __, query: query.data.startswith("toggle_user_var")) \ No newline at end of file +toggle_user_var_filter = filters.create(lambda _, __, query: query.data.startswith("toggle_user_var")) +edit_client_settings_filter = filters.create(lambda _, __, query: query.data == "edit_client") +edit_client_setting_filter = filters.create(lambda _, __, query: query.data.startswith("edit_clt")) +reload_settings_filter = filters.create(lambda _, __, query: query.data == "reload_settings") diff --git a/src/bot/plugins/callbacks/settings/__init__.py b/src/bot/plugins/callbacks/settings/__init__.py index 62d7e45..60bdf06 100644 --- a/src/bot/plugins/callbacks/settings/__init__.py +++ b/src/bot/plugins/callbacks/settings/__init__.py @@ -13,13 +13,13 @@ async def settings_callback(client: Client, callback_query: CallbackQuery) -> No InlineKeyboardButton("🫂 Users Settings", "get_users") ], [ - InlineKeyboardButton("📥 Client Settings", "menu") + InlineKeyboardButton("📥 Client Settings", "edit_client") ], [ InlineKeyboardButton("🇮🇹 Language Settings", "menu") ], [ - InlineKeyboardButton("🔄 Reload Settings", "menu") + InlineKeyboardButton("🔄 Reload Settings", "reload_settings") ], [ InlineKeyboardButton("🔙 Menu", "menu") diff --git a/src/bot/plugins/callbacks/settings/client_settings_callbacks.py b/src/bot/plugins/callbacks/settings/client_settings_callbacks.py new file mode 100644 index 0000000..80bdcc9 --- /dev/null +++ b/src/bot/plugins/callbacks/settings/client_settings_callbacks.py @@ -0,0 +1,49 @@ +from pyrogram import Client +from pyrogram.types import CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton + +from .... import custom_filters +from .....configs import Configs +from .....utils import convert_type_from_string +from .....db_management import write_support + + +@Client.on_callback_query(custom_filters.edit_client_settings_filter) +async def edit_client_settings_callback(client: Client, callback_query: CallbackQuery) -> None: + # get all fields of the model dynamically + fields = [ + [InlineKeyboardButton(f"Edit {key.replace('_', ' ').capitalize()}", + f"edit_clt#{key}-{item.annotation}")] + for key, item in Configs.config.clients.model_fields.items() + ] + + await callback_query.edit_message_text( + f"Edit Qbittorrent Client", + reply_markup=InlineKeyboardMarkup( + fields + + [ + [ + InlineKeyboardButton("🔙 Menu", "settings") + ] + ] + ) + ) + + +@Client.on_callback_query(custom_filters.edit_client_setting_filter) +async def edit_client_setting_callback(client: Client, callback_query: CallbackQuery) -> None: + data = callback_query.data.split("#")[1] + field_to_edit = data.split("-")[0] + data_type = convert_type_from_string(data.split("-")[1]) + + write_support(callback_query.data, callback_query.from_user.id) + + await callback_query.edit_message_text( + f"Send the new value for field \"{field_to_edit}\" for client. \n\n**Note:** the field type is \"{data_type}\"", + reply_markup=InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton("🔙 Menu", "settings") + ] + ] + ) + ) diff --git a/src/bot/plugins/callbacks/settings/reload_settings_callbacks.py b/src/bot/plugins/callbacks/settings/reload_settings_callbacks.py new file mode 100644 index 0000000..ffeadea --- /dev/null +++ b/src/bot/plugins/callbacks/settings/reload_settings_callbacks.py @@ -0,0 +1,12 @@ +from pyrogram import Client +from pyrogram.types import CallbackQuery + +from .... import custom_filters +from .....configs import Configs + + +@Client.on_callback_query(custom_filters.reload_settings_filter) +async def reload_settings_callback(client: Client, callback_query: CallbackQuery) -> None: + # TO FIX reload + Configs.reload_config() + await callback_query.answer("Settings Reloaded", show_alert=True) diff --git a/src/bot/plugins/callbacks/settings/users_settings_callbacks.py b/src/bot/plugins/callbacks/settings/users_settings_callbacks.py index 69f5fdb..0dff7f0 100644 --- a/src/bot/plugins/callbacks/settings/users_settings_callbacks.py +++ b/src/bot/plugins/callbacks/settings/users_settings_callbacks.py @@ -8,14 +8,12 @@ from .....db_management import write_support from .....utils import get_user_from_config -BOT_CONFIGS = Configs.config - @Client.on_callback_query(custom_filters.get_users_filter) async def get_users_callback(client: Client, callback_query: CallbackQuery) -> None: users = [ [InlineKeyboardButton(f"User #{i.user_id}", f"user_info#{i.user_id}")] - for i in BOT_CONFIGS.users + for i in Configs.config.users ] await callback_query.edit_message_text( @@ -24,7 +22,7 @@ async def get_users_callback(client: Client, callback_query: CallbackQuery) -> N users + [ [ - InlineKeyboardButton("🔙 Menu", "menu") + InlineKeyboardButton("🔙 Menu", "settings") ] ] ) @@ -90,7 +88,7 @@ async def edit_user_callback(client: Client, callback_query: CallbackQuery) -> N write_support(callback_query.data, callback_query.from_user.id) await callback_query.edit_message_text( - f"Send the new value for {field_to_edit} for user #{user_id}. \n\n**Note:** the field type is {data_type}", + f"Send the new value for field \"{field_to_edit}\" for user #{user_id}. \n\n**Note:** the field type is \"{data_type}\"", reply_markup=InlineKeyboardMarkup( [ [ @@ -108,13 +106,13 @@ async def toggle_user_var(client: Client, callback_query: CallbackQuery) -> None field_to_edit = data.split("-")[1] user_info = get_user_from_config(user_id) - user_from_configs = BOT_CONFIGS.users.index(user_info) + user_from_configs = Configs.config.users.index(user_info) if user_from_configs == -1: return - BOT_CONFIGS.users[user_from_configs].notify = not BOT_CONFIGS.users[user_from_configs].notify - Configs.update_config(BOT_CONFIGS) + Configs.config.users[user_from_configs].notify = not Configs.config.users[user_from_configs].notify + Configs.update_config(Configs.config) await callback_query.edit_message_text( f"Edit User #{user_id} {field_to_edit} Field", @@ -122,7 +120,7 @@ async def toggle_user_var(client: Client, callback_query: CallbackQuery) -> None [ [ InlineKeyboardButton( - f"{'✅' if BOT_CONFIGS.users[user_from_configs].notify else '❌'} Toggle", + f"{'✅' if Configs.config.users[user_from_configs].notify else '❌'} Toggle", f"toggle_user_var#{user_id}-{field_to_edit}") ], [ diff --git a/src/bot/plugins/commands.py b/src/bot/plugins/commands.py index 120805e..1697b98 100644 --- a/src/bot/plugins/commands.py +++ b/src/bot/plugins/commands.py @@ -7,13 +7,10 @@ from .common import send_menu -BOT_CONFIGS = Configs.config - - @Client.on_message(filters.command("start")) async def start_command(client: Client, message: Message) -> None: """Start the bot.""" - if message.from_user.id in [i.user_id for i in BOT_CONFIGS.users]: + if message.from_user.id in [i.user_id for i in Configs.config.users]: await send_menu(client, message.id, message.chat.id) else: @@ -24,7 +21,7 @@ async def start_command(client: Client, message: Message) -> None: @Client.on_message(filters.command("stats")) async def stats_command(client: Client, message: Message) -> None: - if message.from_user.id in [i.user_id for i in BOT_CONFIGS.users]: + if message.from_user.id in [i.user_id for i in Configs.config.users]: stats_text = f"**============SYSTEM============**\n" \ f"**CPU Usage:** {psutil.cpu_percent(interval=None)}%\n" \ diff --git a/src/bot/plugins/on_message.py b/src/bot/plugins/on_message.py index 2f37f57..d1257d3 100644 --- a/src/bot/plugins/on_message.py +++ b/src/bot/plugins/on_message.py @@ -9,7 +9,6 @@ from ...configs import Configs from ...utils import get_user_from_config, convert_type_from_string -BOT_CONFIGS = Configs.config logger = logging.getLogger(__name__) @@ -72,7 +71,7 @@ async def on_text(client: Client, message: Message) -> None: await client.send_message(message.from_user.id, "The path entered does not exist! Retry") elif "edit_user" in action: - data = db_management.read_support(message.from_user.id).split("#")[1] + data = action.split("#")[1] user_id = int(data.split("-")[0]) field_to_edit = data.split("-")[1] data_type = convert_type_from_string(data.split("-")[2].replace("", "")) @@ -81,22 +80,43 @@ async def on_text(client: Client, message: Message) -> None: new_value = data_type(message.text) user_info = get_user_from_config(user_id) - user_from_configs = BOT_CONFIGS.users.index(user_info) + user_from_configs = Configs.config.users.index(user_info) if user_from_configs == -1: return - setattr(BOT_CONFIGS.users[user_from_configs], field_to_edit, new_value) - Configs.update_config(BOT_CONFIGS) + setattr(Configs.config.users[user_from_configs], field_to_edit, new_value) + Configs.update_config(Configs.config) + Configs.reload_config() logger.debug(f"Updating User #{user_id} {field_to_edit} settings to {new_value}") db_management.write_support("None", message.from_user.id) - await send_menu(client, message, message.chat) - except Exception: + await send_menu(client, message.id, message.from_user.id) + except Exception as e: await message.reply_text( - f"Error: unable to convert value \"{message.text}\" to type \"{data_type}\"" + f"Error: {e}" ) logger.exception(f"Error converting value \"{message.text}\" to type \"{data_type}\"", exc_info=True) + elif "edit_clt" in action: + data = action.split("#")[1] + field_to_edit = data.split("-")[0] + data_type = convert_type_from_string(data.split("-")[1]) + + try: + new_value = data_type(message.text) + + setattr(Configs.config.clients, field_to_edit, new_value) + Configs.update_config(Configs.config) + Configs.reload_config() + logger.debug(f"Updating Client field \"{field_to_edit}\" to \"{new_value}\"") + db_management.write_support("None", message.from_user.id) + + await send_menu(client, message.id, message.from_user.id) + except Exception as e: + await message.reply_text( + f"Error: {e}" + ) + logger.exception(f"Error converting value \"{message.text}\" to type \"{data_type}\"", exc_info=True) else: await client.send_message(message.from_user.id, "The command does not exist") diff --git a/src/utils.py b/src/utils.py index 00cdad8..94c0561 100644 --- a/src/utils.py +++ b/src/utils.py @@ -11,15 +11,12 @@ from .configs.user import User -BOT_CONFIGS = Configs.config - - async def torrent_finished(app): with QbittorrentManagement() as qb: for i in qb.get_torrent_info(status_filter="completed"): if db_management.read_completed_torrents(i.hash) is None: - for user in BOT_CONFIGS.users: + for user in Configs.config.users: if user.notify: try: await app.send_message(user.user_id, f"torrent {i.name} has finished downloading!") @@ -31,7 +28,7 @@ async def torrent_finished(app): def get_user_from_config(user_id: int) -> User: return next( iter( - [i for i in BOT_CONFIGS.users if i.user_id == user_id] + [i for i in Configs.config.users if i.user_id == user_id] ) ) From a6c820ad6142d447b501bbd12df83c431c27e77f Mon Sep 17 00:00:00 2001 From: ch3p4ll3 Date: Sat, 16 Dec 2023 03:16:01 +0100 Subject: [PATCH 11/25] add check qbittorrent connection --- src/bot/custom_filters.py | 24 +++++++++--- .../settings/client_settings_callbacks.py | 37 ++++++++++++++++++- .../settings/users_settings_callbacks.py | 14 ++++--- src/qbittorrent_manager.py | 4 ++ 4 files changed, 66 insertions(+), 13 deletions(-) diff --git a/src/bot/custom_filters.py b/src/bot/custom_filters.py index 423f9b4..a8db5a3 100644 --- a/src/bot/custom_filters.py +++ b/src/bot/custom_filters.py @@ -1,31 +1,45 @@ from pyrogram import filters +# Categories filters add_category_filter = filters.create(lambda _, __, query: query.data == "add_category") remove_category_filter = filters.create(lambda _, __, query: query.data.startswith("remove_category")) modify_category_filter = filters.create(lambda _, __, query: query.data.startswith("modify_category")) category_filter = filters.create(lambda _, __, query: query.data.startswith("category")) -menu_filter = filters.create(lambda _, __, query: query.data.startswith("menu")) -list_filter = filters.create(lambda _, __, query: query.data.startswith("list")) -list_by_status_filter = filters.create(lambda _, __, query: query.data.split("#")[0] == "by_status_list") +select_category_filter = filters.create(lambda _, __, query: query.data.startswith("select_category")) + +# Add filters add_magnet_filter = filters.create(lambda _, __, query: query.data.startswith("add_magnet")) add_torrent_filter = filters.create(lambda _, __, query: query.data.startswith("add_torrent")) + +# Pause/Resume filters pause_all_filter = filters.create(lambda _, __, query: query.data.startswith("pause_all")) resume_all_filter = filters.create(lambda _, __, query: query.data.startswith("resume_all")) pause_filter = filters.create(lambda _, __, query: query.data.startswith("pause")) resume_filter = filters.create(lambda _, __, query: query.data.startswith("resume")) + +# Delete filers delete_one_filter = filters.create(lambda _, __, query: query.data.split("#")[0] == "delete_one") delete_one_no_data_filter = filters.create(lambda _, __, query: query.data.startswith("delete_one_no_data")) delete_one_data_filter = filters.create(lambda _, __, query: query.data.startswith("delete_one_data")) delete_all_filter = filters.create(lambda _, __, query: query.data.split("#")[0] == "delete_all") delete_all_no_data_filter = filters.create(lambda _, __, query: query.data.startswith("delete_all_no_data")) delete_all_data_filter = filters.create(lambda _, __, query: query.data.startswith("delete_all_data")) -torrentInfo_filter = filters.create(lambda _, __, query: query.data.startswith("torrentInfo")) -select_category_filter = filters.create(lambda _, __, query: query.data.startswith("select_category")) + +# Settings filters settings_filter = filters.create(lambda _, __, query: query.data == "settings") get_users_filter = filters.create(lambda _, __, query: query.data == "get_users") user_info_filter = filters.create(lambda _, __, query: query.data.startswith("user_info")) edit_user_filter = filters.create(lambda _, __, query: query.data.startswith("edit_user")) toggle_user_var_filter = filters.create(lambda _, __, query: query.data.startswith("toggle_user_var")) edit_client_settings_filter = filters.create(lambda _, __, query: query.data == "edit_client") +list_client_settings_filter = filters.create(lambda _, __, query: query.data == "lst_client") +check_connection_filter = filters.create(lambda _, __, query: query.data == "check_connection") edit_client_setting_filter = filters.create(lambda _, __, query: query.data.startswith("edit_clt")) reload_settings_filter = filters.create(lambda _, __, query: query.data == "reload_settings") + + +# Other +torrentInfo_filter = filters.create(lambda _, __, query: query.data.startswith("torrentInfo")) +menu_filter = filters.create(lambda _, __, query: query.data.startswith("menu")) +list_filter = filters.create(lambda _, __, query: query.data.startswith("list")) +list_by_status_filter = filters.create(lambda _, __, query: query.data.split("#")[0] == "by_status_list") diff --git a/src/bot/plugins/callbacks/settings/client_settings_callbacks.py b/src/bot/plugins/callbacks/settings/client_settings_callbacks.py index 80bdcc9..e706736 100644 --- a/src/bot/plugins/callbacks/settings/client_settings_callbacks.py +++ b/src/bot/plugins/callbacks/settings/client_settings_callbacks.py @@ -3,12 +3,45 @@ from .... import custom_filters from .....configs import Configs +from .....qbittorrent_manager import QbittorrentManagement from .....utils import convert_type_from_string from .....db_management import write_support @Client.on_callback_query(custom_filters.edit_client_settings_filter) async def edit_client_settings_callback(client: Client, callback_query: CallbackQuery) -> None: + confs = '\n- '.join(iter([f"**{key.capitalize()}:** {item}" for key, item in Configs.config.clients.model_dump().items()])) + + await callback_query.edit_message_text( + f"Edit Qbittorrent Client Settings \n\n**Current Settings:**\n- {confs}", + reply_markup=InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton("📝 Edit Client Settings", "lst_client") + ], + [ + InlineKeyboardButton("✅ Check Client connection", "check_connection") + ], + [ + InlineKeyboardButton("🔙 Settings", "settings") + ] + ] + ) + ) + + +@Client.on_callback_query(custom_filters.check_connection_filter) +async def check_connection_callback(client: Client, callback_query: CallbackQuery) -> None: + try: + with QbittorrentManagement() as qb: + version = qb.check_connection() + await callback_query.answer(f"✅ The connection works. QBittorrent version: {version}", show_alert=True) + except Exception: + await callback_query.answer("❌ Unable to establish connection with QBittorrent", show_alert=True) + + +@Client.on_callback_query(custom_filters.list_client_settings_filter) +async def list_client_settings_callback(client: Client, callback_query: CallbackQuery) -> None: # get all fields of the model dynamically fields = [ [InlineKeyboardButton(f"Edit {key.replace('_', ' ').capitalize()}", @@ -22,7 +55,7 @@ async def edit_client_settings_callback(client: Client, callback_query: Callback fields + [ [ - InlineKeyboardButton("🔙 Menu", "settings") + InlineKeyboardButton("🔙 Settings", "settings") ] ] ) @@ -42,7 +75,7 @@ async def edit_client_setting_callback(client: Client, callback_query: CallbackQ reply_markup=InlineKeyboardMarkup( [ [ - InlineKeyboardButton("🔙 Menu", "settings") + InlineKeyboardButton("🔙 Settings", "settings") ] ] ) diff --git a/src/bot/plugins/callbacks/settings/users_settings_callbacks.py b/src/bot/plugins/callbacks/settings/users_settings_callbacks.py index 0dff7f0..6925a2a 100644 --- a/src/bot/plugins/callbacks/settings/users_settings_callbacks.py +++ b/src/bot/plugins/callbacks/settings/users_settings_callbacks.py @@ -22,7 +22,7 @@ async def get_users_callback(client: Client, callback_query: CallbackQuery) -> N users + [ [ - InlineKeyboardButton("🔙 Menu", "settings") + InlineKeyboardButton("🔙 Settings", "settings") ] ] ) @@ -44,13 +44,15 @@ async def get_user_info_callback(client: Client, callback_query: CallbackQuery) for key, item in user_info.model_fields.items() ] + confs = '\n- '.join(iter([f"**{key.capitalize()}:** {item}" for key, item in user_info.model_dump().items()])) + await callback_query.edit_message_text( - f"Edit User #{user_id}", + f"Edit User #{user_id}\n\n**Current Settings:**\n- {confs}", reply_markup=InlineKeyboardMarkup( fields + [ [ - InlineKeyboardButton("🔙 Menu", "menu") + InlineKeyboardButton(f"🔙 Users", f"get_users") ] ] ) @@ -77,7 +79,7 @@ async def edit_user_callback(client: Client, callback_query: CallbackQuery) -> N f"toggle_user_var#{user_id}-{field_to_edit}") ], [ - InlineKeyboardButton("🔙 Menu", f"user_info#{user_id}") + InlineKeyboardButton(f"🔙 User#{user_id} info", f"user_info#{user_id}") ] ] ) @@ -92,7 +94,7 @@ async def edit_user_callback(client: Client, callback_query: CallbackQuery) -> N reply_markup=InlineKeyboardMarkup( [ [ - InlineKeyboardButton("🔙 Menu", f"user_info#{user_id}") + InlineKeyboardButton(f"🔙 User#{user_id} info", f"user_info#{user_id}") ] ] ) @@ -124,7 +126,7 @@ async def toggle_user_var(client: Client, callback_query: CallbackQuery) -> None f"toggle_user_var#{user_id}-{field_to_edit}") ], [ - InlineKeyboardButton("🔙 Menu", f"user_info#{user_id}") + InlineKeyboardButton(f"🔙 User#{user_id} info", f"user_info#{user_id}") ] ] ) diff --git a/src/qbittorrent_manager.py b/src/qbittorrent_manager.py index 519ea17..2352161 100644 --- a/src/qbittorrent_manager.py +++ b/src/qbittorrent_manager.py @@ -111,3 +111,7 @@ def create_category(self, name: str, save_path: str) -> None: def remove_category(self, name: str) -> None: logger.debug(f"Removing category {name}") self.qbt_client.torrents_remove_categories(categories=name) + + def check_connection(self) -> str: + logger.debug("Checking Qbt Connection") + return self.qbt_client.app.version From 300471b5ca3f495fc02c48fe940f83a191e4e7f2 Mon Sep 17 00:00:00 2001 From: Mattia Vidoni Date: Sat, 16 Dec 2023 03:30:56 +0100 Subject: [PATCH 12/25] Update docker-image.yml --- .github/workflows/docker-image.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index e92d406..561fabd 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -13,6 +13,11 @@ jobs: runs-on: ubuntu-latest steps: + - name: "Set current date as env variable" + run: | + echo "builddate=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT + id: version # this is used on variable path + - name: Checkout uses: actions/checkout@v3 - @@ -36,4 +41,6 @@ jobs: context: . file: ./Dockerfile push: true - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/qbittorrent-bot:latest + tags: | + ${{ secrets.DOCKER_HUB_USERNAME }}/qbittorrent-bot:${{ steps.version.outputs.builddate }} + ${{ secrets.DOCKER_HUB_USERNAME }}/qbittorrent-bot:latest From f7b9d947f863f543b9d03268d8a1da80fc93aa8b Mon Sep 17 00:00:00 2001 From: ch3p4ll3 Date: Sat, 16 Dec 2023 18:19:44 +0100 Subject: [PATCH 13/25] add users role (reader, manager, administrator) --- config.json.template | 3 +- src/bot/custom_filters.py | 11 +++++ .../callbacks/add_torrents_callbacks.py | 4 +- .../plugins/callbacks/category_callbacks.py | 10 ++-- .../plugins/callbacks/delete_all_callbacks.py | 6 +-- .../callbacks/delete_single_callbacks.py | 6 +-- src/bot/plugins/callbacks/list_callbacks.py | 6 +-- src/bot/plugins/callbacks/pause_callbacks.py | 4 +- src/bot/plugins/callbacks/resume_callbacks.py | 4 +- .../plugins/callbacks/settings/__init__.py | 2 +- .../settings/client_settings_callbacks.py | 2 +- .../settings/reload_settings_callbacks.py | 2 +- .../settings/users_settings_callbacks.py | 8 ++-- src/bot/plugins/callbacks/torrent_info.py | 2 +- src/bot/plugins/commands.py | 45 ++++++++---------- src/bot/plugins/common.py | 46 ++++++++++++------- src/bot/plugins/on_message.py | 4 +- src/configs/enums.py | 6 +++ src/configs/user.py | 3 ++ src/db_management.py | 2 +- src/utils.py | 4 +- 21 files changed, 106 insertions(+), 74 deletions(-) diff --git a/config.json.template b/config.json.template index 9e96c0b..563d5d9 100644 --- a/config.json.template +++ b/config.json.template @@ -15,7 +15,8 @@ "users": [ { "user_id": 123456, - "notify": false + "notify": false, + "role": "administrator" } ] } \ No newline at end of file diff --git a/src/bot/custom_filters.py b/src/bot/custom_filters.py index a8db5a3..91f3cd0 100644 --- a/src/bot/custom_filters.py +++ b/src/bot/custom_filters.py @@ -1,4 +1,15 @@ from pyrogram import filters +from ..configs import Configs +from ..utils import get_user_from_config +from ..configs.enums import UserRolesEnum + + +# Authorization filters +check_user_filter = filters.create(lambda _, __, message: message.from_user.id in [i.user_id for i in Configs.config.users]) + +user_is_reader = filters.create(lambda _, __, query: get_user_from_config(query.from_user.id).role == UserRolesEnum.Reader) +user_is_manager = filters.create(lambda _, __, query: get_user_from_config(query.from_user.id).role == UserRolesEnum.Manager) +user_is_administrator = filters.create(lambda _, __, query: get_user_from_config(query.from_user.id).role == UserRolesEnum.Administrator) # Categories filters add_category_filter = filters.create(lambda _, __, query: query.data == "add_category") diff --git a/src/bot/plugins/callbacks/add_torrents_callbacks.py b/src/bot/plugins/callbacks/add_torrents_callbacks.py index 675ce81..2035a55 100644 --- a/src/bot/plugins/callbacks/add_torrents_callbacks.py +++ b/src/bot/plugins/callbacks/add_torrents_callbacks.py @@ -4,13 +4,13 @@ from ... import custom_filters -@Client.on_callback_query(custom_filters.add_magnet_filter) +@Client.on_callback_query(custom_filters.add_magnet_filter & custom_filters.check_user_filter & (custom_filters.user_is_administrator | custom_filters.user_is_manager)) async def add_magnet_callback(client: Client, callback_query: CallbackQuery) -> None: db_management.write_support(f"magnet#{callback_query.data.split('#')[1]}", callback_query.from_user.id) await client.answer_callback_query(callback_query.id, "Send a magnet link") -@Client.on_callback_query(custom_filters.add_torrent_filter) +@Client.on_callback_query(custom_filters.add_torrent_filter & custom_filters.check_user_filter & (custom_filters.user_is_administrator | custom_filters.user_is_manager)) async def add_torrent_callback(client: Client, callback_query: CallbackQuery) -> None: db_management.write_support(f"torrent#{callback_query.data.split('#')[1]}", callback_query.from_user.id) await client.answer_callback_query(callback_query.id, "Send a torrent file") diff --git a/src/bot/plugins/callbacks/category_callbacks.py b/src/bot/plugins/callbacks/category_callbacks.py index 4356096..c57fcb8 100644 --- a/src/bot/plugins/callbacks/category_callbacks.py +++ b/src/bot/plugins/callbacks/category_callbacks.py @@ -8,7 +8,7 @@ from ....qbittorrent_manager import QbittorrentManagement -@Client.on_callback_query(custom_filters.add_category_filter) +@Client.on_callback_query(custom_filters.add_category_filter & custom_filters.check_user_filter & (custom_filters.user_is_administrator | custom_filters.user_is_manager)) async def add_category_callback(client: Client, callback_query: CallbackQuery) -> None: db_management.write_support("category_name", callback_query.from_user.id) button = InlineKeyboardMarkup([[InlineKeyboardButton("🔙 Menu", "menu")]]) @@ -19,7 +19,7 @@ async def add_category_callback(client: Client, callback_query: CallbackQuery) - await client.send_message(callback_query.from_user.id, "Send the category name", reply_markup=button) -@Client.on_callback_query(custom_filters.select_category_filter) +@Client.on_callback_query(custom_filters.select_category_filter & custom_filters.check_user_filter & (custom_filters.user_is_administrator | custom_filters.user_is_manager)) async def list_categories(client: Client, callback_query: CallbackQuery): buttons = [] @@ -45,7 +45,7 @@ async def list_categories(client: Client, callback_query: CallbackQuery): reply_markup=InlineKeyboardMarkup(buttons)) -@Client.on_callback_query(custom_filters.remove_category_filter) +@Client.on_callback_query(custom_filters.remove_category_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def remove_category_callback(client: Client, callback_query: CallbackQuery) -> None: buttons = [[InlineKeyboardButton("🔙 Menu", "menu")]] @@ -57,7 +57,7 @@ async def remove_category_callback(client: Client, callback_query: CallbackQuery reply_markup=InlineKeyboardMarkup(buttons)) -@Client.on_callback_query(custom_filters.modify_category_filter) +@Client.on_callback_query(custom_filters.modify_category_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def modify_category_callback(client: Client, callback_query: CallbackQuery) -> None: buttons = [[InlineKeyboardButton("🔙 Menu", "menu")]] @@ -67,7 +67,7 @@ async def modify_category_callback(client: Client, callback_query: CallbackQuery reply_markup=InlineKeyboardMarkup(buttons)) -@Client.on_callback_query(custom_filters.category_filter) +@Client.on_callback_query(custom_filters.category_filter & custom_filters.check_user_filter & (custom_filters.user_is_administrator | custom_filters.user_is_manager)) async def category(client: Client, callback_query: CallbackQuery) -> None: buttons = [] diff --git a/src/bot/plugins/callbacks/delete_all_callbacks.py b/src/bot/plugins/callbacks/delete_all_callbacks.py index b2ec3a7..95e3125 100644 --- a/src/bot/plugins/callbacks/delete_all_callbacks.py +++ b/src/bot/plugins/callbacks/delete_all_callbacks.py @@ -6,7 +6,7 @@ from ..common import send_menu -@Client.on_callback_query(custom_filters.delete_all_filter) +@Client.on_callback_query(custom_filters.delete_all_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def delete_all_callback(client: Client, callback_query: CallbackQuery) -> None: buttons = [[InlineKeyboardButton("🗑 Delete all torrents", "delete_all_no_data")], [InlineKeyboardButton("🗑 Delete all torrents and data", "delete_all_data")], @@ -15,7 +15,7 @@ async def delete_all_callback(client: Client, callback_query: CallbackQuery) -> reply_markup=InlineKeyboardMarkup(buttons)) -@Client.on_callback_query(custom_filters.delete_all_no_data_filter) +@Client.on_callback_query(custom_filters.delete_all_no_data_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def delete_all_with_no_data_callback(client: Client, callback_query: CallbackQuery) -> None: with QbittorrentManagement() as qb: qb.delete_all_no_data() @@ -23,7 +23,7 @@ async def delete_all_with_no_data_callback(client: Client, callback_query: Callb await send_menu(client, callback_query.message.id, callback_query.from_user.id) -@Client.on_callback_query(custom_filters.delete_all_data_filter) +@Client.on_callback_query(custom_filters.delete_all_data_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def delete_all_with_data_callback(client: Client, callback_query: CallbackQuery) -> None: with QbittorrentManagement() as qb: qb.delete_all_data() diff --git a/src/bot/plugins/callbacks/delete_single_callbacks.py b/src/bot/plugins/callbacks/delete_single_callbacks.py index 7ddb026..9e3aece 100644 --- a/src/bot/plugins/callbacks/delete_single_callbacks.py +++ b/src/bot/plugins/callbacks/delete_single_callbacks.py @@ -6,7 +6,7 @@ from ..common import list_active_torrents, send_menu -@Client.on_callback_query(custom_filters.delete_one_filter) +@Client.on_callback_query(custom_filters.delete_one_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def delete_callback(client: Client, callback_query: CallbackQuery) -> None: if callback_query.data.find("#") == -1: await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "delete_one") @@ -22,7 +22,7 @@ async def delete_callback(client: Client, callback_query: CallbackQuery) -> None reply_markup=InlineKeyboardMarkup(buttons)) -@Client.on_callback_query(custom_filters.delete_one_no_data_filter) +@Client.on_callback_query(custom_filters.delete_one_no_data_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def delete_no_data_callback(client: Client, callback_query: CallbackQuery) -> None: if callback_query.data.find("#") == -1: await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "delete_one_no_data") @@ -33,7 +33,7 @@ async def delete_no_data_callback(client: Client, callback_query: CallbackQuery) await send_menu(client, callback_query.message.id, callback_query.from_user.id) -@Client.on_callback_query(custom_filters.delete_one_data_filter) +@Client.on_callback_query(custom_filters.delete_one_data_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def delete_with_data_callback(client: Client, callback_query: CallbackQuery) -> None: if callback_query.data.find("#") == -1: await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "delete_one_data") diff --git a/src/bot/plugins/callbacks/list_callbacks.py b/src/bot/plugins/callbacks/list_callbacks.py index a33bde3..7a7a815 100644 --- a/src/bot/plugins/callbacks/list_callbacks.py +++ b/src/bot/plugins/callbacks/list_callbacks.py @@ -5,19 +5,19 @@ from ..common import list_active_torrents, send_menu -@Client.on_callback_query(custom_filters.list_filter) +@Client.on_callback_query(custom_filters.list_filter & custom_filters.check_user_filter) async def list_callback(client: Client, callback_query: CallbackQuery) -> None: await list_active_torrents(client, 0, callback_query.from_user.id, callback_query.message.id, db_management.read_support(callback_query.from_user.id)) -@Client.on_callback_query(custom_filters.list_by_status_filter) +@Client.on_callback_query(custom_filters.list_by_status_filter & custom_filters.check_user_filter) async def list_by_status_callback(client: Client, callback_query: CallbackQuery) -> None: status_filter = callback_query.data.split("#")[1] await list_active_torrents(client,0, callback_query.from_user.id, callback_query.message.id, db_management.read_support(callback_query.from_user.id), status_filter=status_filter) -@Client.on_callback_query(custom_filters.menu_filter) +@Client.on_callback_query(custom_filters.menu_filter & custom_filters.check_user_filter) async def menu_callback(client: Client, callback_query: CallbackQuery) -> None: await send_menu(client, callback_query.message.id, callback_query.from_user.id) diff --git a/src/bot/plugins/callbacks/pause_callbacks.py b/src/bot/plugins/callbacks/pause_callbacks.py index 56e3e8d..2d416b9 100644 --- a/src/bot/plugins/callbacks/pause_callbacks.py +++ b/src/bot/plugins/callbacks/pause_callbacks.py @@ -6,14 +6,14 @@ from ..common import list_active_torrents, send_menu -@Client.on_callback_query(custom_filters.pause_all_filter) +@Client.on_callback_query(custom_filters.pause_all_filter & custom_filters.check_user_filter & (custom_filters.user_is_administrator | custom_filters.user_is_manager)) async def pause_all_callback(client: Client, callback_query: CallbackQuery) -> None: with QbittorrentManagement() as qb: qb.pause_all() await client.answer_callback_query(callback_query.id, "Paused all torrents") -@Client.on_callback_query(custom_filters.pause_filter) +@Client.on_callback_query(custom_filters.pause_filter & custom_filters.check_user_filter & (custom_filters.user_is_administrator | custom_filters.user_is_manager)) async def pause_callback(client: Client, callback_query: CallbackQuery) -> None: if callback_query.data.find("#") == -1: await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "pause") diff --git a/src/bot/plugins/callbacks/resume_callbacks.py b/src/bot/plugins/callbacks/resume_callbacks.py index 05887ab..2ce135d 100644 --- a/src/bot/plugins/callbacks/resume_callbacks.py +++ b/src/bot/plugins/callbacks/resume_callbacks.py @@ -6,14 +6,14 @@ from ..common import list_active_torrents, send_menu -@Client.on_callback_query(custom_filters.resume_all_filter) +@Client.on_callback_query(custom_filters.resume_all_filter & custom_filters.check_user_filter & (custom_filters.user_is_administrator | custom_filters.user_is_manager)) async def resume_all_callback(client: Client, callback_query: CallbackQuery) -> None: with QbittorrentManagement() as qb: qb.resume_all() await client.answer_callback_query(callback_query.id, "Resumed all torrents") -@Client.on_callback_query(custom_filters.resume_filter) +@Client.on_callback_query(custom_filters.resume_filter & custom_filters.check_user_filter & (custom_filters.user_is_administrator | custom_filters.user_is_manager)) async def resume_callback(client: Client, callback_query: CallbackQuery) -> None: if callback_query.data.find("#") == -1: await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "resume") diff --git a/src/bot/plugins/callbacks/settings/__init__.py b/src/bot/plugins/callbacks/settings/__init__.py index 60bdf06..b021c8c 100644 --- a/src/bot/plugins/callbacks/settings/__init__.py +++ b/src/bot/plugins/callbacks/settings/__init__.py @@ -3,7 +3,7 @@ from .... import custom_filters -@Client.on_callback_query(custom_filters.settings_filter) +@Client.on_callback_query(custom_filters.settings_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def settings_callback(client: Client, callback_query: CallbackQuery) -> None: await callback_query.edit_message_text( "QBittorrentBot Settings", diff --git a/src/bot/plugins/callbacks/settings/client_settings_callbacks.py b/src/bot/plugins/callbacks/settings/client_settings_callbacks.py index e706736..71f61be 100644 --- a/src/bot/plugins/callbacks/settings/client_settings_callbacks.py +++ b/src/bot/plugins/callbacks/settings/client_settings_callbacks.py @@ -8,7 +8,7 @@ from .....db_management import write_support -@Client.on_callback_query(custom_filters.edit_client_settings_filter) +@Client.on_callback_query(custom_filters.edit_client_settings_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def edit_client_settings_callback(client: Client, callback_query: CallbackQuery) -> None: confs = '\n- '.join(iter([f"**{key.capitalize()}:** {item}" for key, item in Configs.config.clients.model_dump().items()])) diff --git a/src/bot/plugins/callbacks/settings/reload_settings_callbacks.py b/src/bot/plugins/callbacks/settings/reload_settings_callbacks.py index ffeadea..292cc8d 100644 --- a/src/bot/plugins/callbacks/settings/reload_settings_callbacks.py +++ b/src/bot/plugins/callbacks/settings/reload_settings_callbacks.py @@ -5,7 +5,7 @@ from .....configs import Configs -@Client.on_callback_query(custom_filters.reload_settings_filter) +@Client.on_callback_query(custom_filters.reload_settings_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def reload_settings_callback(client: Client, callback_query: CallbackQuery) -> None: # TO FIX reload Configs.reload_config() diff --git a/src/bot/plugins/callbacks/settings/users_settings_callbacks.py b/src/bot/plugins/callbacks/settings/users_settings_callbacks.py index 6925a2a..168ab63 100644 --- a/src/bot/plugins/callbacks/settings/users_settings_callbacks.py +++ b/src/bot/plugins/callbacks/settings/users_settings_callbacks.py @@ -9,7 +9,7 @@ from .....utils import get_user_from_config -@Client.on_callback_query(custom_filters.get_users_filter) +@Client.on_callback_query(custom_filters.get_users_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def get_users_callback(client: Client, callback_query: CallbackQuery) -> None: users = [ [InlineKeyboardButton(f"User #{i.user_id}", f"user_info#{i.user_id}")] @@ -29,7 +29,7 @@ async def get_users_callback(client: Client, callback_query: CallbackQuery) -> N ) -@Client.on_callback_query(custom_filters.user_info_filter) +@Client.on_callback_query(custom_filters.user_info_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def get_user_info_callback(client: Client, callback_query: CallbackQuery) -> None: user_id = int(callback_query.data.split("#")[1]) @@ -59,7 +59,7 @@ async def get_user_info_callback(client: Client, callback_query: CallbackQuery) ) -@Client.on_callback_query(custom_filters.edit_user_filter) +@Client.on_callback_query(custom_filters.edit_user_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def edit_user_callback(client: Client, callback_query: CallbackQuery) -> None: data = callback_query.data.split("#")[1] user_id = int(data.split("-")[0]) @@ -101,7 +101,7 @@ async def edit_user_callback(client: Client, callback_query: CallbackQuery) -> N ) -@Client.on_callback_query(custom_filters.toggle_user_var_filter) +@Client.on_callback_query(custom_filters.toggle_user_var_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def toggle_user_var(client: Client, callback_query: CallbackQuery) -> None: data = callback_query.data.split("#")[1] user_id = int(data.split("-")[0]) diff --git a/src/bot/plugins/callbacks/torrent_info.py b/src/bot/plugins/callbacks/torrent_info.py index 185998a..efcc6a0 100644 --- a/src/bot/plugins/callbacks/torrent_info.py +++ b/src/bot/plugins/callbacks/torrent_info.py @@ -8,7 +8,7 @@ from ....utils import convert_size, convert_eta -@Client.on_callback_query(custom_filters.torrentInfo_filter) +@Client.on_callback_query(custom_filters.torrentInfo_filter & custom_filters.check_user_filter) async def torrent_info_callback(client: Client, callback_query: CallbackQuery) -> None: with QbittorrentManagement() as qb: torrent = qb.get_torrent_info(callback_query.data.split("#")[1]) diff --git a/src/bot/plugins/commands.py b/src/bot/plugins/commands.py index 1697b98..acae72c 100644 --- a/src/bot/plugins/commands.py +++ b/src/bot/plugins/commands.py @@ -2,38 +2,33 @@ from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message import psutil +from .. import custom_filters from ...utils import convert_size from ...configs import Configs from .common import send_menu -@Client.on_message(filters.command("start")) +@Client.on_message(~custom_filters.check_user_filter) +async def access_denied_message(client: Client, message: Message) -> None: + button = InlineKeyboardMarkup([[InlineKeyboardButton("Github", + url="https://github.com/ch3p4ll3/QBittorrentBot/")]]) + await client.send_message(message.chat.id, "You are not authorized to use this bot", reply_markup=button) + + +@Client.on_message(filters.command("start") & custom_filters.check_user_filter) async def start_command(client: Client, message: Message) -> None: """Start the bot.""" - if message.from_user.id in [i.user_id for i in Configs.config.users]: - await send_menu(client, message.id, message.chat.id) - - else: - button = InlineKeyboardMarkup([[InlineKeyboardButton("Github", - url="https://github.com/ch3p4ll3/QBittorrentBot/")]]) - await client.send_message(message.chat.id, "You are not authorized to use this bot", reply_markup=button) + await send_menu(client, message.id, message.chat.id) -@Client.on_message(filters.command("stats")) +@Client.on_message(filters.command("stats") & custom_filters.check_user_filter) async def stats_command(client: Client, message: Message) -> None: - if message.from_user.id in [i.user_id for i in Configs.config.users]: - - stats_text = f"**============SYSTEM============**\n" \ - f"**CPU Usage:** {psutil.cpu_percent(interval=None)}%\n" \ - f"**CPU Temp:** {psutil.sensors_temperatures()['coretemp'][0].current}°C\n" \ - f"**Free Memory:** {convert_size(psutil.virtual_memory().available)} of " \ - f"{convert_size(psutil.virtual_memory().total)} ({psutil.virtual_memory().percent}%)\n" \ - f"**Disks usage:** {convert_size(psutil.disk_usage('/mnt').used)} of " \ - f"{convert_size(psutil.disk_usage('/mnt').total)} ({psutil.disk_usage('/mnt').percent}%)" - - await client.send_message(message.chat.id, stats_text) - - else: - button = InlineKeyboardMarkup([[InlineKeyboardButton("Github", - url="https://github.com/ch3p4ll3/QBittorrentBot/")]]) - await client.send_message(message.chat.id, "You are not authorized to use this bot", reply_markup=button) + stats_text = f"**============SYSTEM============**\n" \ + f"**CPU Usage:** {psutil.cpu_percent(interval=None)}%\n" \ + f"**CPU Temp:** {psutil.sensors_temperatures()['coretemp'][0].current}°C\n" \ + f"**Free Memory:** {convert_size(psutil.virtual_memory().available)} of " \ + f"{convert_size(psutil.virtual_memory().total)} ({psutil.virtual_memory().percent}%)\n" \ + f"**Disks usage:** {convert_size(psutil.disk_usage('/mnt').used)} of " \ + f"{convert_size(psutil.disk_usage('/mnt').total)} ({psutil.disk_usage('/mnt').percent}%)" + + await client.send_message(message.chat.id, stats_text) diff --git a/src/bot/plugins/common.py b/src/bot/plugins/common.py index f6a3802..ac33ccb 100644 --- a/src/bot/plugins/common.py +++ b/src/bot/plugins/common.py @@ -4,32 +4,44 @@ from ... import db_management from ...qbittorrent_manager import QbittorrentManagement +from ...utils import get_user_from_config +from ...configs.enums import UserRolesEnum -async def send_menu(client: Client, message, chat) -> None: - db_management.write_support("None", chat) +async def send_menu(client: Client, message_id: int, chat_id: int) -> None: + user = get_user_from_config(chat_id) buttons = [ - [InlineKeyboardButton("📝 List", "list")], - [InlineKeyboardButton("➕ Add Magnet", "category#add_magnet"), - InlineKeyboardButton("➕ Add Torrent", "category#add_torrent")], - [InlineKeyboardButton("⏸ Pause", "pause"), - InlineKeyboardButton("▶️ Resume", "resume")], - [InlineKeyboardButton("⏸ Pause All", "pause_all"), - InlineKeyboardButton("▶️ Resume All", "resume_all")], - [InlineKeyboardButton("🗑 Delete", "delete_one"), - InlineKeyboardButton("🗑 Delete All", "delete_all")], - [InlineKeyboardButton("➕ Add Category", "add_category"), - InlineKeyboardButton("🗑 Remove Category", "select_category#remove_category")], - [InlineKeyboardButton("📝 Modify Category", "select_category#modify_category")], - [InlineKeyboardButton("⚙️ Settings", "settings")] + [InlineKeyboardButton("📝 List", "list")] ] + if user.role in [UserRolesEnum.Manager, UserRolesEnum.Administrator]: + buttons += [ + [InlineKeyboardButton("➕ Add Magnet", "category#add_magnet"), + InlineKeyboardButton("➕ Add Torrent", "category#add_torrent")], + [InlineKeyboardButton("⏸ Pause", "pause"), + InlineKeyboardButton("▶️ Resume", "resume")], + [InlineKeyboardButton("⏸ Pause All", "pause_all"), + InlineKeyboardButton("▶️ Resume All", "resume_all")], + ] + + if user.role == UserRolesEnum.Administrator: + buttons += [ + [InlineKeyboardButton("🗑 Delete", "delete_one"), + InlineKeyboardButton("🗑 Delete All", "delete_all")], + [InlineKeyboardButton("➕ Add Category", "add_category"), + InlineKeyboardButton("🗑 Remove Category", "select_category#remove_category")], + [InlineKeyboardButton("📝 Modify Category", "select_category#modify_category")], + [InlineKeyboardButton("⚙️ Settings", "settings")] + ] + + db_management.write_support("None", chat_id) + try: - await client.edit_message_text(chat, message, text="Qbittorrent Control", + await client.edit_message_text(chat_id, message_id, text="Qbittorrent Control", reply_markup=InlineKeyboardMarkup(buttons)) except MessageIdInvalid: - await client.send_message(chat, text="Qbittorrent Control", reply_markup=InlineKeyboardMarkup(buttons)) + await client.send_message(chat_id, text="Qbittorrent Control", reply_markup=InlineKeyboardMarkup(buttons)) async def list_active_torrents(client: Client, n, chat, message, callback, status_filter: str = None) -> None: diff --git a/src/bot/plugins/on_message.py b/src/bot/plugins/on_message.py index d1257d3..fc62993 100644 --- a/src/bot/plugins/on_message.py +++ b/src/bot/plugins/on_message.py @@ -8,11 +8,13 @@ from .common import send_menu from ...configs import Configs from ...utils import get_user_from_config, convert_type_from_string +from .. import custom_filters + logger = logging.getLogger(__name__) -@Client.on_message(~filters.me) +@Client.on_message(~filters.me & custom_filters.check_user_filter) async def on_text(client: Client, message: Message) -> None: action = db_management.read_support(message.from_user.id) diff --git a/src/configs/enums.py b/src/configs/enums.py index 9f7548c..3c58caf 100644 --- a/src/configs/enums.py +++ b/src/configs/enums.py @@ -3,3 +3,9 @@ class ClientTypeEnum(str, Enum): QBittorrent = 'qbittorrent' + + +class UserRolesEnum(str, Enum): + Reader = "reader" + Manager = "manager" + Administrator = "administrator" diff --git a/src/configs/user.py b/src/configs/user.py index 8f667ed..a7d45d3 100644 --- a/src/configs/user.py +++ b/src/configs/user.py @@ -1,7 +1,10 @@ from pydantic import BaseModel from typing import Optional +from src.configs.enums import UserRolesEnum + class User(BaseModel): user_id: int + role: UserRolesEnum = UserRolesEnum.Reader notify: Optional[bool] = True diff --git a/src/db_management.py b/src/db_management.py index fae80c3..1f56dc7 100644 --- a/src/db_management.py +++ b/src/db_management.py @@ -3,7 +3,7 @@ from os import getenv db = Database() -db.bind(provider='sqlite', filename=f'{"/app/config/" if getenv("IS_DOCKER", False) else "./"}/database.sqlite', +db.bind(provider='sqlite', filename=f'{"/app/config/" if getenv("IS_DOCKER", False) else "../"}/database.sqlite', create_db=True) diff --git a/src/utils.py b/src/utils.py index 94c0561..96f28d3 100644 --- a/src/utils.py +++ b/src/utils.py @@ -7,7 +7,7 @@ from src import db_management from src.qbittorrent_manager import QbittorrentManagement from .configs import Configs -from .configs.enums import ClientTypeEnum +from .configs.enums import ClientTypeEnum, UserRolesEnum from .configs.user import User @@ -54,5 +54,7 @@ def convert_type_from_string(input_type: str): return IPvAnyAddress elif "ClientTypeEnum" in input_type: return ClientTypeEnum + elif "UserRolesEnum" in input_type: + return UserRolesEnum elif "str" in input_type: return str From 49d7e97bdc2d6b578bcf462f05ac481c64d3b9cc Mon Sep 17 00:00:00 2001 From: ch3p4ll3 Date: Sun, 17 Dec 2023 14:33:45 +0100 Subject: [PATCH 14/25] add client repository --- .../plugins/callbacks/category_callbacks.py | 15 +- .../plugins/callbacks/delete_all_callbacks.py | 13 +- .../callbacks/delete_single_callbacks.py | 13 +- src/bot/plugins/callbacks/pause_callbacks.py | 11 +- src/bot/plugins/callbacks/resume_callbacks.py | 11 +- .../settings/client_settings_callbacks.py | 9 +- src/bot/plugins/callbacks/torrent_info.py | 7 +- src/bot/plugins/common.py | 7 +- src/bot/plugins/on_message.py | 28 ++-- src/client_manager/__init__.py | 1 + src/client_manager/client_manager.py | 84 +++++++++++ src/client_manager/client_repo.py | 12 ++ src/client_manager/qbittorrent_manager.py | 142 ++++++++++++++++++ src/qbittorrent_manager.py | 117 --------------- src/utils.py | 4 +- 15 files changed, 305 insertions(+), 169 deletions(-) create mode 100644 src/client_manager/__init__.py create mode 100644 src/client_manager/client_manager.py create mode 100644 src/client_manager/client_repo.py create mode 100644 src/client_manager/qbittorrent_manager.py delete mode 100644 src/qbittorrent_manager.py diff --git a/src/bot/plugins/callbacks/category_callbacks.py b/src/bot/plugins/callbacks/category_callbacks.py index c57fcb8..45492f8 100644 --- a/src/bot/plugins/callbacks/category_callbacks.py +++ b/src/bot/plugins/callbacks/category_callbacks.py @@ -5,7 +5,8 @@ from . import add_magnet_callback, add_torrent_callback from .... import db_management from ... import custom_filters -from ....qbittorrent_manager import QbittorrentManagement +from ....client_manager import ClientRepo +from ....configs import Configs @Client.on_callback_query(custom_filters.add_category_filter & custom_filters.check_user_filter & (custom_filters.user_is_administrator | custom_filters.user_is_manager)) @@ -23,8 +24,8 @@ async def add_category_callback(client: Client, callback_query: CallbackQuery) - async def list_categories(client: Client, callback_query: CallbackQuery): buttons = [] - with QbittorrentManagement() as qb: - categories = qb.get_categories() + repository = ClientRepo.get_client_manager(Configs.config.clients.type) + categories = repository.get_categories() if categories is None: buttons.append([InlineKeyboardButton("🔙 Menu", "menu")]) @@ -49,8 +50,8 @@ async def list_categories(client: Client, callback_query: CallbackQuery): async def remove_category_callback(client: Client, callback_query: CallbackQuery) -> None: buttons = [[InlineKeyboardButton("🔙 Menu", "menu")]] - with QbittorrentManagement() as qb: - qb.remove_category(callback_query.data.split("#")[1]) + repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository.remove_category(callback_query.data.split("#")[1]) await client.edit_message_text(callback_query.from_user.id, callback_query.message.id, f"The category {callback_query.data.split('#')[1]} has been removed", @@ -71,8 +72,8 @@ async def modify_category_callback(client: Client, callback_query: CallbackQuery async def category(client: Client, callback_query: CallbackQuery) -> None: buttons = [] - with QbittorrentManagement() as qb: - categories = qb.get_categories() + repository = ClientRepo.get_client_manager(Configs.config.clients.type) + categories = repository.get_categories() if categories is None: if "magnet" in callback_query.data: diff --git a/src/bot/plugins/callbacks/delete_all_callbacks.py b/src/bot/plugins/callbacks/delete_all_callbacks.py index 95e3125..50e5985 100644 --- a/src/bot/plugins/callbacks/delete_all_callbacks.py +++ b/src/bot/plugins/callbacks/delete_all_callbacks.py @@ -2,8 +2,9 @@ from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, CallbackQuery from ... import custom_filters -from ....qbittorrent_manager import QbittorrentManagement +from ....client_manager import ClientRepo from ..common import send_menu +from ....configs import Configs @Client.on_callback_query(custom_filters.delete_all_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) @@ -17,15 +18,17 @@ async def delete_all_callback(client: Client, callback_query: CallbackQuery) -> @Client.on_callback_query(custom_filters.delete_all_no_data_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def delete_all_with_no_data_callback(client: Client, callback_query: CallbackQuery) -> None: - with QbittorrentManagement() as qb: - qb.delete_all_no_data() + repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository.delete_all_no_data() + await client.answer_callback_query(callback_query.id, "Deleted only torrents") await send_menu(client, callback_query.message.id, callback_query.from_user.id) @Client.on_callback_query(custom_filters.delete_all_data_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def delete_all_with_data_callback(client: Client, callback_query: CallbackQuery) -> None: - with QbittorrentManagement() as qb: - qb.delete_all_data() + repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository.delete_all_data() + await client.answer_callback_query(callback_query.id, "Deleted All+Torrents") await send_menu(client, callback_query.message.id, callback_query.from_user.id) diff --git a/src/bot/plugins/callbacks/delete_single_callbacks.py b/src/bot/plugins/callbacks/delete_single_callbacks.py index 9e3aece..81c1f10 100644 --- a/src/bot/plugins/callbacks/delete_single_callbacks.py +++ b/src/bot/plugins/callbacks/delete_single_callbacks.py @@ -2,8 +2,9 @@ from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, CallbackQuery from ... import custom_filters -from ....qbittorrent_manager import QbittorrentManagement +from ....client_manager import ClientRepo from ..common import list_active_torrents, send_menu +from ....configs import Configs @Client.on_callback_query(custom_filters.delete_one_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) @@ -28,8 +29,9 @@ async def delete_no_data_callback(client: Client, callback_query: CallbackQuery) await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "delete_one_no_data") else: - with QbittorrentManagement() as qb: - qb.delete_one_no_data(torrent_hash=callback_query.data.split("#")[1]) + repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository.delete_one_no_data(torrent_hash=callback_query.data.split("#")[1]) + await send_menu(client, callback_query.message.id, callback_query.from_user.id) @@ -39,6 +41,7 @@ async def delete_with_data_callback(client: Client, callback_query: CallbackQuer await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "delete_one_data") else: - with QbittorrentManagement() as qb: - qb.delete_one_data(torrent_hash=callback_query.data.split("#")[1]) + repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository.delete_one_data(torrent_hash=callback_query.data.split("#")[1]) + await send_menu(client, callback_query.message.id, callback_query.from_user.id) diff --git a/src/bot/plugins/callbacks/pause_callbacks.py b/src/bot/plugins/callbacks/pause_callbacks.py index 2d416b9..e880402 100644 --- a/src/bot/plugins/callbacks/pause_callbacks.py +++ b/src/bot/plugins/callbacks/pause_callbacks.py @@ -2,14 +2,15 @@ from pyrogram.types import CallbackQuery from ... import custom_filters -from ....qbittorrent_manager import QbittorrentManagement +from ....client_manager import ClientRepo from ..common import list_active_torrents, send_menu +from ....configs import Configs @Client.on_callback_query(custom_filters.pause_all_filter & custom_filters.check_user_filter & (custom_filters.user_is_administrator | custom_filters.user_is_manager)) async def pause_all_callback(client: Client, callback_query: CallbackQuery) -> None: - with QbittorrentManagement() as qb: - qb.pause_all() + repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository.pause_all() await client.answer_callback_query(callback_query.id, "Paused all torrents") @@ -19,6 +20,6 @@ async def pause_callback(client: Client, callback_query: CallbackQuery) -> None: await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "pause") else: - with QbittorrentManagement() as qb: - qb.pause(torrent_hash=callback_query.data.split("#")[1]) + repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository.pause(torrent_hash=callback_query.data.split("#")[1]) await send_menu(client, callback_query.message.id, callback_query.from_user.id) diff --git a/src/bot/plugins/callbacks/resume_callbacks.py b/src/bot/plugins/callbacks/resume_callbacks.py index 2ce135d..99e469e 100644 --- a/src/bot/plugins/callbacks/resume_callbacks.py +++ b/src/bot/plugins/callbacks/resume_callbacks.py @@ -2,14 +2,15 @@ from pyrogram.types import CallbackQuery from ... import custom_filters -from ....qbittorrent_manager import QbittorrentManagement +from ....client_manager import ClientRepo from ..common import list_active_torrents, send_menu +from ....configs import Configs @Client.on_callback_query(custom_filters.resume_all_filter & custom_filters.check_user_filter & (custom_filters.user_is_administrator | custom_filters.user_is_manager)) async def resume_all_callback(client: Client, callback_query: CallbackQuery) -> None: - with QbittorrentManagement() as qb: - qb.resume_all() + repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository.resume_all() await client.answer_callback_query(callback_query.id, "Resumed all torrents") @@ -19,6 +20,6 @@ async def resume_callback(client: Client, callback_query: CallbackQuery) -> None await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "resume") else: - with QbittorrentManagement() as qb: - qb.resume(torrent_hash=callback_query.data.split("#")[1]) + repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository.resume(torrent_hash=callback_query.data.split("#")[1]) await send_menu(client, callback_query.message.id, callback_query.from_user.id) diff --git a/src/bot/plugins/callbacks/settings/client_settings_callbacks.py b/src/bot/plugins/callbacks/settings/client_settings_callbacks.py index 71f61be..14966b3 100644 --- a/src/bot/plugins/callbacks/settings/client_settings_callbacks.py +++ b/src/bot/plugins/callbacks/settings/client_settings_callbacks.py @@ -3,7 +3,7 @@ from .... import custom_filters from .....configs import Configs -from .....qbittorrent_manager import QbittorrentManagement +from .....client_manager import ClientRepo from .....utils import convert_type_from_string from .....db_management import write_support @@ -33,9 +33,10 @@ async def edit_client_settings_callback(client: Client, callback_query: Callback @Client.on_callback_query(custom_filters.check_connection_filter) async def check_connection_callback(client: Client, callback_query: CallbackQuery) -> None: try: - with QbittorrentManagement() as qb: - version = qb.check_connection() - await callback_query.answer(f"✅ The connection works. QBittorrent version: {version}", show_alert=True) + repository = ClientRepo.get_client_manager(Configs.config.clients.type) + version = repository.check_connection() + + await callback_query.answer(f"✅ The connection works. QBittorrent version: {version}", show_alert=True) except Exception: await callback_query.answer("❌ Unable to establish connection with QBittorrent", show_alert=True) diff --git a/src/bot/plugins/callbacks/torrent_info.py b/src/bot/plugins/callbacks/torrent_info.py index efcc6a0..8124d4f 100644 --- a/src/bot/plugins/callbacks/torrent_info.py +++ b/src/bot/plugins/callbacks/torrent_info.py @@ -4,14 +4,15 @@ from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, CallbackQuery from ... import custom_filters -from ....qbittorrent_manager import QbittorrentManagement +from ....client_manager import ClientRepo +from ....configs import Configs from ....utils import convert_size, convert_eta @Client.on_callback_query(custom_filters.torrentInfo_filter & custom_filters.check_user_filter) async def torrent_info_callback(client: Client, callback_query: CallbackQuery) -> None: - with QbittorrentManagement() as qb: - torrent = qb.get_torrent_info(callback_query.data.split("#")[1]) + repository = ClientRepo.get_client_manager(Configs.config.clients.type) + torrent = repository.get_torrent_info(callback_query.data.split("#")[1]) text = f"{torrent.name}\n" diff --git a/src/bot/plugins/common.py b/src/bot/plugins/common.py index ac33ccb..6756eda 100644 --- a/src/bot/plugins/common.py +++ b/src/bot/plugins/common.py @@ -3,7 +3,8 @@ from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup from ... import db_management -from ...qbittorrent_manager import QbittorrentManagement +from ...configs import Configs +from ...client_manager import ClientRepo from ...utils import get_user_from_config from ...configs.enums import UserRolesEnum @@ -45,8 +46,8 @@ async def send_menu(client: Client, message_id: int, chat_id: int) -> None: async def list_active_torrents(client: Client, n, chat, message, callback, status_filter: str = None) -> None: - with QbittorrentManagement() as qb: - torrents = qb.get_torrent_info(status_filter=status_filter) + repository = ClientRepo.get_client_manager(Configs.config.clients.type) + torrents = repository.get_torrent_info(status_filter=status_filter) def render_categories_buttons(): return [ diff --git a/src/bot/plugins/on_message.py b/src/bot/plugins/on_message.py index fc62993..1b54b1d 100644 --- a/src/bot/plugins/on_message.py +++ b/src/bot/plugins/on_message.py @@ -3,7 +3,7 @@ import tempfile from pyrogram import Client, filters from pyrogram.types import Message -from ...qbittorrent_manager import QbittorrentManagement +from ...client_manager import ClientRepo from ... import db_management from .common import send_menu from ...configs import Configs @@ -23,9 +23,11 @@ async def on_text(client: Client, message: Message) -> None: magnet_link = message.text.split("\n") category = db_management.read_support(message.from_user.id).split("#")[1] - with QbittorrentManagement() as qb: - qb.add_magnet(magnet_link=magnet_link, - category=category) + repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository.add_magnet( + magnet_link=magnet_link, + category=category + ) await send_menu(client, message.id, message.from_user.id) db_management.write_support("None", message.from_user.id) @@ -40,9 +42,9 @@ async def on_text(client: Client, message: Message) -> None: category = db_management.read_support(message.from_user.id).split("#")[1] await message.download(name) - with QbittorrentManagement() as qb: - qb.add_torrent(file_name=name, - category=category) + repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository.add_torrent(file_name=name, category=category) + await send_menu(client, message.id, message.from_user.id) db_management.write_support("None", message.from_user.id) @@ -57,16 +59,16 @@ async def on_text(client: Client, message: Message) -> None: if os.path.exists(message.text): name = db_management.read_support(message.from_user.id).split("#")[1] + repository = ClientRepo.get_client_manager(Configs.config.clients.type) + if "modify" in action: - with QbittorrentManagement() as qb: - qb.edit_category(name=name, - save_path=message.text) + repository.edit_category(name=name, save_path=message.text) + await send_menu(client, message.id, message.from_user.id) return - with QbittorrentManagement() as qb: - qb.create_category(name=name, - save_path=message.text) + repository.create_category(name=name, save_path=message.text) + await send_menu(client, message.id, message.from_user.id) else: diff --git a/src/client_manager/__init__.py b/src/client_manager/__init__.py new file mode 100644 index 0000000..ed8a16e --- /dev/null +++ b/src/client_manager/__init__.py @@ -0,0 +1 @@ +from .client_repo import ClientRepo diff --git a/src/client_manager/client_manager.py b/src/client_manager/client_manager.py new file mode 100644 index 0000000..5658224 --- /dev/null +++ b/src/client_manager/client_manager.py @@ -0,0 +1,84 @@ +from typing import Union, List +from abc import ABC + + +class ClientManager(ABC): + @classmethod + def add_magnet(cls, magnet_link: Union[str, List[str]], category: str = None) -> None: + """Add one or multiple magnet links with or without a category""" + raise NotImplementedError + + @classmethod + def add_torrent(cls, file_name: str, category: str = None) -> None: + """Add one torrent file with or without a category""" + raise NotImplementedError + + @classmethod + def resume_all(cls) -> None: + """Resume all torrents""" + raise NotImplementedError + + @classmethod + def pause_all(cls) -> None: + """Pause all torrents""" + raise NotImplementedError + + @classmethod + def resume(cls, torrent_hash: str) -> None: + """Resume a specific torrent""" + raise NotImplementedError + + @classmethod + def pause(cls, torrent_hash: str) -> None: + """Pause a specific torrent""" + raise NotImplementedError + + @classmethod + def delete_one_no_data(cls, torrent_hash: str) -> None: + """Delete a specific torrent without deleting the data""" + raise NotImplementedError + + @classmethod + def delete_one_data(cls, torrent_hash: str) -> None: + """Delete a specific torrent deleting the data""" + raise NotImplementedError + + @classmethod + def delete_all_no_data(cls) -> None: + """Delete all torrents without deleting the data""" + raise NotImplementedError + + @classmethod + def delete_all_data(cls) -> None: + """Delete all torrents deleting the data""" + raise NotImplementedError + + @classmethod + def get_categories(cls): + """Get categories""" + raise NotImplementedError + + @classmethod + def get_torrent_info(cls, torrent_hash: str = None, status_filter: str = None): + """Get a torrent info with or without a status filter""" + raise NotImplementedError + + @classmethod + def edit_category(cls, name: str, save_path: str) -> None: + """Edit a category save path""" + raise NotImplementedError + + @classmethod + def create_category(cls, name: str, save_path: str) -> None: + """Create a new category""" + raise NotImplementedError + + @classmethod + def remove_category(cls, name: str) -> None: + """Delete a category""" + raise NotImplementedError + + @classmethod + def check_connection(cls) -> str: + """Check connection with Client""" + raise NotImplementedError diff --git a/src/client_manager/client_repo.py b/src/client_manager/client_repo.py new file mode 100644 index 0000000..e0d8596 --- /dev/null +++ b/src/client_manager/client_repo.py @@ -0,0 +1,12 @@ +from ..configs.enums import ClientTypeEnum +from .qbittorrent_manager import QbittorrentManager, ClientManager + + +class ClientRepo: + repositories = { + ClientTypeEnum.QBittorrent: QbittorrentManager + } + + @classmethod + def get_client_manager(cls, client_type: ClientTypeEnum): + return cls.repositories.get(client_type, ClientManager) diff --git a/src/client_manager/qbittorrent_manager.py b/src/client_manager/qbittorrent_manager.py new file mode 100644 index 0000000..f3f1c41 --- /dev/null +++ b/src/client_manager/qbittorrent_manager.py @@ -0,0 +1,142 @@ +import qbittorrentapi +import logging +from src.configs import Configs +from typing import Union, List +from .client_manager import ClientManager + +BOT_CONFIGS = Configs.config +logger = logging.getLogger(__name__) + + +class QbittorrentManager(ClientManager): + @classmethod + def add_magnet(cls, magnet_link: Union[str, List[str]], category: str = None) -> None: + if category == "None": + category = None + + with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + logger.debug(f"Adding magnet with category {category}") + qbt_client.torrents_add(urls=magnet_link, category=category) + + @classmethod + def add_torrent(cls, file_name: str, category: str = None) -> None: + if category == "None": + category = None + + try: + with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + logger.debug(f"Adding torrent with category {category}") + qbt_client.torrents_add(torrent_files=file_name, category=category) + + except qbittorrentapi.exceptions.UnsupportedMediaType415Error: + pass + + @classmethod + def resume_all(cls) -> None: + logger.debug("Resuming all torrents") + with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + qbt_client.torrents.resume.all() + + @classmethod + def pause_all(cls) -> None: + logger.debug("Pausing all torrents") + with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + qbt_client.torrents.pause.all() + + @classmethod + def resume(cls, torrent_hash: str) -> None: + logger.debug(f"Resuming torrent with has {torrent_hash}") + with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + qbt_client.torrents_resume(torrent_hashes=torrent_hash) + + @classmethod + def pause(cls, torrent_hash: str) -> None: + logger.debug(f"Pausing torrent with hash {torrent_hash}") + with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + qbt_client.torrents_pause(torrent_hashes=torrent_hash) + + @classmethod + def delete_one_no_data(cls, torrent_hash: str) -> None: + logger.debug(f"Deleting torrent with hash {torrent_hash} without removing files") + with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + qbt_client.torrents_delete( + delete_files=False, + torrent_hashes=torrent_hash + ) + + @classmethod + def delete_one_data(cls, torrent_hash: str) -> None: + logger.debug(f"Deleting torrent with hash {torrent_hash} + removing files") + with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + qbt_client.torrents_delete( + delete_files=True, + torrent_hashes=torrent_hash + ) + + @classmethod + def delete_all_no_data(cls) -> None: + logger.debug(f"Deleting all torrents") + with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + for i in qbt_client.torrents_info(): + qbt_client.torrents_delete(delete_files=False, hashes=i.hash) + + @classmethod + def delete_all_data(cls) -> None: + logger.debug(f"Deleting all torrent + files") + with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + for i in qbt_client.torrents_info(): + qbt_client.torrents_delete(delete_files=True, hashes=i.hash) + + @classmethod + def get_categories(cls): + with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + categories = qbt_client.torrent_categories.categories + if len(categories) > 0: + return categories + + else: + return + + @classmethod + def get_torrent_info(cls, torrent_hash: str = None, status_filter: str = None): + if torrent_hash is None: + logger.debug("Getting torrents infos") + with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + return qbt_client.torrents_info(status_filter=status_filter) + logger.debug(f"Getting infos for torrent with hash {torrent_hash}") + with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + return next( + iter( + qbt_client.torrents_info(status_filter=status_filter, torrent_hashes=torrent_hash) + ), None + ) + + @classmethod + def edit_category(cls, name: str, save_path: str) -> None: + logger.debug(f"Editing category {name}, new save path: {save_path}") + with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + qbt_client.torrents_edit_category( + name=name, + save_path=save_path + ) + + @classmethod + def create_category(cls, name: str, save_path: str) -> None: + logger.debug(f"Creating new category {name} with save path: {save_path}") + with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + qbt_client.torrents_create_category( + name=name, + save_path=save_path + ) + + @classmethod + def remove_category(cls, name: str) -> None: + logger.debug(f"Removing category {name}") + with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + qbt_client.torrents_remove_categories(categories=name) + + @classmethod + def check_connection(cls) -> str: + logger.debug("Checking Qbt Connection") + with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + return qbt_client.app.version diff --git a/src/qbittorrent_manager.py b/src/qbittorrent_manager.py deleted file mode 100644 index 2352161..0000000 --- a/src/qbittorrent_manager.py +++ /dev/null @@ -1,117 +0,0 @@ -import qbittorrentapi -import logging -from .configs import Configs -from typing import Union, List - -BOT_CONFIGS = Configs.config -logger = logging.getLogger(__name__) - - -class QbittorrentManagement: - def __init__(self): - self.qbt_client = qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) - - def __enter__(self): - try: - self.qbt_client.auth_log_in() - except qbittorrentapi.LoginFailed as e: - logger.exception("Qbittorrent Login Failed", exc_info=True) - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.qbt_client.auth_log_out() - - def add_magnet(self, magnet_link: Union[str, List[str]], category: str = None) -> None: - if category == "None": - category = None - - if category is not None: - logger.debug(f"Adding magnet with category {category}") - self.qbt_client.torrents_add(urls=magnet_link, category=category) - else: - logger.debug("Adding magnet without category") - self.qbt_client.torrents_add(urls=magnet_link) - - def add_torrent(self, file_name: str, category: str = None) -> None: - if category == "None": - category = None - - try: - if category is not None: - logger.debug(f"Adding torrent with category {category}") - self.qbt_client.torrents_add(torrent_files=file_name, category=category) - else: - logger.debug("Adding torrent without category") - self.qbt_client.torrents_add(torrent_files=file_name) - - except qbittorrentapi.exceptions.UnsupportedMediaType415Error: - pass - - def resume_all(self) -> None: - logger.debug("Resuming all torrents") - self.qbt_client.torrents.resume.all() - - def pause_all(self) -> None: - logger.debug("Pausing all torrents") - self.qbt_client.torrents.pause.all() - - def resume(self, torrent_hash: str) -> None: - logger.debug(f"Resuming torrent with has {torrent_hash}") - self.qbt_client.torrents_resume(torrent_hashes=torrent_hash) - - def pause(self, torrent_hash: str) -> None: - logger.debug(f"Pausing torrent with hash {torrent_hash}") - self.qbt_client.torrents_pause(torrent_hashes=torrent_hash) - - def delete_one_no_data(self, torrent_hash: str) -> None: - logger.debug(f"Deleting torrent with hash {torrent_hash} without removing files") - self.qbt_client.torrents_delete(delete_files=False, - torrent_hashes=torrent_hash) - - def delete_one_data(self, torrent_hash: str) -> None: - logger.debug(f"Deleting torrent with hash {torrent_hash} + removing files") - self.qbt_client.torrents_delete(delete_files=True, - torrent_hashes=torrent_hash) - - def delete_all_no_data(self) -> None: - logger.debug(f"Deleting all torrents") - for i in self.qbt_client.torrents_info(): - self.qbt_client.torrents_delete(delete_files=False, hashes=i.hash) - - def delete_all_data(self) -> None: - logger.debug(f"Deleting all torrent + files") - for i in self.qbt_client.torrents_info(): - self.qbt_client.torrents_delete(delete_files=True, hashes=i.hash) - - def get_categories(self): - categories = self.qbt_client.torrent_categories.categories - if len(categories) > 0: - return categories - - else: - return - - def get_torrent_info(self, torrent_hash: str = None, status_filter: str = None): - if torrent_hash is None: - logger.debug("Getting torrents infos") - return self.qbt_client.torrents_info(status_filter=status_filter) - logger.debug(f"Getting infos for torrent with hash {torrent_hash}") - return next(iter(self.qbt_client.torrents_info(status_filter=status_filter, torrent_hashes=torrent_hash)), None) - - def edit_category(self, name: str, save_path: str) -> None: - logger.debug(f"Editing category {name}, new save path: {save_path}") - self.qbt_client.torrents_edit_category(name=name, - save_path=save_path) - - def create_category(self, name: str, save_path: str) -> None: - logger.debug(f"Creating new category {name} with save path: {save_path}") - self.qbt_client.torrents_create_category(name=name, - save_path=save_path) - - def remove_category(self, name: str) -> None: - logger.debug(f"Removing category {name}") - self.qbt_client.torrents_remove_categories(categories=name) - - def check_connection(self) -> str: - logger.debug("Checking Qbt Connection") - return self.qbt_client.app.version diff --git a/src/utils.py b/src/utils.py index 96f28d3..b01fcee 100644 --- a/src/utils.py +++ b/src/utils.py @@ -5,14 +5,14 @@ from pyrogram.errors.exceptions import UserIsBlocked from src import db_management -from src.qbittorrent_manager import QbittorrentManagement +from src.client_manager.qbittorrent_manager import QbittorrentManager from .configs import Configs from .configs.enums import ClientTypeEnum, UserRolesEnum from .configs.user import User async def torrent_finished(app): - with QbittorrentManagement() as qb: + with QbittorrentManager() as qb: for i in qb.get_torrent_info(status_filter="completed"): if db_management.read_completed_torrents(i.hash) is None: From 776f66ece6427448b96fab7c43b4e773f35bb7f2 Mon Sep 17 00:00:00 2001 From: ch3p4ll3 Date: Sun, 17 Dec 2023 15:00:55 +0100 Subject: [PATCH 15/25] rearrange menu --- src/bot/custom_filters.py | 5 ++- src/bot/plugins/callbacks/__init__.py | 10 +++--- .../plugins/callbacks/category/__init__.py | 31 +++++++++++++++++++ .../{ => category}/category_callbacks.py | 10 +++--- src/bot/plugins/callbacks/delete/__init__.py | 24 ++++++++++++++ .../{ => delete}/delete_all_callbacks.py | 8 ++--- .../{ => delete}/delete_single_callbacks.py | 8 ++--- .../callbacks/pause_resume/__init__.py | 25 +++++++++++++++ .../{ => pause_resume}/pause_callbacks.py | 8 ++--- .../{ => pause_resume}/resume_callbacks.py | 8 ++--- src/bot/plugins/common.py | 12 ++----- 11 files changed, 113 insertions(+), 36 deletions(-) create mode 100644 src/bot/plugins/callbacks/category/__init__.py rename src/bot/plugins/callbacks/{ => category}/category_callbacks.py (95%) create mode 100644 src/bot/plugins/callbacks/delete/__init__.py rename src/bot/plugins/callbacks/{ => delete}/delete_all_callbacks.py (92%) rename src/bot/plugins/callbacks/{ => delete}/delete_single_callbacks.py (93%) create mode 100644 src/bot/plugins/callbacks/pause_resume/__init__.py rename src/bot/plugins/callbacks/{ => pause_resume}/pause_callbacks.py (87%) rename src/bot/plugins/callbacks/{ => pause_resume}/resume_callbacks.py (87%) diff --git a/src/bot/custom_filters.py b/src/bot/custom_filters.py index 91f3cd0..4c9d131 100644 --- a/src/bot/custom_filters.py +++ b/src/bot/custom_filters.py @@ -12,6 +12,7 @@ user_is_administrator = filters.create(lambda _, __, query: get_user_from_config(query.from_user.id).role == UserRolesEnum.Administrator) # Categories filters +menu_category_filter = filters.create(lambda _, __, query: query.data == "menu_categories") add_category_filter = filters.create(lambda _, __, query: query.data == "add_category") remove_category_filter = filters.create(lambda _, __, query: query.data.startswith("remove_category")) modify_category_filter = filters.create(lambda _, __, query: query.data.startswith("modify_category")) @@ -23,12 +24,14 @@ add_torrent_filter = filters.create(lambda _, __, query: query.data.startswith("add_torrent")) # Pause/Resume filters +menu_pause_resume_filter = filters.create(lambda _, __, query: query.data == "menu_pause_resume") pause_all_filter = filters.create(lambda _, __, query: query.data.startswith("pause_all")) resume_all_filter = filters.create(lambda _, __, query: query.data.startswith("resume_all")) pause_filter = filters.create(lambda _, __, query: query.data.startswith("pause")) resume_filter = filters.create(lambda _, __, query: query.data.startswith("resume")) # Delete filers +menu_delete_filter = filters.create(lambda _, __, query: query.data == "menu_delete") delete_one_filter = filters.create(lambda _, __, query: query.data.split("#")[0] == "delete_one") delete_one_no_data_filter = filters.create(lambda _, __, query: query.data.startswith("delete_one_no_data")) delete_one_data_filter = filters.create(lambda _, __, query: query.data.startswith("delete_one_data")) @@ -51,6 +54,6 @@ # Other torrentInfo_filter = filters.create(lambda _, __, query: query.data.startswith("torrentInfo")) -menu_filter = filters.create(lambda _, __, query: query.data.startswith("menu")) +menu_filter = filters.create(lambda _, __, query: query.data == "menu") list_filter = filters.create(lambda _, __, query: query.data.startswith("list")) list_by_status_filter = filters.create(lambda _, __, query: query.data.split("#")[0] == "by_status_list") diff --git a/src/bot/plugins/callbacks/__init__.py b/src/bot/plugins/callbacks/__init__.py index 7ccdef2..eb03b23 100644 --- a/src/bot/plugins/callbacks/__init__.py +++ b/src/bot/plugins/callbacks/__init__.py @@ -1,8 +1,8 @@ from .add_torrents_callbacks import * -from .category_callbacks import * -from .delete_all_callbacks import * -from .delete_single_callbacks import * +from src.bot.plugins.callbacks.category.category_callbacks import * +from src.bot.plugins.callbacks.delete.delete_all_callbacks import * +from src.bot.plugins.callbacks.delete.delete_single_callbacks import * from .list_callbacks import * -from .pause_callbacks import * -from .resume_callbacks import * +from .pause_resume.pause_callbacks import * +from .pause_resume.resume_callbacks import * from .torrent_info import * diff --git a/src/bot/plugins/callbacks/category/__init__.py b/src/bot/plugins/callbacks/category/__init__.py new file mode 100644 index 0000000..534d78a --- /dev/null +++ b/src/bot/plugins/callbacks/category/__init__.py @@ -0,0 +1,31 @@ +from pyrogram import Client +from pyrogram.types import CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton + +from .... import custom_filters + + +@Client.on_callback_query(custom_filters.menu_category_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) +async def menu_category_callback(client: Client, callback_query: CallbackQuery) -> None: + await callback_query.edit_message_text( + "Pause/Resume a download", + reply_markup=InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton("➕ Add Category", "add_category"), + ], + [ + InlineKeyboardButton("🗑 Remove Category", "select_category#remove_category") + ], + [ + InlineKeyboardButton("📝 Modify Category", "select_category#modify_category")], + [ + InlineKeyboardButton("🔙 Menu", "menu") + ] + ] + ) + ) + + + + + diff --git a/src/bot/plugins/callbacks/category_callbacks.py b/src/bot/plugins/callbacks/category/category_callbacks.py similarity index 95% rename from src/bot/plugins/callbacks/category_callbacks.py rename to src/bot/plugins/callbacks/category/category_callbacks.py index 45492f8..99393eb 100644 --- a/src/bot/plugins/callbacks/category_callbacks.py +++ b/src/bot/plugins/callbacks/category/category_callbacks.py @@ -2,11 +2,11 @@ from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, CallbackQuery from pyrogram.errors.exceptions import MessageIdInvalid -from . import add_magnet_callback, add_torrent_callback -from .... import db_management -from ... import custom_filters -from ....client_manager import ClientRepo -from ....configs import Configs +from ...callbacks import add_magnet_callback, add_torrent_callback +from ..... import db_management +from .... import custom_filters +from .....client_manager import ClientRepo +from .....configs import Configs @Client.on_callback_query(custom_filters.add_category_filter & custom_filters.check_user_filter & (custom_filters.user_is_administrator | custom_filters.user_is_manager)) diff --git a/src/bot/plugins/callbacks/delete/__init__.py b/src/bot/plugins/callbacks/delete/__init__.py new file mode 100644 index 0000000..9fb3661 --- /dev/null +++ b/src/bot/plugins/callbacks/delete/__init__.py @@ -0,0 +1,24 @@ +from pyrogram import Client +from pyrogram.types import CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton + +from .... import custom_filters + + +@Client.on_callback_query(custom_filters.menu_delete_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) +async def menu_delete_callback(client: Client, callback_query: CallbackQuery) -> None: + await callback_query.edit_message_text( + "Delete a torrent", + reply_markup=InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton("🗑 Delete", "delete_one") + ], + [ + InlineKeyboardButton("🗑 Delete All", "delete_all") + ], + [ + InlineKeyboardButton("🔙 Menu", "menu") + ] + ] + ) + ) diff --git a/src/bot/plugins/callbacks/delete_all_callbacks.py b/src/bot/plugins/callbacks/delete/delete_all_callbacks.py similarity index 92% rename from src/bot/plugins/callbacks/delete_all_callbacks.py rename to src/bot/plugins/callbacks/delete/delete_all_callbacks.py index 50e5985..e045711 100644 --- a/src/bot/plugins/callbacks/delete_all_callbacks.py +++ b/src/bot/plugins/callbacks/delete/delete_all_callbacks.py @@ -1,10 +1,10 @@ from pyrogram import Client from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, CallbackQuery -from ... import custom_filters -from ....client_manager import ClientRepo -from ..common import send_menu -from ....configs import Configs +from .... import custom_filters +from .....client_manager import ClientRepo +from ...common import send_menu +from .....configs import Configs @Client.on_callback_query(custom_filters.delete_all_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) diff --git a/src/bot/plugins/callbacks/delete_single_callbacks.py b/src/bot/plugins/callbacks/delete/delete_single_callbacks.py similarity index 93% rename from src/bot/plugins/callbacks/delete_single_callbacks.py rename to src/bot/plugins/callbacks/delete/delete_single_callbacks.py index 81c1f10..e73e47f 100644 --- a/src/bot/plugins/callbacks/delete_single_callbacks.py +++ b/src/bot/plugins/callbacks/delete/delete_single_callbacks.py @@ -1,10 +1,10 @@ from pyrogram import Client from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, CallbackQuery -from ... import custom_filters -from ....client_manager import ClientRepo -from ..common import list_active_torrents, send_menu -from ....configs import Configs +from .... import custom_filters +from .....client_manager import ClientRepo +from ...common import send_menu, list_active_torrents +from .....configs import Configs @Client.on_callback_query(custom_filters.delete_one_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) diff --git a/src/bot/plugins/callbacks/pause_resume/__init__.py b/src/bot/plugins/callbacks/pause_resume/__init__.py new file mode 100644 index 0000000..0ca1890 --- /dev/null +++ b/src/bot/plugins/callbacks/pause_resume/__init__.py @@ -0,0 +1,25 @@ +from pyrogram import Client +from pyrogram.types import CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton +from .... import custom_filters + + +@Client.on_callback_query(custom_filters.menu_pause_resume_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) +async def menu_pause_resume_callback(client: Client, callback_query: CallbackQuery) -> None: + await callback_query.edit_message_text( + "Pause/Resume a torrent", + reply_markup=InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton("⏸ Pause", "pause"), + InlineKeyboardButton("▶️ Resume", "resume") + ], + [ + InlineKeyboardButton("⏸ Pause All", "pause_all"), + InlineKeyboardButton("▶️ Resume All", "resume_all") + ], + [ + InlineKeyboardButton("🔙 Menu", "menu") + ] + ] + ) + ) diff --git a/src/bot/plugins/callbacks/pause_callbacks.py b/src/bot/plugins/callbacks/pause_resume/pause_callbacks.py similarity index 87% rename from src/bot/plugins/callbacks/pause_callbacks.py rename to src/bot/plugins/callbacks/pause_resume/pause_callbacks.py index e880402..50cfad5 100644 --- a/src/bot/plugins/callbacks/pause_callbacks.py +++ b/src/bot/plugins/callbacks/pause_resume/pause_callbacks.py @@ -1,10 +1,10 @@ from pyrogram import Client from pyrogram.types import CallbackQuery -from ... import custom_filters -from ....client_manager import ClientRepo -from ..common import list_active_torrents, send_menu -from ....configs import Configs +from .... import custom_filters +from .....client_manager import ClientRepo +from ...common import list_active_torrents, send_menu +from .....configs import Configs @Client.on_callback_query(custom_filters.pause_all_filter & custom_filters.check_user_filter & (custom_filters.user_is_administrator | custom_filters.user_is_manager)) diff --git a/src/bot/plugins/callbacks/resume_callbacks.py b/src/bot/plugins/callbacks/pause_resume/resume_callbacks.py similarity index 87% rename from src/bot/plugins/callbacks/resume_callbacks.py rename to src/bot/plugins/callbacks/pause_resume/resume_callbacks.py index 99e469e..2290428 100644 --- a/src/bot/plugins/callbacks/resume_callbacks.py +++ b/src/bot/plugins/callbacks/pause_resume/resume_callbacks.py @@ -1,10 +1,10 @@ from pyrogram import Client from pyrogram.types import CallbackQuery -from ... import custom_filters -from ....client_manager import ClientRepo -from ..common import list_active_torrents, send_menu -from ....configs import Configs +from .... import custom_filters +from .....client_manager import ClientRepo +from ...common import list_active_torrents, send_menu +from .....configs import Configs @Client.on_callback_query(custom_filters.resume_all_filter & custom_filters.check_user_filter & (custom_filters.user_is_administrator | custom_filters.user_is_manager)) diff --git a/src/bot/plugins/common.py b/src/bot/plugins/common.py index 6756eda..5e39f2f 100644 --- a/src/bot/plugins/common.py +++ b/src/bot/plugins/common.py @@ -19,19 +19,13 @@ async def send_menu(client: Client, message_id: int, chat_id: int) -> None: buttons += [ [InlineKeyboardButton("➕ Add Magnet", "category#add_magnet"), InlineKeyboardButton("➕ Add Torrent", "category#add_torrent")], - [InlineKeyboardButton("⏸ Pause", "pause"), - InlineKeyboardButton("▶️ Resume", "resume")], - [InlineKeyboardButton("⏸ Pause All", "pause_all"), - InlineKeyboardButton("▶️ Resume All", "resume_all")], + [InlineKeyboardButton("⏯ Pause/Resume", "menu_pause_resume")] ] if user.role == UserRolesEnum.Administrator: buttons += [ - [InlineKeyboardButton("🗑 Delete", "delete_one"), - InlineKeyboardButton("🗑 Delete All", "delete_all")], - [InlineKeyboardButton("➕ Add Category", "add_category"), - InlineKeyboardButton("🗑 Remove Category", "select_category#remove_category")], - [InlineKeyboardButton("📝 Modify Category", "select_category#modify_category")], + [InlineKeyboardButton("🗑 Delete", "menu_delete")], + [InlineKeyboardButton("📂 Categories", "menu_categories")], [InlineKeyboardButton("⚙️ Settings", "settings")] ] From 984553ccb5af8a5b3b0b30e235077d0204040393 Mon Sep 17 00:00:00 2001 From: ch3p4ll3 Date: Sun, 17 Dec 2023 15:13:29 +0100 Subject: [PATCH 16/25] refactor list_active_torrents --- .../delete/delete_single_callbacks.py | 6 +++--- src/bot/plugins/callbacks/list_callbacks.py | 6 ++---- .../callbacks/pause_resume/pause_callbacks.py | 3 ++- .../callbacks/pause_resume/resume_callbacks.py | 3 ++- src/bot/plugins/common.py | 18 ++++++++++-------- 5 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/bot/plugins/callbacks/delete/delete_single_callbacks.py b/src/bot/plugins/callbacks/delete/delete_single_callbacks.py index e73e47f..27e34e8 100644 --- a/src/bot/plugins/callbacks/delete/delete_single_callbacks.py +++ b/src/bot/plugins/callbacks/delete/delete_single_callbacks.py @@ -10,7 +10,7 @@ @Client.on_callback_query(custom_filters.delete_one_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def delete_callback(client: Client, callback_query: CallbackQuery) -> None: if callback_query.data.find("#") == -1: - await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "delete_one") + await list_active_torrents(client, callback_query.from_user.id, callback_query.message.id, "delete_one") else: @@ -26,7 +26,7 @@ async def delete_callback(client: Client, callback_query: CallbackQuery) -> None @Client.on_callback_query(custom_filters.delete_one_no_data_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def delete_no_data_callback(client: Client, callback_query: CallbackQuery) -> None: if callback_query.data.find("#") == -1: - await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "delete_one_no_data") + await list_active_torrents(client, callback_query.from_user.id, callback_query.message.id, "delete_one_no_data") else: repository = ClientRepo.get_client_manager(Configs.config.clients.type) @@ -38,7 +38,7 @@ async def delete_no_data_callback(client: Client, callback_query: CallbackQuery) @Client.on_callback_query(custom_filters.delete_one_data_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def delete_with_data_callback(client: Client, callback_query: CallbackQuery) -> None: if callback_query.data.find("#") == -1: - await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "delete_one_data") + await list_active_torrents(client, callback_query.from_user.id, callback_query.message.id, "delete_one_data") else: repository = ClientRepo.get_client_manager(Configs.config.clients.type) diff --git a/src/bot/plugins/callbacks/list_callbacks.py b/src/bot/plugins/callbacks/list_callbacks.py index 7a7a815..ecdce05 100644 --- a/src/bot/plugins/callbacks/list_callbacks.py +++ b/src/bot/plugins/callbacks/list_callbacks.py @@ -7,15 +7,13 @@ @Client.on_callback_query(custom_filters.list_filter & custom_filters.check_user_filter) async def list_callback(client: Client, callback_query: CallbackQuery) -> None: - await list_active_torrents(client, 0, callback_query.from_user.id, callback_query.message.id, - db_management.read_support(callback_query.from_user.id)) + await list_active_torrents(client, callback_query.from_user.id, callback_query.message.id) @Client.on_callback_query(custom_filters.list_by_status_filter & custom_filters.check_user_filter) async def list_by_status_callback(client: Client, callback_query: CallbackQuery) -> None: status_filter = callback_query.data.split("#")[1] - await list_active_torrents(client,0, callback_query.from_user.id, callback_query.message.id, - db_management.read_support(callback_query.from_user.id), status_filter=status_filter) + await list_active_torrents(client, callback_query.from_user.id, callback_query.message.id, status_filter=status_filter) @Client.on_callback_query(custom_filters.menu_filter & custom_filters.check_user_filter) diff --git a/src/bot/plugins/callbacks/pause_resume/pause_callbacks.py b/src/bot/plugins/callbacks/pause_resume/pause_callbacks.py index 50cfad5..526a840 100644 --- a/src/bot/plugins/callbacks/pause_resume/pause_callbacks.py +++ b/src/bot/plugins/callbacks/pause_resume/pause_callbacks.py @@ -17,9 +17,10 @@ async def pause_all_callback(client: Client, callback_query: CallbackQuery) -> N @Client.on_callback_query(custom_filters.pause_filter & custom_filters.check_user_filter & (custom_filters.user_is_administrator | custom_filters.user_is_manager)) async def pause_callback(client: Client, callback_query: CallbackQuery) -> None: if callback_query.data.find("#") == -1: - await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "pause") + await list_active_torrents(client, callback_query.from_user.id, callback_query.message.id, "pause") else: repository = ClientRepo.get_client_manager(Configs.config.clients.type) repository.pause(torrent_hash=callback_query.data.split("#")[1]) + await callback_query.answer("Torrent Paused") await send_menu(client, callback_query.message.id, callback_query.from_user.id) diff --git a/src/bot/plugins/callbacks/pause_resume/resume_callbacks.py b/src/bot/plugins/callbacks/pause_resume/resume_callbacks.py index 2290428..65e1820 100644 --- a/src/bot/plugins/callbacks/pause_resume/resume_callbacks.py +++ b/src/bot/plugins/callbacks/pause_resume/resume_callbacks.py @@ -17,9 +17,10 @@ async def resume_all_callback(client: Client, callback_query: CallbackQuery) -> @Client.on_callback_query(custom_filters.resume_filter & custom_filters.check_user_filter & (custom_filters.user_is_administrator | custom_filters.user_is_manager)) async def resume_callback(client: Client, callback_query: CallbackQuery) -> None: if callback_query.data.find("#") == -1: - await list_active_torrents(client, 1, callback_query.from_user.id, callback_query.message.id, "resume") + await list_active_torrents(client, callback_query.from_user.id, callback_query.message.id, "resume") else: repository = ClientRepo.get_client_manager(Configs.config.clients.type) repository.resume(torrent_hash=callback_query.data.split("#")[1]) + await callback_query.answer("Torrent Resumed") await send_menu(client, callback_query.message.id, callback_query.from_user.id) diff --git a/src/bot/plugins/common.py b/src/bot/plugins/common.py index 5e39f2f..18acf3e 100644 --- a/src/bot/plugins/common.py +++ b/src/bot/plugins/common.py @@ -2,6 +2,8 @@ from pyrogram.errors.exceptions import MessageIdInvalid from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from typing import Optional + from ... import db_management from ...configs import Configs from ...client_manager import ClientRepo @@ -39,7 +41,7 @@ async def send_menu(client: Client, message_id: int, chat_id: int) -> None: await client.send_message(chat_id, text="Qbittorrent Control", reply_markup=InlineKeyboardMarkup(buttons)) -async def list_active_torrents(client: Client, n, chat, message, callback, status_filter: str = None) -> None: +async def list_active_torrents(client: Client, chat_id, message_id, callback: Optional[str] = None, status_filter: str = None) -> None: repository = ClientRepo.get_client_manager(Configs.config.clients.type) torrents = repository.get_torrent_info(status_filter=status_filter) @@ -56,24 +58,24 @@ def render_categories_buttons(): if not torrents: buttons = [categories_buttons, [InlineKeyboardButton("🔙 Menu", "menu")]] try: - await client.edit_message_text(chat, message, "There are no torrents", + await client.edit_message_text(chat_id, message_id, "There are no torrents", reply_markup=InlineKeyboardMarkup(buttons)) except MessageIdInvalid: - await client.send_message(chat, "There are no torrents", reply_markup=InlineKeyboardMarkup(buttons)) + await client.send_message(chat_id, "There are no torrents", reply_markup=InlineKeyboardMarkup(buttons)) return buttons = [categories_buttons] - if n == 1: + if callback is not None: for key, i in enumerate(torrents): buttons.append([InlineKeyboardButton(i.name, f"{callback}#{i.info.hash}")]) buttons.append([InlineKeyboardButton("🔙 Menu", "menu")]) try: - await client.edit_message_reply_markup(chat, message, reply_markup=InlineKeyboardMarkup(buttons)) + await client.edit_message_reply_markup(chat_id, message_id, reply_markup=InlineKeyboardMarkup(buttons)) except MessageIdInvalid: - await client.send_message(chat, "Qbittorrent Control", reply_markup=InlineKeyboardMarkup(buttons)) + await client.send_message(chat_id, "Qbittorrent Control", reply_markup=InlineKeyboardMarkup(buttons)) else: for key, i in enumerate(torrents): @@ -82,6 +84,6 @@ def render_categories_buttons(): buttons.append([InlineKeyboardButton("🔙 Menu", "menu")]) try: - await client.edit_message_reply_markup(chat, message, reply_markup=InlineKeyboardMarkup(buttons)) + await client.edit_message_reply_markup(chat_id, message_id, reply_markup=InlineKeyboardMarkup(buttons)) except MessageIdInvalid: - await client.send_message(chat, "Qbittorrent Control", reply_markup=InlineKeyboardMarkup(buttons)) + await client.send_message(chat_id, "Qbittorrent Control", reply_markup=InlineKeyboardMarkup(buttons)) From 5bdbcb8a375069762d4b1cc086242ea34d28482c Mon Sep 17 00:00:00 2001 From: ch3p4ll3 Date: Sun, 17 Dec 2023 15:22:16 +0100 Subject: [PATCH 17/25] create logs folder if does not exists --- .gitignore | 3 ++- main.py | 10 ++++++++-- src/configs/__init__.py | 1 + 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 78fc413..f9de277 100644 --- a/.gitignore +++ b/.gitignore @@ -165,4 +165,5 @@ config.json *.session *.session-journal -*.sqlite \ No newline at end of file +*.sqlite +/docker_folder diff --git a/main.py b/main.py index b879425..b377446 100644 --- a/main.py +++ b/main.py @@ -1,11 +1,17 @@ from src.bot import app, scheduler import logging from logging import handlers -from os import getenv +from src.configs import Configs +from os.path import exists +from os import mkdir + + +if not exists(Configs.log_folder): + mkdir(Configs.log_folder) # Create a file handler handler = logging.handlers.TimedRotatingFileHandler( - f'{"/app/config/" if getenv("IS_DOCKER", False) else "./"}logs/QbittorrentBot.log', + f'{Configs.log_folder}/QbittorrentBot.log', when='midnight', backupCount=10 ) diff --git a/src/configs/__init__.py b/src/configs/__init__.py index 94eb31b..a24d648 100644 --- a/src/configs/__init__.py +++ b/src/configs/__init__.py @@ -16,6 +16,7 @@ def load_config() -> Union[MainConfig, None, int]: class Configs: config_path = config_path + log_folder = "/app/config/logs" if getenv("IS_DOCKER", False) else "./logs" config = load_config() @classmethod From d25c380c2bf049a50f9ffb35535a15d955bba1d8 Mon Sep 17 00:00:00 2001 From: ch3p4ll3 Date: Sun, 17 Dec 2023 17:21:37 +0100 Subject: [PATCH 18/25] start to create docs --- README.md | 2 +- config.json.template | 2 +- docs/advanced/add_new_client_manager.md | 0 docs/advanced/index.md | 0 docs/advanced/index.yml | 1 + docs/advanced/manager_user_roles.md | 0 docs/getting_started/configuration_file.md | 92 ++++++++++++++++++++++ docs/getting_started/index.md | 29 +++++++ docs/getting_started/migrating_to_v2.md | 20 +++++ docs/index.md | 32 ++++++++ docs/retype.yml | 11 +++ src/configs/client.py | 1 - src/configs/config.py | 2 +- 13 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 docs/advanced/add_new_client_manager.md create mode 100644 docs/advanced/index.md create mode 100644 docs/advanced/index.yml create mode 100644 docs/advanced/manager_user_roles.md create mode 100644 docs/getting_started/configuration_file.md create mode 100644 docs/getting_started/index.md create mode 100644 docs/getting_started/migrating_to_v2.md create mode 100644 docs/index.md create mode 100644 docs/retype.yml diff --git a/README.md b/README.md index 63995eb..b07f882 100755 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ The config file is stored in the mounted /app/config/ volume ``` { - "clients": { + "client": { "type": "qbittorrent", "host": "192.168.178.102", "port": 8080, diff --git a/config.json.template b/config.json.template index 563d5d9..f2afd73 100644 --- a/config.json.template +++ b/config.json.template @@ -1,5 +1,5 @@ { - "clients": { + "client": { "type": "qbittorrent", "host": "192.168.178.102", "port": 8080, diff --git a/docs/advanced/add_new_client_manager.md b/docs/advanced/add_new_client_manager.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/advanced/index.md b/docs/advanced/index.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/advanced/index.yml b/docs/advanced/index.yml new file mode 100644 index 0000000..5ff38d3 --- /dev/null +++ b/docs/advanced/index.yml @@ -0,0 +1 @@ +order: -1000 \ No newline at end of file diff --git a/docs/advanced/manager_user_roles.md b/docs/advanced/manager_user_roles.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/getting_started/configuration_file.md b/docs/getting_started/configuration_file.md new file mode 100644 index 0000000..194202d --- /dev/null +++ b/docs/getting_started/configuration_file.md @@ -0,0 +1,92 @@ +# Configuration File + +The configuration file serves as a central repository for all the necessary information that the QBittorrentBot needs to operate effectively. It defines the connection parameters, credentials, and user settings that the bot utilizes to interact with the qBittorrent server and Telegram API. + +Below you can find an example of the configuration file: + +```json5 +{ + "client": { + "type": "qbittorrent", + "host": "192.168.178.102", + "port": 8080, + "user": "admin", + "password": "admin" + }, + "telegram": { + "bot_token": "1111111:AAAAAAAA-BBBBBBBBB", + "api_id": 1111, + "api_hash": "aaaaaaaa" + }, + + "users": [ + { + "user_id": 123456, + "notify": false, + "role": "administrator" + } + ] +} +``` + +Here's a brief overview of the configuration file and its key sections: + +- **Clients Section**: Establishes the connection details for the qBittorrent server, including the hostname, port number, username, and password. This enables the bot to interact with the qBittorrent server and manage torrents. + +- **Telegram Section**: Contains the bot token, API ID, and API hash, which are essential for authenticating the bot with the Telegram API. These credentials allow the bot to communicate with the Telegram server and receive user commands. Click [here](https://docs.pyrogram.org/intro/quickstart) to find out how to retrive your API ID and API Hash + +- **Users Section**: Lists the authorized users of the QBittorrentBot, along with their Telegram user IDs, notification preferences, and user roles. This section defines the users who can interact with the bot, receive notifications, and manage torrents. + +## Client + +This section defines the configuration for the qBittorrent client that the bot will be interacting with. + +Name | Type | Value +--- | --- | --- +type | [ClientTypeEnum](#clienttypeenum) | The type of client. +host | [IPvAnyAddress](#ipvanyaddress) | The IP address of the qBittorrent server. +port | int | The port number of the qBittorrent server. +user | str | The username for the qBittorrent server. +password | str | The password for the qBittorrent server. + +## Telegram + +This section defines the configuration for the Telegram bot that the QBittorrentBot will be communicating with. + +Name | Type | Value +--- | --- | --- +bot_token | str | The bot token for the QBittorrentBot. This is a unique identifier that is used to authenticate the bot with the Telegram API. +api_id | int | The API ID for the QBittorrentBot. This is a unique identifier that is used to identify the bot to the Telegram API. +api_hash |str | The API hash for the QBittorrentBot. This is a string of characters that is used to verify the authenticity of the bot's requests to the Telegram API. + + +## Users + +This section defines the list of users who are authorized to use the QBittorrentBot. Each user is defined by their Telegram user ID, whether or not they should be notified about completed torrents, and their role. + +Name | Type | Value +--- | --- |--- +user_id | int |The Telegram user ID of the user. This is a unique identifier that is used to identify the user to the Telegram API. +notify | bool |Whether or not the user should be notified about new torrents. +role | [UserRolesEnum](#userrolesenum) |The role of the user. + + +## Enums + +### ClientTypeEnum + +Name | Type | Value(to be used in json) +--- | --- |--- +QBittorrent | str | qbittorrent + +### UserRolesEnum +Name | Type | Value(to be used in json) | Remarks +--- | --- |--- | +Reader | str | reader | Can perform only reading operations(view torrents) +Manager | str | manager | Can perform only managing operations(view torrents + can download files + can add/edit categories + set torrent priority + can stop/start downloads) +Administrator| str | administrator | Can perform all operations (Manager + remove torrent + remove category + edit configs) + +## Other types + +### IPvAnyAddress +This type allows either an IPv4Address or an IPv6Address \ No newline at end of file diff --git a/docs/getting_started/index.md b/docs/getting_started/index.md new file mode 100644 index 0000000..e68510c --- /dev/null +++ b/docs/getting_started/index.md @@ -0,0 +1,29 @@ +# Getting Started + +In order to start using the bot, you must first create a folder where the bot will fish for settings and where it will save logs + +For example: let's create a folder called `QBittorrentBot` in the home of the user `user`. The path to the folder will then be `/home/user/docker/QBittorrentBot`. + +Before starting the bot you need to place the configuration file in this folder. You can rename the `config.json.template` file to `config.json` and change the parameters as desired. Go [here](configuration_file.md) to read more about the configuration file. + +Once that is done you can start the bot using docker, you can use either docker or docker compose. + ++++ Docker +Open your terminal and execute the following command to start the bot container: + +`docker run -d -v /home/user/docker/QBittorrentBot:/app/config:rw --name qbittorrent-bot ch3p4ll3/qbittorrent-bot:latest` ++++ Docker compose +Create a file named `docker-compose.yml` inside a directory with the following content: +``` +version: '3.9' +services: + qbittorrent-bot: + image: 'ch3p4ll3/qbittorrent-bot:latest' + container_name: qbittorrent-bot + restart: unless-stopped + volumes: + - '/home/user/docker/QBittorrentBot:/app/config:rw' +``` + +Run the following command to start the bot using Docker Compose: +`docker compose up -d` \ No newline at end of file diff --git a/docs/getting_started/migrating_to_v2.md b/docs/getting_started/migrating_to_v2.md new file mode 100644 index 0000000..874387a --- /dev/null +++ b/docs/getting_started/migrating_to_v2.md @@ -0,0 +1,20 @@ +# Migrating to V2 + +Much has changed with the new version, especially the management of the configuration file. Some fields have been added, while others have changed names, so first check that you have all the fields in the json with the correct name. + +## New fields + +Two new fields were introduced: `type` and `role` in the `client` and `users` sections, respectively. + +The `type` field determines the type of torrent client you want to use(currently only qbittorrent is supported, so its value must be `qbittorrent`) + +The `role` field, on the other hand, determines the role of a particular user. Currently there are 3 roles: +- Reader +- Manager +- Administrator + +You can find more information [here](configuration_file/#enums) + +## Changed names + +There are 2 changes to the field names, the first is the name of the `qbittorrent` section which has been renamed to `client`. While the second is the `ip` field inside che `client` section which has been renamed to `host` \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..6695c48 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,32 @@ +# QBittorrentBot + +QBittorrentBot is a Telegram bot that allows you to control your qBittorrent downloads from your Telegram account. + +This means that you can add, remove, pause, resume, and delete torrents, as well as view a list of your active downloads, all without having to open the qBittorrent application. + +!!!warning Warning +A lot has changed from version 2, if you have any errors, please take a look [here](getting_started/migrating_to_v2). +!!! + +## What can this bot do? +Here are some of the things you can do with QBittorrentBot: + +- **Add torrents**: You can add torrents to your qBittorrent download queue by sending magnet links or torrent files to the bot. +- **List active downloads**: You can get a list of your current downloads, including their progress, status, and estimated completion time. +- **Pause/Resume downloads**: You can pause or resume ongoing downloads with a single command. +- **Delete torrents**: You can remove unwanted downloads from your queue with a single command. +- **Add/Remove categories**: You can categorize your torrents for better organization and accessibility. +- **Edit settings**: You can edit user and torrent client settings directly from the bot + +## Benefits + +Here are some of the benefits of using QBittorrentBot: + +- **Security**: You can add multiple users who have access to the bot and each of them can have different permissions(reader, manager, administrator) +- **Convenience**: You can manage your torrents from anywhere, as long as you have your Telegram app with you. +- **Efficiency**: You don't have to switch between apps to control your torrents. +- **Organization**: You can categorize your torrents for better organization and accessibility. +- **Docker support**: You can deploy and manage the bot seamlessly using Docker for enhanced isolation, security, and flexibility. + + +QBittorrentBot is an open-source project, so you can contribute to its development if you want to make it even more powerful and user-friendly. diff --git a/docs/retype.yml b/docs/retype.yml new file mode 100644 index 0000000..72ac7d1 --- /dev/null +++ b/docs/retype.yml @@ -0,0 +1,11 @@ +input: . +output: .retype +url: # Add your website address here +branding: + title: QBittorrentBot + label: Docs +links: +- text: Getting Started + link: getting_started/ +footer: + copyright: "© Copyright {{ year }}. All rights reserved." \ No newline at end of file diff --git a/src/configs/client.py b/src/configs/client.py index e6cdd9b..2d07ecd 100644 --- a/src/configs/client.py +++ b/src/configs/client.py @@ -1,4 +1,3 @@ -import ipaddress from pydantic import BaseModel, field_validator, IPvAnyAddress from .enums import ClientTypeEnum diff --git a/src/configs/config.py b/src/configs/config.py index 905ac2a..9e28a47 100644 --- a/src/configs/config.py +++ b/src/configs/config.py @@ -6,6 +6,6 @@ class MainConfig(BaseModel): - clients: Client + client: Client telegram: Telegram users: list[User] From b32a6553650cf2737f11a1ce0d5145f287f4a975 Mon Sep 17 00:00:00 2001 From: ch3p4ll3 Date: Sun, 17 Dec 2023 20:15:14 +0100 Subject: [PATCH 19/25] create docs + github CI for docs --- .github/workflows/deploy_retype.yml | 26 ++++++ README.md | 7 +- docs/advanced/add_new_client_manager.md | 75 ++++++++++++++++++ docs/advanced/index.md | 3 + docs/advanced/index.yml | 2 +- docs/advanced/manager_user_roles.md | 17 ++++ docs/getting_started/migrating_to_v2.md | 58 +++++++++++++- docs/images/administrator.png | Bin 0 -> 22700 bytes .../administrator_qbittorrent_settings.png | Bin 0 -> 32301 bytes docs/images/administrator_settings.png | Bin 0 -> 20639 bytes docs/images/list_torrents.png | Bin 0 -> 18359 bytes docs/images/torrent_info.png | Bin 0 -> 29234 bytes docs/index.md | 5 +- docs/screenshots/index.md | 10 +++ docs/screenshots/index.yml | 1 + .../callbacks/category/category_callbacks.py | 6 +- .../callbacks/delete/delete_all_callbacks.py | 4 +- .../delete/delete_single_callbacks.py | 4 +- .../callbacks/pause_resume/pause_callbacks.py | 4 +- .../pause_resume/resume_callbacks.py | 4 +- .../settings/client_settings_callbacks.py | 6 +- src/bot/plugins/callbacks/torrent_info.py | 2 +- src/bot/plugins/common.py | 2 +- src/bot/plugins/on_message.py | 8 +- src/client_manager/qbittorrent_manager.py | 35 ++++---- 25 files changed, 234 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/deploy_retype.yml create mode 100644 docs/images/administrator.png create mode 100644 docs/images/administrator_qbittorrent_settings.png create mode 100644 docs/images/administrator_settings.png create mode 100644 docs/images/list_torrents.png create mode 100644 docs/images/torrent_info.png create mode 100644 docs/screenshots/index.md create mode 100644 docs/screenshots/index.yml diff --git a/.github/workflows/deploy_retype.yml b/.github/workflows/deploy_retype.yml new file mode 100644 index 0000000..7eb835e --- /dev/null +++ b/.github/workflows/deploy_retype.yml @@ -0,0 +1,26 @@ +name: Publish Retype powered website to GitHub Pages +on: + workflow_dispatch: + push: + branches: + - main + +jobs: + publish: + name: Publish to retype branch + + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - uses: actions/checkout@v2 + + - uses: retypeapp/action-build@latest + with: + config: docs/retype.yml + + - uses: retypeapp/action-github-pages@latest + with: + update-branch: true \ No newline at end of file diff --git a/README.md b/README.md index b07f882..0f36f3f 100755 --- a/README.md +++ b/README.md @@ -38,11 +38,13 @@ The config file is stored in the mounted /app/config/ volume "users": [ { "user_id": 123456, - "notify": false + "notify": false, + "role": "administrator" }, { "user_id": 12345678, - "notify": true + "notify": true, + "role": "manager" } ] } @@ -62,7 +64,6 @@ Pull and run the image with: `docker run -d -v /home/user/docker/QBittorrentBot: - Move in the project directory - Install dependencies with `pip3 install -r requirements.txt` - Create a config.json file -- Edit in the file /src/config.py the location of the file 'config.json' - Start the bot with `python3 main.py` ## How to enable the qBittorrent Web UI diff --git a/docs/advanced/add_new_client_manager.md b/docs/advanced/add_new_client_manager.md index e69de29..22701d3 100644 --- a/docs/advanced/add_new_client_manager.md +++ b/docs/advanced/add_new_client_manager.md @@ -0,0 +1,75 @@ +# Add new client manager + +Adding a new client manager to QBittorrentBot involves creating a new class that implements the `ClientManager` interface. This interface defines the methods that the bot uses to interact with the client, such as adding, removing, pausing, and resuming torrents. + +To do this you need to follow a couple of steps: + +- Clone the repository locally using the command: `git clone https://github.com/ch3p4ll3/QBittorrentBot.git` +- Navigate to the folder `src/client_manager` +- Create a new file for your client manager class. Name the file something like `_manager.py`. For example, if you are writing a manager for utorrent the name will be `utorrent_manager.py` +- Define your client manager class. The class should inherit from the `ClientManager` class and implement all of its methods. For example, the `utorrent_manager.py` file might look like this: +```python +from typing import Union, List +from .client_manager import ClientManager + + +class UtorrentManager(ClientManager): + @classmethod + def add_magnet(cls, magnet_link: Union[str, List[str]], category: str = None) -> None: + # Implement your code to add a magnet to the utorrent client + pass + + @classmethod + def add_torrent(cls, file_name: str, category: str = None) -> None: + # Implement your code to add a torrent to the utorrent client + pass +... +``` +- Navigate to the `src/configs/` folder and edit the `enums.py` file by adding to the `ClientTypeEnum` class an enum for your client. For example, if we wanted to add a manager for utorrent the class would become like this: +```python +class ClientTypeEnum(str, Enum): + QBittorrent = 'qbittorrent' + Utorrent = 'utorrent' +``` +- Return to the `src/client_manager` folder and edit the `client_repo.py` file by adding to the dictionary named `repositories` an entry associating the newly created enum with the new manager. Example: +```python +from ..configs.enums import ClientTypeEnum +from .qbittorrent_manager import QbittorrentManager, ClientManager + + +class ClientRepo: + repositories = { + ClientTypeEnum.QBittorrent: QbittorrentManager, + ClientTypeEnum.Utorrent: UtorrentManager + } +... +``` +- Register your client manager in the config file. The config file is a JSON file that defines the configuration for the QBittorrentBot. You can add your new client manager to the client section of the config file. For example, the config file might look like this: +```json +{ + "client": { + "type": "utorrent", + "host": "192.168.178.102", + "port": 8080, + "user": "admin", + "password": "admin" + }, + "telegram": { + "bot_token": "1111111:AAAAAAAA-BBBBBBBBB", + "api_id": 1111, + "api_hash": "aaaaaaaa" + }, + + "users": [ + { + "user_id": 123456, + "notify": false, + "role": "administrator" + } + ] +} +``` +- Build the docker image +- Start the docker container + +You can now use the bot with the new client, have fun:partying_face: \ No newline at end of file diff --git a/docs/advanced/index.md b/docs/advanced/index.md index e69de29..250f34d 100644 --- a/docs/advanced/index.md +++ b/docs/advanced/index.md @@ -0,0 +1,3 @@ +# Advanced + +This section will speigate the advanced functions of the bot, such as creating new client managers, and managing user roles \ No newline at end of file diff --git a/docs/advanced/index.yml b/docs/advanced/index.yml index 5ff38d3..4d66b6e 100644 --- a/docs/advanced/index.yml +++ b/docs/advanced/index.yml @@ -1 +1 @@ -order: -1000 \ No newline at end of file +order: -10 \ No newline at end of file diff --git a/docs/advanced/manager_user_roles.md b/docs/advanced/manager_user_roles.md index e69de29..1323451 100644 --- a/docs/advanced/manager_user_roles.md +++ b/docs/advanced/manager_user_roles.md @@ -0,0 +1,17 @@ +# Managing users roles + +QBittorrentBot provides a user role management system to control access to different actions and functionalities within the bot. The system defines three roles: Reader, Manager, and Admin, each with increasing permissions and capabilities. + +## Reader + +The Reader role grants basic access to view the list of active torrents and inspect the details of individual torrents. Users with the Reader role can view the torrent name, download speed, upload speed, progress, and file size. They can also view the category to which each torrent belongs. + +## Manager + +The Manager role extends the Reader role with additional permissions, allowing users to perform actions beyond mere observation. Manager-level users can download new torrents by sending magnet links or torrent files to the bot. They can also add or edit categories to organize their torrents effectively. Additionally, Manager users can set torrent priorities, enabling them to manage the download order and prioritize specific torrents. Moreover, they can pause or resume ongoing downloads, providing flexibility in managing their torrent activity. + +## Admin + +The Admin role, the most privileged, grants the user full control over the bot's functionalities. In addition to the capabilities of the Manager role, Admin users can remove torrents from their download lists, eliminating unwanted downloads. They can also remove categories, streamlining their torrent organization structure. And, as the highest-level role, Admin users have the authority to edit the bot's configuration files, modifying its settings and behavior. + +This role management system ensures that users are granted access appropriate to their needs and responsibilities. Readers can observe and manage their torrent activity, Managers can perform more extensive actions, and Admins have full control over the bot's operation. This structure enhances security and prevents unauthorized users from modifying configuration files or deleting torrents. \ No newline at end of file diff --git a/docs/getting_started/migrating_to_v2.md b/docs/getting_started/migrating_to_v2.md index 874387a..68e3480 100644 --- a/docs/getting_started/migrating_to_v2.md +++ b/docs/getting_started/migrating_to_v2.md @@ -17,4 +17,60 @@ You can find more information [here](configuration_file/#enums) ## Changed names -There are 2 changes to the field names, the first is the name of the `qbittorrent` section which has been renamed to `client`. While the second is the `ip` field inside che `client` section which has been renamed to `host` \ No newline at end of file +There are 2 changes to the field names, the first is the name of the `qbittorrent` section which has been renamed to `client`. While the second is the `ip` field inside che `client` section which has been renamed to `host` + +## V1 vs V2 +configurations in comparison + +||| V1 + +```json +{ + "qbittorrent": { + "ip": "192.168.178.102", + "port": 8080, + "user": "admin", + "password": "admin" + }, + "telegram": { + "bot_token": "1111111:AAAAAAAA-BBBBBBBBB", + "api_id": 1111, + "api_hash": "aaaaaaaa" + }, + + "users": [ + { + "user_id": 123456, + "notify": false + } + ] +} +``` + +||| V2 + +```json +{ + "client": { + "type": "qbittorrent", + "host": "192.168.178.102", + "port": 8080, + "user": "admin", + "password": "admin" + }, + "telegram": { + "bot_token": "1111111:AAAAAAAA-BBBBBBBBB", + "api_id": 1111, + "api_hash": "aaaaaaaa" + }, + + "users": [ + { + "user_id": 123456, + "notify": false, + "role": "administrator" + } + ] +} +``` +||| \ No newline at end of file diff --git a/docs/images/administrator.png b/docs/images/administrator.png new file mode 100644 index 0000000000000000000000000000000000000000..8cab98e6b9009e84e47842543b90b4125e1d6ecf GIT binary patch literal 22700 zcmZ^L1yCH(wr%4OAZTz62`<4Mf?IG2PVnIF8r&feAb43x|O0U!0cJ&LORnn0GW9m3k&VZJKLL)JgRit{EC=655M)0(!> z9_@3#WF;6a^hg4TVTX{?&mjg;MYK{ugQ!qFzIuY2T4md)Q2!XbF~mmPV8vxa4RR!m zBt#Tzita%VqFR>PL=wY3dHw(Sc%;oA(Ln4xVqz9!`gHgoKr;aq>ZbAUpe}Tw{__e< z)1&nJK%qcSu?a4kk1BPD)M|Rv5i*DqTvAgQ2ru6D1$DVp%)CBpv#AN#rR+tIDk5%9 zOnFyDj3_1~fT~(2DPLYF)c;Ehh7dzi_hDxzeV0O-HkCn`KFWQR3N_FD2fgm=WQ0#& zAo?)t0@_RTkOYm~B4V{lstAF=uS^VRro5!jUY*kQK4jQ|A9mz|ki@2`>q5Sk3O(PV zO4e_MYV3$j6kv*DyJ1L8sU?ToSaWN#8Oq9?(uz&%fS(&mt}2~TjRi+vC&}od_!c$q zBth2vxxh~)){|Pj4DJy|fGvg{|5BX8d{po4Ngul8R0KvAuH;HM{fn8ps15tJ1csR^ zIYhB(MQ)L-l_1gWo6H0?n~q-=R+nOR*Mf5Yh2W0+{siYA$gxQ^6^G1 zZ*^xd32@UXL@jRCpM--E5ZtC+q1Z;~@J9nq)J-M&sNK_ZpAM8p6AaB!_UUG}vF{>Y z%kkxNm?WHDXWs`q?Ut`5UDUI^yy07nJoY!MQ=Tsly`h&6A1#@-on>ZwYH0L$Y&Jdb z+3q*<8(;qs*H?!FKHaSQ8~Hz6-44s)6^|41Sx-KMMkghz+Ym4VpB}y&SK?)~6yf#+ zYpo>dZfiXT6|FGRS}(K>HmOS7zx3e{IfPE_g1gY_)awF%hoH8t)ZXIT=t`hypz>Jjk;y;T#O{{3>7oi8(D`ES;bDn zD&B~F%zV=A-B;E^!g;T|;i!bSyzTv|fsimnMIu4x+aYf0q~?->x}PNlsIDoy*kSYAmjHBGk!z9)ZiTe{U}Azl@K!;)7 z+0B0Kqqn=T>xG$F@Xwq1?@u&zw8a)eyl#1&IoCgb`?b1RH~L+7#4bJQw{Dj2v$Z{~ zKq&Q_)6aK)CGU7Yw&Zl6p`)$VK0DU-L3LyM167q@y-IEI=Mh_09u;`85=}Uxb4UAz zkE;5|-?uoXz7H1*SCx7Z(ARwLe%$})hoMIUKTMZWjQq~W#%*}dcu!jDmlo2hM>>6N zuU*)l$4%wbJ>A;q!@Z!FbpG1vgK)25CDWk zOdKV(9hWF1DtJh#xcnyZC|N!2_L>+>?4yff1+R#W>ld)aMcE!N2HCsU^qcmQZ%;jJ z=d7Wr!|~Y_Wx4d*zr0*58jb`8tn1;ro`v~!hu&4TVD9q8<&_Xxiag$iI|z&1^7n`1 zhO}DO>BjmjY0&5cG|h!MlfP|i$}}`xR5^eB>iq-<$S7sjEZQFHa(C)n_?zUB-0Zb! zJvONN+VJS{IIIc#IvPP)`aDNl6vzCMz;zv9yUKu!?!ac}`PtFk-rraM1Nbm@iqxmY z#Kgo^@Nd45agq=mHdItXZ~f}e>zr0G`Po$w2?&g{(<2vl>SJ^_`}A-~nSr-yQb(f9 z4$k-$6N0{*y+KZDF*v0R0SRXQ439X_d_f>WNm1S+@u z=cB?Ie!4zht3~FT!kcO<6ZF9>-51vNGB5&`CHKAg0ru5QD6$AbB+-2d|1!m56CvH2(z4d~T}A2xu4VzhK(W%eL{6GUHbWzm6_S;aerjoiDO5?8Apfj6BFYd$1ES+ z<+xwl@EdshTF=vKr~ zO&7}$LKaa}Gjv!|%2V0MAAGRl4J?JJqJFJ3E%GCVhP))<5o$4J2ENB6oBRZTxGlao zb$Wc@6MIJQCu)ui`?zJ{H&`A1#tqv2AJUsmSBAhKbt3AEfjv`dr;tB5?e}-f-AA)i z+THMJb+of$sV=K%A1D6u`MfpxD3;($g!#hgl83{S`EArpXO4i2@mj9wVdIhh&F(Om zdp{-p@4hR2a_It1n%|ROANV6`30v)!cbNgqOQ*&_(p2Gjv=zsE|JEeYDdl(+=v6i-JxU+1bzDua>5Un+sHy2v%)Mh| zp|QC`uYT@u@)>6($2!(;bbn0gi!~9fv*Gt2vMn}PU1(7hT+O<6)(DO$XRyj>Jar#5 zfZg|P1m9E4L+@KgmzL^2XaA+Q7?>}6sgZZ3(2!F^cL6*mJ*v(3Ua(28T-ZGd<#$|%P-Yi zQ>>qQZ1?PEF8eZc*L|=h!y)m0xZron{4=LF_l9~*uUJuOxXqho#-b4+P`)%K4ns76 zVGye5+t%*u+5(#PIPh z(QC9VnzFt+5_R?=7t2lPmz0*|MopD{bBh#OO00tvDg|`C`=I~9EPsA4@z?qA$HZPM z8sIOVNW+hxO$BuaQ8??ThoxF{#pV$ib+)TbV%mF=oRBGwsz%$@?LRwjAfFhMIlxqI znok2JP}a9^1s9#roY-$edm96I^BRbSe76f>mK>9ly^S%FlK}(o%E4kINV?ou=cGDI zaD)~-7S}Nm7e001iQgS#a@lbPFB&`+K#`p-e1BC;{{p+k8T#kgkMjBx&+?Ik(^Pj9 z;Y*IRi*n`F2`?)d(aR5*Mf`Y@!dtj(nR0IuDg5q+wxd`M=bCrnfcL{Zb`sq*4R)(t zt9yvKR=Qh-Nq_6)EA1DB*m(sHrz5u-?Oi`|M-XClJpTzDwFoeOx(|yQ`k0gB6t4;d zQSrqIx@?B%v!*Uv{c?w;R0ao0xZ5plq7ndIFkc-xCskuHY2AZDbVzXbpR5MYGYa45 z-s7R6%cShIt#oi*6&@OU$KOm+&2>E$_Vh)tt-mujnFTLh>xS z^{PIef4LN=>T+_7l6nXT$1?i@-Bwrg`(m%LFc1TI$OWXOWlk5Grz{#La3gQ909?I> z#3A#|hHQ##MLfJ=dHw!N%8Gz|e=+Qy6%8$|3(|}ESSy~Ji@`18(o*1uMmgO3{7c4> z@$1V=i3k!t*AiG*XSGf!z4gGE>L&A>5S4>^8^ZB54z`UiK6{T@h+@m6;%TCVt2Q;SI&JrjyOkiP+r1}N%PVjvxj={lTzaWA<vTy%V*;0nXCaJy7e=m9`8$@+b zV%?7pP$?VGMhKKj`w@(l5waD$DAeEig8h1`5jD^LH<4+K9c})o8qB-F}e=kdvdpVI9wwu(b(m}ed z1a?nA(*0Jcq#fZ3)pGw5IyGV-a2ETF5=g3kbT%sK#Pu{1I?SpZ^*X|b3Yz!i>3os! z`_V`44XzUpx%l3d4>pcC$S?f8_g-(3t+TY*T zDzBFafBAlesR+C0ShlU?BcfPDWMnRVR35#9gF|`|0z5pncy4YkzLdF{86pZwct{9p zP?uBYWrJl9UQhd1=+`@mqhwh*xf;RJLZF<cr=<9yiC-OJ_5d9Hm~AzY+8Yb{E7bBk{bT9BzTMF$lMI2%9y0&z6b$#15V-8etdi~qfrmR_z?XXsmu2Qv7 zF8{KC;Q-8JtkaCi;2hXx}luf4vG4lxdns~W`{jW(fsZb}xJLBE1J;rW){g!<}f`)2#Hjf8_R zmewjui2u`uo=r-?YNO@HsMn$e&xp?753!h08Iy;WmRfacEw~h6LgP-ED;7+nBJ^?@ zJNezRyLOgN=R1c4OohC)e)pIOvew$a>lSSdP~vX)V1*mGV#6Xnr*o&BRMcCYfn-~E z2&T3iX=7P}A9Zw^t)_~EeXj7gA(TtCe~R~Bbpi~Dx6VSz-a9R;lE+OXGe!N=10_T^ zD9T@d69rtZD3jmEJ=`{8Mvmg*JM#mdy7nlKl1g^0J-w){sbgx?N{^;X)vc|q$FqfT z@~Sdd*7!e_cPc5eKV=3^B|2kT?Ebx!uCWW_{rtC!9o3r`ukR%rOF49*V^NYO1=-ErkHzuK?(fDAyM8#%aYEWGHgaSmI>i=hS{`>Z%ToexvHc@ zRzZQ22m`z>@Tb4O3)M|@;X4K;q-bbs{U(#ey0)7a6%deq{dL~<+DrWUW}=&{8ch0G zTT;!dXJDPk;(3e&&@(f7L1R5CwMYTx+pF++g0=?Z8MX46lzDTiNWx2u^9!5xba&Fq zoI@eeJOb~_=HmOy16^>=U}Jx3Tp~tu^zuRA;?djRI1genM}CrZ&)-YgBIUJ{R>dDj z*J`y}uH*NX2tABjmYAxmj#t7C*-YeslxSuJsmv$28s z4q!u7adSy`Y`m92-HZc+qbQ=--_Kmk!%@&YpE>*Rt0}|W*sw;-PX*3sp-5jUNpQMl zu@QgaQmPshJm)>3UR=+V2xE)$Y8~Rg>qAwIGZ!M?$|QORplZvGj_lfSbtaXd$UgT5 z==vKwJ!J{tqi%{(!|MMRR}ALQt_MHbmw1S(jnhX3?o5V`NB`sHdXa|qz~;@Yn_I;yREKss6f zm_eQ1fHR_H#DZNf_SG1=StPc&b8`5HQfE231FqIy+vcea_awdRS_D9RV5&dIRc~5B zyDMx`yy=p+0E+e=pMUuhyH7<`O{F$rW7m`M{OtVg@4OCi{PT-*7hOsOuY(8KpC#4U z@bP*tJ-C}*u@RMiOH3fJ^^=yPkJMo7NRcIWZaU$S?;k8w2eZ!NE-rtlTlyf|>BR=A@*J;`8U zl#>q|K4h~#W;h-itp|k9Dk_0%H|*WYIjuh}pK<2eNM|b_8dPmHU}FdsT6335AN?QY zl0urU+QaoK8ufSjVo5d&p>57AmtdUEnnJlaS)~#xs8~K5rp(BiiVTR=+Ty<~b+RI! zd!BFW<~|K;+!Yb^p6}9HK_QK`NUeB#I)1v6?{fXy6owu}7HKMjG3#ZOB@YMSr+#~E zaLQ2F3{Yv0BBX48)CK86kbhjdB(bnOZCfwVr_~(T@n}nwD^v^B_xGva-A9X;kqR+T zkn@NmBvFo6Uh`3BO)V@if69QG^q4+^)ls2nmDA{rvbXtc9i22*C0BaGWApVDdZ6sk zyBEz5aWk6Gh}LBy?%P3j%zP5QVxX(Az~|{1SSy z+%ftqxzp2%J8QAgeQ?H_TVz$B30h)oxhlX0_1;Y1*!Fg}@7_bMVgDNU&2U9v;n@9Z zwj>1meAWvI>5enThUyABi*$3(fs3X%WkUWv4_itVpfWEI`|`w*cKAlORD*V@qP~W47etl=*=>tI22&#I?szaiTWkgBd zmDT$3KK46N^88!I8U?9BRqT)^9^ri4Rw14%EA}~w$`>!rQss#uihHC?V_t`Kn+}mh z^z2fVTHtu1da=bplFDCXbqr^yrm{GEQB`fa!Q3SErCkr@2bSigsY}a8Hub^J5GxDZ zetd}#d^O46qYZ_Dzs*|b)F@ff-=4Mo^xdfj8C&A%#{N|VO-0-^jk~93LvoQOHh@gT ze{r{t`IlPm%|m{px7%?9_Tm*}?W(oag(yKJdr;=bfAYY#fq5=xxQ}D8L@(s}*En5tpGJrDI)| zXRl))hq15ng%;5}paL^zx5#>8_poQ|V)T6@SIbH0dGxwm#ON+xW2oZcF6n(hCpCgM z7V+Qvkz0lCK4lkQZ-d+fHQZu}6W<$ijPrywp-rMLGdz3IC35i~c;7a+PP_Sof84st z+N<&a!YN|u^T`p0DLUaV*e#wrx)xe-rg$NL|L{U$$e~11s&wP}17pK3X8cV;KeM+y zvmqK16OQ3LLet0YvY3KKYAn^q+R*sthnhEjGAINos$-7wOh>CI6P0V$7AU>rSd)#S6UG?=EQ` zSI)UO8r~`z;YlAk?SSGcssewDWgwvrV+(D59=@%sur)m)m!S~j;Jf+bD+hmVB;fuf z1u38RqymgUAi&#T1H#}UQURyiOrua-kX0MhzTK$*!5+4u=K{CBTv$LJa2y{Wt2I#W zFQgntEMj_ovsFBHNpjb7wGwcBIx|``IKqIJO~maA$wqjTRub~wyf_nkB-6PL!@hgy zZbZI5y_`kKxuU$Co=CC;si(=kjbJjQr|W|wP=X1l+g{SN;f-L3k0%oHSu9{nseCxA zbtqJ`xcoKV)?Sx&7kf1AV3yFi_G^5#*j|IUNRfn)0GIC~vJ|#vM79pOUy3)P4guN0 zc3AbJMXmYzo*aUVGC1MVw`wVJWyshlzBPRxpoP{j1>6RkFT5)ES7*dGm3fI%+v55; zDJ>bcNqP!W=ROV-ACW>eh4m<`FG2OLf@evPGQep^_h1=`a%Y=xd3mdn2<>sMLqZJh zDfhP+a5mlaRXX45{-iT&_)W3JNLu4Nw$7gwA3BW>SG?`TLM+si{Q*$_@i&9M5{3`6}bt~?hM2-LdVbBm6d^o zlGof_UUzqHIAGOf{pWYqud>G!7*y){{23$UdSY0Al| zz7E1dCL{q#jQ@H8Qq3T{mXO_m>#1rmzQ=Bvl4p$jUmky`FlxBu>XlW&#%>Cdm_or1E%uvHZ_KuubH>YG~Ox8WU>hG$xfL9@vp?*pK2D;~qf zp{JBAcL{mz`GXf%t5Bwas zkhpT68GoIW$0tbrxS}898~Pur-Fxw!+Pj&gMC*<2y{YCqcekf3V{FVM{AOKs#eHxf zs_)Lp>~K524)=Kc?WFInw7MrZF5rFc^`*1hC=~7;D=UNF){1iGqP~RtFX+ zaEiH~>}GJ-YJbSXMrq>UZdyYyLjFdN3&q0e@;ERTosm_~c!nV&ndy%cApXAfbs=-Y zAVQGMq|;wM3|tc@vriJdl;gzMT5kNaLc+X0>yHhcJfAtwsr}_b9+_&d&sd>EZt3w9 zY!eGcf{F&c7NSDjA!u@iR_iS*jTx&zqXRhwZrfftm^E9)KUl4`^j3@dvisZ>AW^cG zmQ>iTa)DH^9pA@g5aqZ!M~`&mcJwaZ?95 za|x;58a&I);yW&z<$)=Q$5E{ED@u`4Kn%s{LgPDL<|i@AJ48^ zECh#%4q9y-K8uOhkkjSb)0(ID9bSQvlU;AEsu_#J#nP2LwoX(2F@8fc!|@F-|LFuc z2&>`#bd_eSR%BeodKb{FfA#e6ap)2`#TdLB zTR79@JRR}7%gkh(+2n62X`?CjSxdqqCO^L!``^`<1w3^Y$v|GVQ8C|~amvVBXxU3! zxaXW?`L(%$+RV{n%`vzu7O()ns>IXdX;nqpxVBnc#B?mH5H~ZCA-r7tk**FXy znAf?gb?{&3JE`^y?_rMH>am8*e5;#VeWGXPx`7nt@HpVEnGR^z_C~>}|P_ z?05I%+pPaIq5vZcck7-tIQ4&!+PaxnUiIp>sEtj#VD5MFut9Jn>lb*WAPA^`m}wO8 ztq7>ThIDT36ngodkNEMnaPq}L9F9jeJ+oWg^xa1a(ro4$cw2S@~djwfP0&AF`;IS~MgQ{|~FR&%uMMN)J3 z{;$Pl8O?!&QTO2hl=LFv$R3NvHB)=>(Y@&N4I`mtpzAOFP-mG}vcoz?QL9#KnL;Kp z#Fv?jC=yY1z~5$gK#@z49k;0O8A==(k|L{};+D4K#EVLu_F>4$1ly~tr`v#~zFheA zCuWtHdCcA|6wq_DPC;im>d))pdS6_kDG!{%pkpILVyJIH%g*c@dk}0irnZsy-NcD{ zWJ6XS>Sz4nktx_sPBGYoej>mr!7A(_`Zgu3*@T`QyPY4dW(9`*jAKrD#vi|y7+gl* z$y93jwR#$d^RR$%Br-%g(EU{KqOUcFuQA&?KA_WIOh}xcN>HwzUFHao=@KkXdV1#C}%sBch~6-CPs%fwx4>|&fh@Gno$4ZNWGKDYv_;p4;D{-Lgv|9w)V>&%F_lswP_G*j{EGC z(l-66laSgadUZC#q3M5%} zkmiihDEeU)rlEqCH`dh!t5~tvIIT1Lb`Ody^yymO)^B4DP47YF=J5?xGJ_`Ba5~ck zyiqK-PiFHHMegUw@UeJA^q`WdHyB%`u)=+74gAbzkRTDy7BJ#?BaVFu2alEIFdD`O zo_5R!C2uCwyr=hF1IvCXL7a`7p&F|77QCQ}608CJyNRTjZ*^cbt zu%=e58XQ;yTt9>}gowjGyl3Q0uY7yEP}NEk5#DWGb`@Jz;16AwdRvEziC$CV8@XB& zf`h!R(c2~KD_wDej3AAAd$%W^Z<3@$*{l;oWlfCBMj;fN%JxY^-xwhT5R{3^V21|~ zqyx)H7*PTAHnknEd_Zhk8XUAI{QE+w_umw|f?}l_r>AK$q-(Q0yUVW_^1FWYcw~eaqn7^)|Zlr!sg7bL?a(8ufw-^*J zE&(5ou@}rv@J<>4>n?k)G_W!>)RCLLe^91^f!AQf^a~+2GFGzhK|~dvcc0Is1-31b zi}w1@E~pUGEI;BGBwLwH<8@E5rbB~1(uSibyA_%u^J}^B4(8l_h~)=&vs=$Y+>AG$ zxf|=u3)I5$>?!n1zvb>{I3PUwW%TuXmCS(R`FqsfKFLsI6n;ARz?^uGoNR+mgPKKO zQ>lH>;wi~T zXPmxXm=IPn7@7|d86BAbBKm{06Cr#t0DwScxQ^7p_-M};*ZS=n1_0I`05<5gfi>nJ zBi$hYeK%)N^(4y&R&y)!kk9lE{8cjaPJ|7!ZjXKpe^-reaZnGPO9IP*awY} zroo&5%1nWK?vTIJ#eKr}7fB$2gpu$yjoYbD0fbqb;SJUk-ylteOtZ^31e!4&z1jEv{?|qOFUOBeW@_k-_2XYuMvcG#03xMVgNpHVN52aEVf$;`#7a z-q~%Dp88?{9BItA)2n%jiL)!=Ko_)=C%fH|p-jwEQX5sAUYPSR|EALRu!RWgeQ^gKW=2>TD?e!px+suZdHfbk&NI3j}7?YRA%c`uG*A^cyy8Ms}?^r>J z{W@51_>m=fRh!KB94{X|N*?1?a^n%|``iR&e`hY>Xw?I}^WwodIU-(Q-Hf+*x&Jh; z9Uz)R+4&SwwExuJ!V?)!%I_rYYBJ|fL&y4L-MrN~BJ6rN?Y|#yvv^6-vAy&PG^|Dw zjAb;QCAqhURjvH2_e#2fYj1^n|G}*1LQeUi!%5F~Y1L2W11ct(Zmn;m3wIQ_q63yu zl(7#9*S*huyvowKUm#?ARM_{x+F5Bw@9NqsMgcptE~ImrRsbkg-phJ+g`$cZ{`hnR|9gSEoNaLXuYMp>(TKmu6l z43_F4@BNKyv`5jfFp&eB*+vp%_e7sX_73xF7mh%`s>AdV6(xXY=Qzhkr={=-dNMzCe(~GM^aE{s_~0+}(ONI!yhsZSZ}39H;cZ zs{Bqwir4%A!vd@33axU_(T>yC^qmfis~w8b{W2K-*2@E?bmJn|2YOPyZxuI5;k&Ml z{I{FVmu%hBi{L(e$~gfeXO6$YX5E4hr5G}fK*(*cksli^j}0ys077tJ?fZyz z{el_CLW6-%DtBan^TkhaQW$ikb8Pi`&DFY)wc@Tk6_WeCwWvh`7-KabOKFpcfu5=~ zf>Bb0uAyo|{(!uET}dH~f1y8!>P+d7=Ee8LpU)#3E`ynYa8m$TfN4nbme&i!7w zype=!ps+7p!3+e36tVXuOHUOWv90}rRiFL+6e)Rrc*jEY12jxYC&@in@?^00+3CHN z=0<(qPZcHeaZgFlZayAw$Lo*#Jlli=_&&pQfzKcTPtR$1xbBFV& zK(ZP!0ZETD82}oa=~p$$Ny)tqJ66t*vC83o$oW=m;jw6y3ue)PGxe@FxF21J&9S)J zEL&c_Ga)nHA*VX@>;x~B{>KMeL8OaS(^NRI&|b!~8STTBv0tf)RrU507x_po$@1oo zKRUN2hn)3|jiK9g(lTM;FNhlL7BV|0CcW$rdg6|C-xiz8ewJ%gI*!j4>ULTl}^W-iPIL^s4@k7YBWuf zj8X$sbq%xU-{h7QOuL<4*SdG7K=Z@>&&Nm+?p>?7(jOmvg{I4Ll|Q(#$O!Rm``4n3 z)4l7F*wWOtXQVIf((Y>9K=S1lzP9!j8HT!R$qo%D%8%F9WX|ZPC1oG|;*BbTfA9r? zj4Q)_y29+&tztJrT_XnrV^!!dYz^W?&A`vXvL(>x38q2e8OC~DO%08QqhXk){7$J0 zq534p2!V~~=ZManczxD;o3#y)2&q{(5*q(!DU>Q?=li=hC1IddWpuZd{_SF9S8>zh zgx?W5q}fl$fdR4&nL;ib=QH@gJ3Z#++feIzMkcz(f;!*slq}jLem?px^-9(`Qm~3$ z0y)8?1u`;x>~%=FTIox2!P`DT%8w+9L|dXfrz(v26xPXuX8sOPUXZmIx(EZAF@*Py z2?1)(2jD5e0dOIRDB1i0`$wU}zTsMQe`# z=-{Y_uioaWwPRtAFK|d z+P1U=A59k6k_OU?H2+KQbY?SL<`Mf!712`pA3xL|r)O>u*Gr5lS%WZtWmlUphbrRk zhXq}|5A=_2k^!h@+bmRS&As6Tbymb|Uec!-q$9K-PtTE8Vd3)>a6s#*brn^78fXn0 z*EjyDXTz7&L;@2$*5VvrEPR1PlcfHwK;T*p2N5c3jE$IGuk-JnnF0%H=R<#YunWtv zQ&=3-vtzGN0jdz9v=2opQ(O|_m}1k!M4oZ=Iv*;IDOsF!!ImTA0>)S+{U6ET$PwX2 z-^oiyv-9i^^IuglO{`_KCL^NJX!>~lb9%Xvh|3-qnig=(VO)b_rjOFHX*W^ah_ z`n1D?^`JpqV5QShIw5y#_^;9_nSDUtoH##I@w!K1n8|)oFI}kS1%DrS(f>>PRQXT(TCH|d|L;-lL98Lw`f%fIdp z6-c?q{Sg|aN>BVG_5-KaG~}3Uc#$2s6w#MkzCgpc{B?Q3qVmw2h^4PrvRPl#KaLs8 z+2*#8SF z71z5uI7>$76&$f?d~o2AKvTtJ3O3&0L|NU1YoR^~1w~3uYHCJOahyX5>`Ij}nd{F- z^{P-L6nU$4hE+2{{p<7bwzCnl{J{s1a{`?O&p_jB=ifV|7FwKm@$QIEOex`rUV1Uf zh2HtPl@;&y9)113N?O-{M|gbrjONGApmVqrd`vKAlEC=w_U+xTUxF2deAWYj#eK1e z0LHx8FEcaq=Nd1_ASVQ5&~`_@*n!yLQTl`HsH3>*w^S{4t*U3bMsZxMj1KxW?tOpR zXDI^{4&q>h$7lsiski2g{M|5LuTVK7l{6KN4TP2-G)2)26vb~=RHt8MkO;VI9jJWIT zewU(QWTd^D-gi+8BNi}o)bc(6X(Uipc8`p`yo+iNI8GWWAQz|;_1^ycIl)LyF7nVY zlljqUbi=f`XD(ybm-jl97-Tc~z0Wbgb@E3dID@ul;rJ+&=_BkM-C^N83{(XA&}y<7 zXTZ*~G@WLl@$#86Fblx{2$9B&rB%0HbgS(ZSEU!?YLXKNZC9cJ`_}%{`VftkGR##g+)uOdpONV~@_7N!^ zZAXvY-a3epXouw%sBiUn)_88Tfu?Wc7|;V5nDd?Ea3GSCHA85&bB>Ub#j))l@$V3D zal07`ySv(h2RfOb$$&5s_De9}I*j@mZ_eYGu*X0-T~?Q$AZu}$ku5=WG6%TP3k53P z`1q-phf^-k9l_x*$%#d5RSvaAJD(0(V-ES1;{t?gbdvl~;`E!mD{T&YMr=}l|I<$Vn%j(rPhuA@gxpU4D6jdKK`$H^%$}HPUs4DYuQhmR zn9NBT`SW@`?162HoJQH5ht-clrqJLFoysZ9k9+Zsw1tN;mG!mMB)Yqr`Pw z9>7>zUmqZAI(2}T5R~h)U)h({J`=l%G#*#MP+}~avO!4vx z-cRq8J7GRt5O@TK4vhW!*RzXS%NpTucX65Ehsy0bY^?{B7 zpDW7!!?F{%-zsRh`1h`tSML}+t+k&zU1i&KKDbk~#C+-p3 z4HaCfXv4;~wdvX-e0)ED{!GAgG>MkPsz09|Owz5S72r^-)UYThxiUSV&?sQr6Q^UMW5NfX7%Vp0PmHMcSu_f! z;vn7KQ%8CG_nB=-o6~U$UeCUkTzS7KW$+pmeqehx5nh)T3oZWVV$=ELuV0Cq2?;7M zz92%)ciy&9ecLtoX!X$cVCHUG10MQhAI2fP^ml4sZMmrD9IRC0^*hB#s%h&lx(x6^3~=1w)7EtQZ#+x!mRyD1FA~Po zceal0w%&ECV1yRRUvAL!OGpGQHQKn`44|dyM54{)}Ws{W~mK`5K4s@@?1C2VM1;ivR;YT1X)akd_Q(@uV z_C-wjpq0?LI)3szSFV+~zLn1H7aF>YuoodJpeZ3_o=90a9-Q4E`UO20V>wa}_tx(^ zlX{{Bq)SzLBtztdztIM>a|%}59TISbg4D<#mn)^?#g8NyAE!88({!t=O?3x9j2Sfd z!LV+gaPn45#M-EizX~8vB+AzkSENLgG=)J&<0wVr=%<@a1+&H=^O5SStBXxdVPN33 zU#_UHWR6Be-~9a=niuy~jgNy-H}|E=o~v%X?_F&rw{>$fbB?M{xHLB-%haL5@MHJp zo9{EQTQzBuPIf9V)sunCFeZ1>^I#7yEEh+L`o$YmK=nnb_&R@_d<$28b?9nmsa!sgQ4 z_*e0t{cv5owWdOY>Y4^bk6~WFwY7$7ct9Zamm~R5fZl@!wQba76YQ6?dNC2Gj}8Ji z1vIdWy>Rjq4+&ypYD}j=JtVl+oz$k<*zq+g=)7zE54vl=LmGX@(X945Sry+Z9$Rwd zix{@&n0>7di;zz^_wtHH>=SyLc;Tey1V%7@)IXY9A&O5=1ptVo|NSliHdRy}v2>A& z2?qB|;*$m$Gv~rT-BbS=x^(VU0)s|X>4_7Qp-P?PMyb=kPIY@VPtQr$%e&W-;3<{r zpQKBIXVW+Es!Gn@kX0E-QzLMvm%{`hZ|Vk=K&yPvCb zX+_@T=f(vV_f|tAE3UNIqI6~#acnFQ9mZ>iyR&5Q#PJd_!p_e}cv@HN8+DK6>x*YB zz(v+ro4GQ!cAd%N8LM1|Dz3Vn<&7BDA*`boyJ6TGlQd^Q4&T2oAph zI|76V086-Rdo7l$+4 zFz>6GZ?1C~d8|~>O&su@$;n?w%kaUuL3p0o5HC2ul=%||(a%7!bua3HH8w^@`C?UK zOec2=nvrU-G9%xT(w6-T>foiYN!d5JUkFFE4w+zU&n~BMbIwYC#DkpAUsyN1*K_l` zg)W81W&0ka@mCQ`8ob}^rLnNo37*ilFR24dMgjog50U9_h4R=qYtWbYHe3A=7&eL+Y8T8b?U>P- z!3#~7YxN}$Fa`Kro;JzL5x~Z9`Kjr^iKHsp;z)+pXA$}iuyf*gt=tDpn;|9Jbr)c^ zvZ!4ETk1inHx%33YgQx0yzykw=VbF$R31o#&NfSsGIVFQdYX^6j4}_y2c~LqZa_Cc zV$wZ@n}`uux0b(aQC!0L0WuAjr!$(BTIM_dCqosy>gN6W$Cli@0`Ws%nLTa8mR(wn z=jdSrQ_`<5`*F>H-rdtPv(|6fDHa$uuuq4!Eoq8Aec{qC>o$vjRCs9cxRFDG0{ttE z#>+{`n|JQ1tTc+{iARgfzj(wrp66JW=lIx|2K{??i;WD^d20}jSuu_Hzf>90~Ly>*(o&(HXJ(K`sC z4sBZ|HcbG4n5YJ^F;N7SxELu1(z~4`+^?dua7Z)#_N7J-ctJ$>XyP<>nAcVp~f@e*e zB$rCXy%CQbsb4%|JkPOqmgCsy$WVdj5kfRYAppP_Kmfo15{xm%fHB4x3j)F{f5)DS zs0LvOp_KFt*9-svSn$CTjH{j^v+IwQy}EYt(rPws-%ZohqIc&_o&B2F9%&FB`qIoP z2M(Wja{3DZFk$S7w_kf9Bq+d8Q5hW>@$#GR{l00tR4N@cbO6V3f9^e?)p)-9#!H!5 zIfKSb76c(OB5e1DRiD21=9oz{#n0;9t<#g!UpRL1?2Ru{MJ|_(8am+X?|=T}i|+tn z(i5ZJn*02h%T{{pyu7rUkCrU`YWYttn-Gs2i5HI;$MY7e#cDAJ1_UC6D2f8W0DzD< zzh4X;7%&#ZumMn9QY1DITp&OIz_Ofc1^@tHjKjl1I=62f6yPV5%K<?i^HfqFYr7s2t4L^5U}_^F2nj_XAVnS&=^#Zx zz=9%zAR?lOBB1!dgJ1^>7JOAyK#BsfAc9B<2!s|;AQ);$XvwDUZZ=zIcIKS-!%7GP z(P1sw;Qjl=b!O%)*9H6EQ|6rGyGrvUK(FTkplM2SLi}%iij2JntagVaNtwSKp7rdc zK0Ug`$HfprUwC~!0O)uf0NCsmRW&$WE{*2W+DAvFv+B>)MMp&dK=Y=_ak0^Xl8n6v zoG!N{$+rz1`RM2eCXF9`aL4+6KOcH{;G2ZtE~b6)^6CcB5h68Jpt+4cSl7;>_3?0a(fPD{dzj@8~_v+ zmjFPAwy9M^m`#$CJk`2OH65gxee5IvEc|@s#`NuqOZE!KTMIt$P0>Uf<&Z!{WBCX>lz zxFIpc3IzE-EL@>(y{%G!|%Qm09b|v0JF(tG#Gkzy`^pI zmQ_zVT`rbo2leY66BQXzKm5?I$I7j?nbV$Ze`9Kx+1#jM!fide2JRer-_SGXE*w8~ z`rP^aFtgcU&;!8H6FEmu0F+@UP(p~`FA0Jm2tq+&p(NLMKGoU{TcRQ(CXO4mXu-U|2#C#Ik@npW zU!;9E?xEpx=S-hFXL>?vSyzqp{Zqhq3?ib_fX{fmHRO_D{?f1yA*7t1hAi{?#yKH+@6rD_f( zIx3=JLVR&aSxH$r0K7bN>WF)X+%e}yri^TQJ8z?oT4Ov zQhMbp8|7AeRmVr8(F_}U$4?o117QIGd1v!Q(eHF!dL^{N;jD06eQ5Ldm04|7Nf98+ za$s--g&NF@T4t+o^yt>r@Ap?aogSZX^kmMV!&#-}R=dsa^?0u#A^uL1B&}Za!`KIh zeevmg1(w3d$cVPBTe@8CH8>3UFN6J&TE_GIfPQ_fR-46ASXf-*_e(WTDb%t7xAnNG zT^qB>=ybVH=HzYOmf@{=UKWMEAo?Si7YO}J1(6pxpNbGt3-baYq!#7{LP#yl3xtqb zs`7%85FN2c2n8p&FSV+?kR^P_4x#IcEL~+wgg>MzF9e^ecaRZ6b%p5jD2r53l^2rW zgR5*7i4dxr2>69y-nCPe7nIUUd+D_-UqA?jC`zHyUUp5(k*aVyQ-reYYAB_CpO@n_ ztOl2yAXHaLg5Y-8NX=U!{Jv}W@)IJv95zm)(drB=%T?{jgb)gK6bO<1f>)OQ)Xs+2 zkQV{6B*{`R%a##Bb)Bm53xp7YYi(cfdZXTCW?1&(ZubbGx`9$k3GsPcqBoeC)ByE) z!I;A9Yjt{MF+!*=U>JtsIAd72me;#0?ZNG$thPU53=7AwKg5bY07xpwsDw+%QBQ?RprlW)9ww8q z*KK{LO`SY->SPTr8b<#J{C^hebi8&@kM=_b5A1wvryV;p`t<5Wh|F-B1@FI0DNu>J zolZH}<;e(L6U>WHCrR>!Q%7xOMKRHFJ-c}WEZ z+O$qxxpI}=e(6AVg#KEX7om<&>gHoLoaRi<$=uvCt_r)$ELt2LP20>yfO(9Xs8+bm4-BAD^6g zAoF*^;{ADZ?i$oz5JZMycwW11-Uyq$ADJ=&8 zmSG0;?O9%K#k}}e!Mq4v5n9n`pwZEdDUmbx?(uj%^`fH7%4|J)_egG%B1`o2sWUpg zanH`}U3&D)J9VtIw8mMIc5PbSHK_lJv^BF{ozJpt@9v%R&R(c~gNEY>rPW&;Gp0;T zPD-rG3zlKteQnmH>2m^kArsPP@QA9LtFSD~FpOe6g<%+uV<`nfNcC+a1LcHV_PHpO zVP1r;gC{L>X3iKlc3fn91DD%nG#Z_r%HhNBl|*vk=rKx3LR^C3=FSdlDXW3(BUuB7 z+#464aN@+t{Rj3ds=BFNYXDgH<5mD5glyZnx9U<%ROH9+yqc1nL1(xCoQ)cRTe&>&A zuh0K5AwKqzQTJ;&O;+}?aZf!Ts6RO=@q;&BY}g>4P%23>0Nr=s(3F|4biL(2^Im-3 zWHiXKyldaj&%N-vjHwawa6UD3$%Oh@Gov#V6_rV%sNtYtYz#~2k^Px3&wJy;cV5aq zuqWJXiiwRYEiMiVt4E2y(@pK}9&(4WB7jX>Gh|tQ{+Y)U8zfXOkB^J_@tdWM8zsIt z@4b~@e?R@{$7f8LsL^mN#|na2T5esuJZ;rC--m~X{d)8S0Bqj6bMf-DpAY@Y0K`T| zM@7~L0ES@_;$mk{pM2M#{)?BV?LT<9Z?A4kKYAMgcwU?K`Tubm%_Ebh|M$f=A|t|g z?fdzIPnRYqC9Yib{_#_%XT3UqMcUe^$OsIJIsw}kA&Oy`2Jx}Cbn2Lxm~=EdTb5;~ z(_!GXg3q__$PtIvpO_Hu7JWb^v&n3=mW7Acr?Q;6chBC;1IoGpib_h~Tlo1?dxQ!6Tct?Ih{j6;+{-g?SO88plVBd-%SjlopgSTCGO%OMcOF zy!dGAHXVBP?0NjuY1SOyv{gG%5GyJwqGMuodY)z3wHr1%U02_2ZNb9NH*MW9@zK%! zdvzPwug};?&+gcj*}ByYvP}Lv<%z&OEt@xuiHW-WIkkK~UrAXx0EmA7g@VG=7R^|W zEiJQ3lH4?y=~_nw+GMAs{=4ELX_3v z+OvBfp*oX6A0HK0XektZwD8QCoa`e_+O&E5jk!9#UT``toIdS!yBZ~@0+Cow8yBD8 z%*|8Qbx}_4*=PRyQiFuJJ)6E=IRDkwo%{1zjZ9=qQAywiOHoO1+q3~drSsCu#T0(4 z#8Onec*W`oV@LMs(IqA-(&=`8yyOc2aJfCV4;wRL*pMe48TJ0WIk{)g54dMEj(UbZ z%!?4^^oW~w9ZcVQxI?mG!c(*Bh1K)ADwC2^zFe`Sf4|$?cf8p{nbRkZSsZqU$7Q$M zQd_npvi$g?4}Jf`=3Tq@DXXidu&Bglw?{`s@;rY&zp!_A?Ykc?c6*eq5(-d#SLfi4 z!vX+cnC#=Ho_y{FyS>uoc2^IDRo|>z{q6ekV;=b5v`L*hwB3I&tJ-5I6sgJ!N-1s# zUMpCZjHJYdxjCnm zwE(<6`?bv!0N8Pvao28Sc&$inuTeWD~qFIv< zK3zsB^^5-0md$&2ztv`|ICAVnPVQL%xM%R~mZD;#!Ep5WDdlI>;FgZ(>uudSb$Qy?hq8|=pT8(4_w3Mt{rdLm z1^}WcuKs4-OK&XzfRm@sjC$;umuE~_ziKf62%@;;i?0EIQu_RhZ+tZWm8BoO?WnA5 z+x3p~`Iap|WegqIZ|J~&X=~Qcc;Ssnwc&&)7uOBz!eu>j& zY~S9qTbHni*!%*E*XLmv#xD|9%SA+lH*K6uC1&GK+tM?3E1$21VVKyMD6K|QQfBr0 z{fbM`QIR^HFD-+62@0jRF04OW3@x?HO_vX%Se`D%{<0lsu zl>k6ugZLl6TAIFN*R)x$0YE}rEX%ParRCC}JERWCZ zzPTSS2LP>BV>asUxodcM%P_$&0;L*_j>`TMr%tZ>_RE2H+}))^yKTGnkeVBrl+wVf znOCBemX%wTr2v8`S_+G+m#9yQfF{XFmZB1?%?F?;TVsm&lZvTa0&0lRu z7bVH zF95(87S0=t%2HHIG%OPlUeD*3oG!QW8bW_W^m;wcVD?E-)xH1#uiL5B8*o-NL`Z~| z*&WJKgw&wa>vk#&)lsejs0k%R5(H%_La46siy|dKjxear3jlCe+Og+}P@P5z31S+x z$~EK#0C*h1^m!0MA;Rql%xwqh+VVp33tnfXvJ@dy7kFKj!M%1-<=XNB00ghw=L&ML z1R+#gd@iTp`LnZWYUf9)^Lbo^5Mx+4;A{y(b%CeSA^QG;mvn;33jh#(9>3r-*N^11 zTIDr_P{@!azpKI)-0HF`sX9Nk22}KUWJ%KUI(#n!p-@8!@l-mzuF9bIK)OnASP@t3& zN(hk&T-*=wFZXBL7nh?%mH`*1A#|O(=J`~F(7zVu1wu$I%nO8&T9_9IA+<0s5JGBU zULb_j!n{BTsfBrg5K>DcNtYd6gAhVB(HM-l>o-D36^#ZLq$7mX!SM@(kXru_h@bQi TnZSkv00000NkvXXu0mjfKU1#7 literal 0 HcmV?d00001 diff --git a/docs/images/administrator_qbittorrent_settings.png b/docs/images/administrator_qbittorrent_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..4c95268d4715617a78fe3735ede91ed129b06bef GIT binary patch literal 32301 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 literal 0 HcmV?d00001 diff --git a/docs/images/administrator_settings.png b/docs/images/administrator_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..0f5cda601da5b88f4dc25763889fa485a317e239 GIT binary patch literal 20639 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
    Z|}GKetT!s4c_cc0qaV0|8#| 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 literal 0 HcmV?d00001 diff --git a/docs/images/torrent_info.png b/docs/images/torrent_info.png new file mode 100644 index 0000000000000000000000000000000000000000..737419676089551b1500b7fb780ed41a7ceab45d GIT binary patch literal 29234 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 z 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

    er;M>A=^<&47u^2AT9@-;aR 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 literal 0 HcmV?d00001 diff --git a/docs/index.md b/docs/index.md index 6695c48..8f43633 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,15 +8,16 @@ This means that you can add, remove, pause, resume, and delete torrents, as well A lot has changed from version 2, if you have any errors, please take a look [here](getting_started/migrating_to_v2). !!! -## What can this bot do? +## Features Here are some of the things you can do with QBittorrentBot: - **Add torrents**: You can add torrents to your qBittorrent download queue by sending magnet links or torrent files to the bot. - **List active downloads**: You can get a list of your current downloads, including their progress, status, and estimated completion time. - **Pause/Resume downloads**: You can pause or resume ongoing downloads with a single command. - **Delete torrents**: You can remove unwanted downloads from your queue with a single command. -- **Add/Remove categories**: You can categorize your torrents for better organization and accessibility. +- **Add/Remove/Edit categories**: You can categorize your torrents for better organization and accessibility. - **Edit settings**: You can edit user and torrent client settings directly from the bot +- **Check client connection**: You can check the connection with the client directly from the bot ## Benefits diff --git a/docs/screenshots/index.md b/docs/screenshots/index.md new file mode 100644 index 0000000..6a5e28c --- /dev/null +++ b/docs/screenshots/index.md @@ -0,0 +1,10 @@ +# Screenshots + +![Main Menu (Administrator view)](/images/administrator.png) + +![List and filter torrents](/images/list_torrents.png) +![View torrent info](/images/torrent_info.png) + +## Administrator section +![Administrator Settings](/images/administrator_settings.png) +![Edit Client Settings](/images/administrator_qbittorrent_settings.png) \ No newline at end of file diff --git a/docs/screenshots/index.yml b/docs/screenshots/index.yml new file mode 100644 index 0000000..0d38165 --- /dev/null +++ b/docs/screenshots/index.yml @@ -0,0 +1 @@ +order: -20 \ No newline at end of file diff --git a/src/bot/plugins/callbacks/category/category_callbacks.py b/src/bot/plugins/callbacks/category/category_callbacks.py index 99393eb..0ecf5d0 100644 --- a/src/bot/plugins/callbacks/category/category_callbacks.py +++ b/src/bot/plugins/callbacks/category/category_callbacks.py @@ -24,7 +24,7 @@ async def add_category_callback(client: Client, callback_query: CallbackQuery) - async def list_categories(client: Client, callback_query: CallbackQuery): buttons = [] - repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository = ClientRepo.get_client_manager(Configs.config.client.type) categories = repository.get_categories() if categories is None: @@ -50,7 +50,7 @@ async def list_categories(client: Client, callback_query: CallbackQuery): async def remove_category_callback(client: Client, callback_query: CallbackQuery) -> None: buttons = [[InlineKeyboardButton("🔙 Menu", "menu")]] - repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository = ClientRepo.get_client_manager(Configs.config.client.type) repository.remove_category(callback_query.data.split("#")[1]) await client.edit_message_text(callback_query.from_user.id, callback_query.message.id, @@ -72,7 +72,7 @@ async def modify_category_callback(client: Client, callback_query: CallbackQuery async def category(client: Client, callback_query: CallbackQuery) -> None: buttons = [] - repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository = ClientRepo.get_client_manager(Configs.config.client.type) categories = repository.get_categories() if categories is None: diff --git a/src/bot/plugins/callbacks/delete/delete_all_callbacks.py b/src/bot/plugins/callbacks/delete/delete_all_callbacks.py index e045711..1d7ed41 100644 --- a/src/bot/plugins/callbacks/delete/delete_all_callbacks.py +++ b/src/bot/plugins/callbacks/delete/delete_all_callbacks.py @@ -18,7 +18,7 @@ async def delete_all_callback(client: Client, callback_query: CallbackQuery) -> @Client.on_callback_query(custom_filters.delete_all_no_data_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def delete_all_with_no_data_callback(client: Client, callback_query: CallbackQuery) -> None: - repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository = ClientRepo.get_client_manager(Configs.config.client.type) repository.delete_all_no_data() await client.answer_callback_query(callback_query.id, "Deleted only torrents") @@ -27,7 +27,7 @@ async def delete_all_with_no_data_callback(client: Client, callback_query: Callb @Client.on_callback_query(custom_filters.delete_all_data_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def delete_all_with_data_callback(client: Client, callback_query: CallbackQuery) -> None: - repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository = ClientRepo.get_client_manager(Configs.config.client.type) repository.delete_all_data() await client.answer_callback_query(callback_query.id, "Deleted All+Torrents") diff --git a/src/bot/plugins/callbacks/delete/delete_single_callbacks.py b/src/bot/plugins/callbacks/delete/delete_single_callbacks.py index 27e34e8..f11c2ca 100644 --- a/src/bot/plugins/callbacks/delete/delete_single_callbacks.py +++ b/src/bot/plugins/callbacks/delete/delete_single_callbacks.py @@ -29,7 +29,7 @@ async def delete_no_data_callback(client: Client, callback_query: CallbackQuery) await list_active_torrents(client, callback_query.from_user.id, callback_query.message.id, "delete_one_no_data") else: - repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository = ClientRepo.get_client_manager(Configs.config.client.type) repository.delete_one_no_data(torrent_hash=callback_query.data.split("#")[1]) await send_menu(client, callback_query.message.id, callback_query.from_user.id) @@ -41,7 +41,7 @@ async def delete_with_data_callback(client: Client, callback_query: CallbackQuer await list_active_torrents(client, callback_query.from_user.id, callback_query.message.id, "delete_one_data") else: - repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository = ClientRepo.get_client_manager(Configs.config.client.type) repository.delete_one_data(torrent_hash=callback_query.data.split("#")[1]) await send_menu(client, callback_query.message.id, callback_query.from_user.id) diff --git a/src/bot/plugins/callbacks/pause_resume/pause_callbacks.py b/src/bot/plugins/callbacks/pause_resume/pause_callbacks.py index 526a840..362e3f8 100644 --- a/src/bot/plugins/callbacks/pause_resume/pause_callbacks.py +++ b/src/bot/plugins/callbacks/pause_resume/pause_callbacks.py @@ -9,7 +9,7 @@ @Client.on_callback_query(custom_filters.pause_all_filter & custom_filters.check_user_filter & (custom_filters.user_is_administrator | custom_filters.user_is_manager)) async def pause_all_callback(client: Client, callback_query: CallbackQuery) -> None: - repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository = ClientRepo.get_client_manager(Configs.config.client.type) repository.pause_all() await client.answer_callback_query(callback_query.id, "Paused all torrents") @@ -20,7 +20,7 @@ async def pause_callback(client: Client, callback_query: CallbackQuery) -> None: await list_active_torrents(client, callback_query.from_user.id, callback_query.message.id, "pause") else: - repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository = ClientRepo.get_client_manager(Configs.config.client.type) repository.pause(torrent_hash=callback_query.data.split("#")[1]) await callback_query.answer("Torrent Paused") await send_menu(client, callback_query.message.id, callback_query.from_user.id) diff --git a/src/bot/plugins/callbacks/pause_resume/resume_callbacks.py b/src/bot/plugins/callbacks/pause_resume/resume_callbacks.py index 65e1820..6a675fc 100644 --- a/src/bot/plugins/callbacks/pause_resume/resume_callbacks.py +++ b/src/bot/plugins/callbacks/pause_resume/resume_callbacks.py @@ -9,7 +9,7 @@ @Client.on_callback_query(custom_filters.resume_all_filter & custom_filters.check_user_filter & (custom_filters.user_is_administrator | custom_filters.user_is_manager)) async def resume_all_callback(client: Client, callback_query: CallbackQuery) -> None: - repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository = ClientRepo.get_client_manager(Configs.config.client.type) repository.resume_all() await client.answer_callback_query(callback_query.id, "Resumed all torrents") @@ -20,7 +20,7 @@ async def resume_callback(client: Client, callback_query: CallbackQuery) -> None await list_active_torrents(client, callback_query.from_user.id, callback_query.message.id, "resume") else: - repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository = ClientRepo.get_client_manager(Configs.config.client.type) repository.resume(torrent_hash=callback_query.data.split("#")[1]) await callback_query.answer("Torrent Resumed") await send_menu(client, callback_query.message.id, callback_query.from_user.id) diff --git a/src/bot/plugins/callbacks/settings/client_settings_callbacks.py b/src/bot/plugins/callbacks/settings/client_settings_callbacks.py index 14966b3..515bc30 100644 --- a/src/bot/plugins/callbacks/settings/client_settings_callbacks.py +++ b/src/bot/plugins/callbacks/settings/client_settings_callbacks.py @@ -10,7 +10,7 @@ @Client.on_callback_query(custom_filters.edit_client_settings_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def edit_client_settings_callback(client: Client, callback_query: CallbackQuery) -> None: - confs = '\n- '.join(iter([f"**{key.capitalize()}:** {item}" for key, item in Configs.config.clients.model_dump().items()])) + confs = '\n- '.join(iter([f"**{key.capitalize()}:** {item}" for key, item in Configs.config.client.model_dump().items()])) await callback_query.edit_message_text( f"Edit Qbittorrent Client Settings \n\n**Current Settings:**\n- {confs}", @@ -33,7 +33,7 @@ async def edit_client_settings_callback(client: Client, callback_query: Callback @Client.on_callback_query(custom_filters.check_connection_filter) async def check_connection_callback(client: Client, callback_query: CallbackQuery) -> None: try: - repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository = ClientRepo.get_client_manager(Configs.config.client.type) version = repository.check_connection() await callback_query.answer(f"✅ The connection works. QBittorrent version: {version}", show_alert=True) @@ -47,7 +47,7 @@ async def list_client_settings_callback(client: Client, callback_query: Callback fields = [ [InlineKeyboardButton(f"Edit {key.replace('_', ' ').capitalize()}", f"edit_clt#{key}-{item.annotation}")] - for key, item in Configs.config.clients.model_fields.items() + for key, item in Configs.config.client.model_fields.items() ] await callback_query.edit_message_text( diff --git a/src/bot/plugins/callbacks/torrent_info.py b/src/bot/plugins/callbacks/torrent_info.py index 8124d4f..11be033 100644 --- a/src/bot/plugins/callbacks/torrent_info.py +++ b/src/bot/plugins/callbacks/torrent_info.py @@ -11,7 +11,7 @@ @Client.on_callback_query(custom_filters.torrentInfo_filter & custom_filters.check_user_filter) async def torrent_info_callback(client: Client, callback_query: CallbackQuery) -> None: - repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository = ClientRepo.get_client_manager(Configs.config.client.type) torrent = repository.get_torrent_info(callback_query.data.split("#")[1]) text = f"{torrent.name}\n" diff --git a/src/bot/plugins/common.py b/src/bot/plugins/common.py index 18acf3e..80ea3b8 100644 --- a/src/bot/plugins/common.py +++ b/src/bot/plugins/common.py @@ -42,7 +42,7 @@ async def send_menu(client: Client, message_id: int, chat_id: int) -> None: async def list_active_torrents(client: Client, chat_id, message_id, callback: Optional[str] = None, status_filter: str = None) -> None: - repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository = ClientRepo.get_client_manager(Configs.config.client.type) torrents = repository.get_torrent_info(status_filter=status_filter) def render_categories_buttons(): diff --git a/src/bot/plugins/on_message.py b/src/bot/plugins/on_message.py index 1b54b1d..303a42e 100644 --- a/src/bot/plugins/on_message.py +++ b/src/bot/plugins/on_message.py @@ -23,7 +23,7 @@ async def on_text(client: Client, message: Message) -> None: magnet_link = message.text.split("\n") category = db_management.read_support(message.from_user.id).split("#")[1] - repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository = ClientRepo.get_client_manager(Configs.config.client.type) repository.add_magnet( magnet_link=magnet_link, category=category @@ -42,7 +42,7 @@ async def on_text(client: Client, message: Message) -> None: category = db_management.read_support(message.from_user.id).split("#")[1] await message.download(name) - repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository = ClientRepo.get_client_manager(Configs.config.client.type) repository.add_torrent(file_name=name, category=category) await send_menu(client, message.id, message.from_user.id) @@ -59,7 +59,7 @@ async def on_text(client: Client, message: Message) -> None: if os.path.exists(message.text): name = db_management.read_support(message.from_user.id).split("#")[1] - repository = ClientRepo.get_client_manager(Configs.config.clients.type) + repository = ClientRepo.get_client_manager(Configs.config.client.type) if "modify" in action: repository.edit_category(name=name, save_path=message.text) @@ -110,7 +110,7 @@ async def on_text(client: Client, message: Message) -> None: try: new_value = data_type(message.text) - setattr(Configs.config.clients, field_to_edit, new_value) + setattr(Configs.config.client, field_to_edit, new_value) Configs.update_config(Configs.config) Configs.reload_config() logger.debug(f"Updating Client field \"{field_to_edit}\" to \"{new_value}\"") diff --git a/src/client_manager/qbittorrent_manager.py b/src/client_manager/qbittorrent_manager.py index f3f1c41..6d4b598 100644 --- a/src/client_manager/qbittorrent_manager.py +++ b/src/client_manager/qbittorrent_manager.py @@ -4,7 +4,6 @@ from typing import Union, List from .client_manager import ClientManager -BOT_CONFIGS = Configs.config logger = logging.getLogger(__name__) @@ -14,7 +13,7 @@ def add_magnet(cls, magnet_link: Union[str, List[str]], category: str = None) -> if category == "None": category = None - with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + with qbittorrentapi.Client(**Configs.config.client.connection_string) as qbt_client: logger.debug(f"Adding magnet with category {category}") qbt_client.torrents_add(urls=magnet_link, category=category) @@ -24,7 +23,7 @@ def add_torrent(cls, file_name: str, category: str = None) -> None: category = None try: - with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + with qbittorrentapi.Client(**Configs.config.client.connection_string) as qbt_client: logger.debug(f"Adding torrent with category {category}") qbt_client.torrents_add(torrent_files=file_name, category=category) @@ -34,31 +33,31 @@ def add_torrent(cls, file_name: str, category: str = None) -> None: @classmethod def resume_all(cls) -> None: logger.debug("Resuming all torrents") - with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + with qbittorrentapi.Client(**Configs.config.client.connection_string) as qbt_client: qbt_client.torrents.resume.all() @classmethod def pause_all(cls) -> None: logger.debug("Pausing all torrents") - with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + with qbittorrentapi.Client(**Configs.config.client.connection_string) as qbt_client: qbt_client.torrents.pause.all() @classmethod def resume(cls, torrent_hash: str) -> None: logger.debug(f"Resuming torrent with has {torrent_hash}") - with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + with qbittorrentapi.Client(**Configs.config.client.connection_string) as qbt_client: qbt_client.torrents_resume(torrent_hashes=torrent_hash) @classmethod def pause(cls, torrent_hash: str) -> None: logger.debug(f"Pausing torrent with hash {torrent_hash}") - with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + with qbittorrentapi.Client(**Configs.config.client.connection_string) as qbt_client: qbt_client.torrents_pause(torrent_hashes=torrent_hash) @classmethod def delete_one_no_data(cls, torrent_hash: str) -> None: logger.debug(f"Deleting torrent with hash {torrent_hash} without removing files") - with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + with qbittorrentapi.Client(**Configs.config.client.connection_string) as qbt_client: qbt_client.torrents_delete( delete_files=False, torrent_hashes=torrent_hash @@ -67,7 +66,7 @@ def delete_one_no_data(cls, torrent_hash: str) -> None: @classmethod def delete_one_data(cls, torrent_hash: str) -> None: logger.debug(f"Deleting torrent with hash {torrent_hash} + removing files") - with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + with qbittorrentapi.Client(**Configs.config.client.connection_string) as qbt_client: qbt_client.torrents_delete( delete_files=True, torrent_hashes=torrent_hash @@ -76,20 +75,20 @@ def delete_one_data(cls, torrent_hash: str) -> None: @classmethod def delete_all_no_data(cls) -> None: logger.debug(f"Deleting all torrents") - with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + with qbittorrentapi.Client(**Configs.config.client.connection_string) as qbt_client: for i in qbt_client.torrents_info(): qbt_client.torrents_delete(delete_files=False, hashes=i.hash) @classmethod def delete_all_data(cls) -> None: logger.debug(f"Deleting all torrent + files") - with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + with qbittorrentapi.Client(**Configs.config.client.connection_string) as qbt_client: for i in qbt_client.torrents_info(): qbt_client.torrents_delete(delete_files=True, hashes=i.hash) @classmethod def get_categories(cls): - with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + with qbittorrentapi.Client(**Configs.config.client.connection_string) as qbt_client: categories = qbt_client.torrent_categories.categories if len(categories) > 0: return categories @@ -101,10 +100,10 @@ def get_categories(cls): def get_torrent_info(cls, torrent_hash: str = None, status_filter: str = None): if torrent_hash is None: logger.debug("Getting torrents infos") - with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + with qbittorrentapi.Client(**Configs.config.client.connection_string) as qbt_client: return qbt_client.torrents_info(status_filter=status_filter) logger.debug(f"Getting infos for torrent with hash {torrent_hash}") - with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + with qbittorrentapi.Client(**Configs.config.client.connection_string) as qbt_client: return next( iter( qbt_client.torrents_info(status_filter=status_filter, torrent_hashes=torrent_hash) @@ -114,7 +113,7 @@ def get_torrent_info(cls, torrent_hash: str = None, status_filter: str = None): @classmethod def edit_category(cls, name: str, save_path: str) -> None: logger.debug(f"Editing category {name}, new save path: {save_path}") - with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + with qbittorrentapi.Client(**Configs.config.client.connection_string) as qbt_client: qbt_client.torrents_edit_category( name=name, save_path=save_path @@ -123,7 +122,7 @@ def edit_category(cls, name: str, save_path: str) -> None: @classmethod def create_category(cls, name: str, save_path: str) -> None: logger.debug(f"Creating new category {name} with save path: {save_path}") - with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + with qbittorrentapi.Client(**Configs.config.client.connection_string) as qbt_client: qbt_client.torrents_create_category( name=name, save_path=save_path @@ -132,11 +131,11 @@ def create_category(cls, name: str, save_path: str) -> None: @classmethod def remove_category(cls, name: str) -> None: logger.debug(f"Removing category {name}") - with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + with qbittorrentapi.Client(**Configs.config.client.connection_string) as qbt_client: qbt_client.torrents_remove_categories(categories=name) @classmethod def check_connection(cls) -> str: logger.debug("Checking Qbt Connection") - with qbittorrentapi.Client(**BOT_CONFIGS.clients.connection_string) as qbt_client: + with qbittorrentapi.Client(**Configs.config.client.connection_string) as qbt_client: return qbt_client.app.version From ea85819c536981f261294752cbbd82a5deff30b7 Mon Sep 17 00:00:00 2001 From: ch3p4ll3 Date: Sun, 17 Dec 2023 20:30:01 +0100 Subject: [PATCH 20/25] export torrent --- src/bot/custom_filters.py | 1 + src/bot/plugins/callbacks/torrent_info.py | 32 ++++++++++++++++++++--- src/client_manager/client_manager.py | 6 +++++ src/client_manager/qbittorrent_manager.py | 13 +++++++++ 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/bot/custom_filters.py b/src/bot/custom_filters.py index 4c9d131..1a6d65a 100644 --- a/src/bot/custom_filters.py +++ b/src/bot/custom_filters.py @@ -53,6 +53,7 @@ # Other +export_filter = filters.create(lambda _, __, query: query.data.startswith("export")) torrentInfo_filter = filters.create(lambda _, __, query: query.data.startswith("torrentInfo")) menu_filter = filters.create(lambda _, __, query: query.data == "menu") list_filter = filters.create(lambda _, __, query: query.data.startswith("list")) diff --git a/src/bot/plugins/callbacks/torrent_info.py b/src/bot/plugins/callbacks/torrent_info.py index 11be033..79a7821 100644 --- a/src/bot/plugins/callbacks/torrent_info.py +++ b/src/bot/plugins/callbacks/torrent_info.py @@ -34,10 +34,34 @@ async def torrent_info_callback(client: Client, callback_query: CallbackQuery) - if torrent.category: text += f"**Category:** {torrent.category}\n" - buttons = [[InlineKeyboardButton("⏸ Pause", f"pause#{callback_query.data.split('#')[1]}")], - [InlineKeyboardButton("▶️ Resume", f"resume#{callback_query.data.split('#')[1]}")], - [InlineKeyboardButton("🗑 Delete", f"delete_one#{callback_query.data.split('#')[1]}")], - [InlineKeyboardButton("🔙 Menu", "menu")]] + buttons = [ + [ + InlineKeyboardButton("💾 Export torrent", f"export#{callback_query.data.split('#')[1]}") + ], + [ + InlineKeyboardButton("⏸ Pause", f"pause#{callback_query.data.split('#')[1]}") + ], + [ + InlineKeyboardButton("▶️ Resume", f"resume#{callback_query.data.split('#')[1]}") + ], + [ + InlineKeyboardButton("🗑 Delete", f"delete_one#{callback_query.data.split('#')[1]}") + ], + [ + InlineKeyboardButton("🔙 Menu", "menu") + ] + ] await client.edit_message_text(callback_query.from_user.id, callback_query.message.id, text=text, reply_markup=InlineKeyboardMarkup(buttons)) + + +@Client.on_callback_query(custom_filters.export_filter & custom_filters.check_user_filter) +async def export_callback(client: Client, callback_query: CallbackQuery) -> None: + repository = ClientRepo.get_client_manager(Configs.config.client.type) + file_bytes = repository.export_torrent(torrent_hash=callback_query.data.split("#")[1]) + + await client.send_document( + callback_query.from_user.id, + file_bytes + ) diff --git a/src/client_manager/client_manager.py b/src/client_manager/client_manager.py index 5658224..bc94577 100644 --- a/src/client_manager/client_manager.py +++ b/src/client_manager/client_manager.py @@ -1,3 +1,4 @@ +from io import BytesIO from typing import Union, List from abc import ABC @@ -82,3 +83,8 @@ def remove_category(cls, name: str) -> None: def check_connection(cls) -> str: """Check connection with Client""" raise NotImplementedError + + @classmethod + def export_torrent(cls, torrent_hash: str) -> BytesIO: + """Export a .torrent file for the torrent.""" + raise NotImplementedError diff --git a/src/client_manager/qbittorrent_manager.py b/src/client_manager/qbittorrent_manager.py index 6d4b598..295c43a 100644 --- a/src/client_manager/qbittorrent_manager.py +++ b/src/client_manager/qbittorrent_manager.py @@ -1,3 +1,5 @@ +from io import BytesIO + import qbittorrentapi import logging from src.configs import Configs @@ -139,3 +141,14 @@ def check_connection(cls) -> str: logger.debug("Checking Qbt Connection") with qbittorrentapi.Client(**Configs.config.client.connection_string) as qbt_client: return qbt_client.app.version + + @classmethod + def export_torrent(cls, torrent_hash: str) -> BytesIO: + logger.debug(f"Exporting torrent with hash {torrent_hash}") + with qbittorrentapi.Client(**Configs.config.client.connection_string) as qbt_client: + torrent_bytes = qbt_client.torrents_export(torrent_hash=torrent_hash) + torrent_name = qbt_client.torrents_info(torrent_hashes=torrent_hash)[0].name + + file_to_return = BytesIO(torrent_bytes) + file_to_return.name = f"{torrent_name}.torrent" + return file_to_return From 1ee46a709fdb637d3c0bfebfb70c44840b19299d Mon Sep 17 00:00:00 2001 From: Mattia Vidoni Date: Mon, 18 Dec 2023 19:58:43 +0100 Subject: [PATCH 21/25] Update LICENSE --- LICENSE | 695 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 674 insertions(+), 21 deletions(-) diff --git a/LICENSE b/LICENSE index fc7dccd..f288702 100755 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,674 @@ -MIT License - -Copyright (c) 2019 Mattia - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. From b66412a9b962613fc6b12d130fddbc1b838fbefd Mon Sep 17 00:00:00 2001 From: ch3p4ll3 Date: Mon, 18 Dec 2023 20:30:09 +0100 Subject: [PATCH 22/25] update docs --- docs/advanced/add_entries_configuration.md | 35 ++++++++++++ docs/advanced/add_new_client_manager.md | 3 + docs/advanced/manager_user_roles.md | 3 + docs/faq.md | 64 ++++++++++++++++++++++ 4 files changed, 105 insertions(+) create mode 100644 docs/advanced/add_entries_configuration.md create mode 100644 docs/faq.md diff --git a/docs/advanced/add_entries_configuration.md b/docs/advanced/add_entries_configuration.md new file mode 100644 index 0000000..3981114 --- /dev/null +++ b/docs/advanced/add_entries_configuration.md @@ -0,0 +1,35 @@ +--- +order: -10 +--- +# Add new entries in configuration file + +Adding a new entry to the QBittorrentBot configuration file involves several steps: + +- **Clone the repository**: `git clone https://github.com/ch3p4ll3/QBittorrentBot.git` + +- **Navigate to the folder**: `src/configs` + +- **Modify the pydantic class**: + Identify the pydantic class where the new entry should be added. + Add a new attribute to the class to represent the new entry. + +- **Create a validation function (if necessary)**: + If the new entry requires additional validation beyond the type provided by pydantic, create a validation function. + The validation function should inspect the value of the new entry and check for any constraints or rules that need to be enforced. + +- **Add the new entry to the config file**: + Open the configuration file (usually `config.json`). + Add a new property to the configuration object for the new entry. + Set the value of the new property to the desired initial value. + +- **Update the convert_type_from_string function (if necessary)**: + If the new entry type requires a custom conversion from a string representation, add the conversion function to the `utils` file. + The function should take a string representation of the new entry type and return the corresponding data type. + +- **Update the bot code (if necessary)**: + If the new entry is being used by the bot code, update the relevant parts of the code to handle the new entry type and its values. + +- Build the docker image +- Start the docker container + +You can now use the bot with the new entry, have fun🥳 diff --git a/docs/advanced/add_new_client_manager.md b/docs/advanced/add_new_client_manager.md index 22701d3..0c67cd4 100644 --- a/docs/advanced/add_new_client_manager.md +++ b/docs/advanced/add_new_client_manager.md @@ -1,3 +1,6 @@ +--- +order: 0 +--- # Add new client manager Adding a new client manager to QBittorrentBot involves creating a new class that implements the `ClientManager` interface. This interface defines the methods that the bot uses to interact with the client, such as adding, removing, pausing, and resuming torrents. diff --git a/docs/advanced/manager_user_roles.md b/docs/advanced/manager_user_roles.md index 1323451..27020c2 100644 --- a/docs/advanced/manager_user_roles.md +++ b/docs/advanced/manager_user_roles.md @@ -1,3 +1,6 @@ +--- +order: -20 +--- # Managing users roles QBittorrentBot provides a user role management system to control access to different actions and functionalities within the bot. The system defines three roles: Reader, Manager, and Admin, each with increasing permissions and capabilities. diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000..36d47ef --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,64 @@ +--- +order: -20 +--- +# FAQ + +### What is QBittorrentBot? + +QBittorrentBot is a Telegram bot that allows you to control your qBittorrent downloads from within the Telegram app. It can add torrents, manage your torrent list, and much more. + +### What are the benefits of using QBittorrentBot? + +There are several benefits to using QBittorrentBot, including: + +* **Convenience:** You can control your torrents from anywhere, without having to open the qBittorrent app. +* **Efficiency:** You can manage your torrents without switching between apps. +* **Organization:** You can categorize your torrents for better organization and accessibility. +* **Docker Support:** You can deploy and manage the bot seamlessly using Docker containers. + +### How do I add QBittorrentBot to my Telegram account? + +Follow this guide to start using QBittorrentBot +[!ref Getting Started](getting_started) + +### How do I edit the configuration for the QBittorrentBot? + +The QBittorrentBot configuration file is located at config.json. This file stores the bot's settings, such as the connection details for the qBittorrent client, the API IDs and hashes, and the list of authorized users. To edit the configuration file, you can open it in a text editor and make the necessary changes. + +### How do I check the status of my torrents? + +You can check the status of your torrents by using the list torrents button. This command will display a list of all your active torrents, including their name, status, progress, and download/upload speed. + +### What is the difference between a magnet link and a torrent file? + +A magnet link is a URI scheme that allows you to download a torrent without having to download the entire torrent file. A torrent file is a file that contains metadata about the torrent, such as the filename, file size, and number of pieces. + +### What are the different user roles available in QBittorrentBot? + +QBittorrentBot supports three user roles: Reader, Manager, and Admin. Each role has different permissions, as follows: + +* **Reader:** Can view lists of active torrents and view individual torrent details. +* **Manager:** Can perform all Reader actions, plus add/edit categories, set torrent priorities, and pause/resume downloads. +* **Admin:** Can perform all Manager actions, plus remove torrents, remove categories, and edit configs. + +### How do I change the user role for a user? + +You can change the user role for a user by editing the `config.json` file. Open the file and find the user's entry. Change the `role` field to the desired role (e.g., "reader", "manager", or "admin"). Save the file and restart the bot or, if you are an admin you can reload the configuration from the bot. + +### How do I install QBittorrentBot on my server? + +You can install QBittorrentBot on your server using Docker. First, install Docker on your server. Then, create a Docker image from the QBittorrentBot Dockerfile. Finally, run the Docker image to start the bot. + +### How do I add a new manager to my QBittorrentBot? + +Please follow this guide +[!ref Add new client manager](advanced/add_new_client_manager) + +### How do I add a new entry to the QBittorrentBot configuration file? + +Please follow this guide +[!ref Add new entries in configuration file](advanced/add_entries_configuration) + +### How do I contribute to the development of QBittorrentBot? + +QBittorrentBot is an open-source project. You can contribute to the development by reporting bugs, suggesting improvements, or submitting pull requests. The project's code is hosted on [GitHub](https://github.com/ch3p4ll3/QBittorrentBot). \ No newline at end of file From 40d24b56515c7e705470a43827eb5f2732486fcb Mon Sep 17 00:00:00 2001 From: ch3p4ll3 Date: Wed, 20 Dec 2023 19:17:27 +0100 Subject: [PATCH 23/25] regex filters --- src/bot/custom_filters.py | 70 +++++++++---------- .../callbacks/pause_resume/pause_callbacks.py | 1 - .../pause_resume/resume_callbacks.py | 1 - 3 files changed, 35 insertions(+), 37 deletions(-) diff --git a/src/bot/custom_filters.py b/src/bot/custom_filters.py index 1a6d65a..3077ead 100644 --- a/src/bot/custom_filters.py +++ b/src/bot/custom_filters.py @@ -12,49 +12,49 @@ user_is_administrator = filters.create(lambda _, __, query: get_user_from_config(query.from_user.id).role == UserRolesEnum.Administrator) # Categories filters -menu_category_filter = filters.create(lambda _, __, query: query.data == "menu_categories") -add_category_filter = filters.create(lambda _, __, query: query.data == "add_category") -remove_category_filter = filters.create(lambda _, __, query: query.data.startswith("remove_category")) -modify_category_filter = filters.create(lambda _, __, query: query.data.startswith("modify_category")) -category_filter = filters.create(lambda _, __, query: query.data.startswith("category")) -select_category_filter = filters.create(lambda _, __, query: query.data.startswith("select_category")) +menu_category_filter = filters.regex(r"^menu_categories$") +add_category_filter = filters.regex(r"^add_category$") +remove_category_filter = filters.regex(r'^remove_category(#.+|$)?$') +modify_category_filter = filters.regex(r'^modify_category(#.+|$)?$') +category_filter = filters.regex(r'^category(#.+|$)?$') +select_category_filter = filters.regex(r'^select_category(#.+|$)?$') # Add filters -add_magnet_filter = filters.create(lambda _, __, query: query.data.startswith("add_magnet")) -add_torrent_filter = filters.create(lambda _, __, query: query.data.startswith("add_torrent")) +add_magnet_filter = filters.regex(r'^add_magnet(#.+|$)?$') +add_torrent_filter = filters.regex(r'^add_torrent(#.+|$)?$') # Pause/Resume filters -menu_pause_resume_filter = filters.create(lambda _, __, query: query.data == "menu_pause_resume") -pause_all_filter = filters.create(lambda _, __, query: query.data.startswith("pause_all")) -resume_all_filter = filters.create(lambda _, __, query: query.data.startswith("resume_all")) -pause_filter = filters.create(lambda _, __, query: query.data.startswith("pause")) -resume_filter = filters.create(lambda _, __, query: query.data.startswith("resume")) +menu_pause_resume_filter = filters.regex(r"^menu_pause_resume$") +pause_all_filter = filters.regex(r"^pause_all$") +resume_all_filter = filters.regex(r"^resume_all$") +pause_filter = filters.regex(r'^pause(#.+|$)?$') +resume_filter = filters.regex(r'^resume(#.+|$)?$') # Delete filers -menu_delete_filter = filters.create(lambda _, __, query: query.data == "menu_delete") -delete_one_filter = filters.create(lambda _, __, query: query.data.split("#")[0] == "delete_one") -delete_one_no_data_filter = filters.create(lambda _, __, query: query.data.startswith("delete_one_no_data")) -delete_one_data_filter = filters.create(lambda _, __, query: query.data.startswith("delete_one_data")) -delete_all_filter = filters.create(lambda _, __, query: query.data.split("#")[0] == "delete_all") -delete_all_no_data_filter = filters.create(lambda _, __, query: query.data.startswith("delete_all_no_data")) -delete_all_data_filter = filters.create(lambda _, __, query: query.data.startswith("delete_all_data")) +menu_delete_filter = filters.regex(r"^menu_delete$") +delete_one_filter = filters.regex(r'^delete_one(#.+|$)?$') +delete_one_no_data_filter = filters.regex(r'^delete_one_no_data(#.+|$)?$') +delete_one_data_filter = filters.regex(r'^delete_one_data(#.+|$)?$') +delete_all_filter = filters.regex(r'^delete_all(#.+|$)?$') +delete_all_no_data_filter = filters.regex(r"^delete_all_no_data$") +delete_all_data_filter = filters.regex(r"^delete_all_data$") # Settings filters -settings_filter = filters.create(lambda _, __, query: query.data == "settings") -get_users_filter = filters.create(lambda _, __, query: query.data == "get_users") -user_info_filter = filters.create(lambda _, __, query: query.data.startswith("user_info")) -edit_user_filter = filters.create(lambda _, __, query: query.data.startswith("edit_user")) -toggle_user_var_filter = filters.create(lambda _, __, query: query.data.startswith("toggle_user_var")) -edit_client_settings_filter = filters.create(lambda _, __, query: query.data == "edit_client") -list_client_settings_filter = filters.create(lambda _, __, query: query.data == "lst_client") -check_connection_filter = filters.create(lambda _, __, query: query.data == "check_connection") -edit_client_setting_filter = filters.create(lambda _, __, query: query.data.startswith("edit_clt")) -reload_settings_filter = filters.create(lambda _, __, query: query.data == "reload_settings") +settings_filter = filters.regex(r"^settings$") +get_users_filter = filters.regex(r"^get_users$") +user_info_filter = filters.regex(r'^user_info(#.+|$)?$') +edit_user_filter = filters.regex(r'^edit_user(#.+|$)?$') +toggle_user_var_filter = filters.regex(r'^toggle_user_var(#.+|$)?$') +edit_client_settings_filter = filters.regex(r"^edit_client$") +list_client_settings_filter = filters.regex(r"^lst_client$") +check_connection_filter = filters.regex(r"^check_connection$") +edit_client_setting_filter = filters.regex(r'^edit_clt(#.+|$)?$') +reload_settings_filter = filters.regex(r"^reload_settings$") # Other -export_filter = filters.create(lambda _, __, query: query.data.startswith("export")) -torrentInfo_filter = filters.create(lambda _, __, query: query.data.startswith("torrentInfo")) -menu_filter = filters.create(lambda _, __, query: query.data == "menu") -list_filter = filters.create(lambda _, __, query: query.data.startswith("list")) -list_by_status_filter = filters.create(lambda _, __, query: query.data.split("#")[0] == "by_status_list") +export_filter = filters.regex(r'^export(#.+|$)?$') +torrentInfo_filter = filters.regex(r'^torrentInfo(#.+|$)?$') +menu_filter = filters.regex(r"^menu$") +list_filter = filters.regex(r"^list$") +list_by_status_filter = filters.regex(r'^by_status_list(#.+|$)?$') diff --git a/src/bot/plugins/callbacks/pause_resume/pause_callbacks.py b/src/bot/plugins/callbacks/pause_resume/pause_callbacks.py index 362e3f8..263a26d 100644 --- a/src/bot/plugins/callbacks/pause_resume/pause_callbacks.py +++ b/src/bot/plugins/callbacks/pause_resume/pause_callbacks.py @@ -23,4 +23,3 @@ async def pause_callback(client: Client, callback_query: CallbackQuery) -> None: repository = ClientRepo.get_client_manager(Configs.config.client.type) repository.pause(torrent_hash=callback_query.data.split("#")[1]) await callback_query.answer("Torrent Paused") - await send_menu(client, callback_query.message.id, callback_query.from_user.id) diff --git a/src/bot/plugins/callbacks/pause_resume/resume_callbacks.py b/src/bot/plugins/callbacks/pause_resume/resume_callbacks.py index 6a675fc..8dd1428 100644 --- a/src/bot/plugins/callbacks/pause_resume/resume_callbacks.py +++ b/src/bot/plugins/callbacks/pause_resume/resume_callbacks.py @@ -23,4 +23,3 @@ async def resume_callback(client: Client, callback_query: CallbackQuery) -> None repository = ClientRepo.get_client_manager(Configs.config.client.type) repository.resume(torrent_hash=callback_query.data.split("#")[1]) await callback_query.answer("Torrent Resumed") - await send_menu(client, callback_query.message.id, callback_query.from_user.id) From 912f1baeb30fec9386bd7a046a1a73ff7b45affb Mon Sep 17 00:00:00 2001 From: ch3p4ll3 Date: Wed, 20 Dec 2023 19:25:18 +0100 Subject: [PATCH 24/25] check torrent add status --- src/bot/plugins/commands.py | 2 ++ src/bot/plugins/on_message.py | 12 ++++++++++-- src/client_manager/client_manager.py | 8 ++++---- src/client_manager/qbittorrent_manager.py | 11 +++++++---- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/bot/plugins/commands.py b/src/bot/plugins/commands.py index acae72c..13ace3c 100644 --- a/src/bot/plugins/commands.py +++ b/src/bot/plugins/commands.py @@ -3,6 +3,7 @@ import psutil from .. import custom_filters +from ...db_management import write_support from ...utils import convert_size from ...configs import Configs from .common import send_menu @@ -18,6 +19,7 @@ async def access_denied_message(client: Client, message: Message) -> None: @Client.on_message(filters.command("start") & custom_filters.check_user_filter) async def start_command(client: Client, message: Message) -> None: """Start the bot.""" + write_support("None", message.chat.id) await send_menu(client, message.id, message.chat.id) diff --git a/src/bot/plugins/on_message.py b/src/bot/plugins/on_message.py index 303a42e..37e045f 100644 --- a/src/bot/plugins/on_message.py +++ b/src/bot/plugins/on_message.py @@ -24,11 +24,15 @@ async def on_text(client: Client, message: Message) -> None: category = db_management.read_support(message.from_user.id).split("#")[1] repository = ClientRepo.get_client_manager(Configs.config.client.type) - repository.add_magnet( + response = repository.add_magnet( magnet_link=magnet_link, category=category ) + if not response: + await message.reply_text("Unable to add magnet link") + return + await send_menu(client, message.id, message.from_user.id) db_management.write_support("None", message.from_user.id) @@ -43,7 +47,11 @@ async def on_text(client: Client, message: Message) -> None: await message.download(name) repository = ClientRepo.get_client_manager(Configs.config.client.type) - repository.add_torrent(file_name=name, category=category) + response = repository.add_torrent(file_name=name, category=category) + + if not response: + await message.reply_text("Unable to add magnet link") + return await send_menu(client, message.id, message.from_user.id) db_management.write_support("None", message.from_user.id) diff --git a/src/client_manager/client_manager.py b/src/client_manager/client_manager.py index bc94577..ae61095 100644 --- a/src/client_manager/client_manager.py +++ b/src/client_manager/client_manager.py @@ -5,13 +5,13 @@ class ClientManager(ABC): @classmethod - def add_magnet(cls, magnet_link: Union[str, List[str]], category: str = None) -> None: - """Add one or multiple magnet links with or without a category""" + def add_magnet(cls, magnet_link: Union[str, List[str]], category: str = None) -> bool: + """Add one or multiple magnet links with or without a category, return true if successful""" raise NotImplementedError @classmethod - def add_torrent(cls, file_name: str, category: str = None) -> None: - """Add one torrent file with or without a category""" + def add_torrent(cls, file_name: str, category: str = None) -> bool: + """Add one torrent file with or without a category, return true if successful""" raise NotImplementedError @classmethod diff --git a/src/client_manager/qbittorrent_manager.py b/src/client_manager/qbittorrent_manager.py index 295c43a..a3670e7 100644 --- a/src/client_manager/qbittorrent_manager.py +++ b/src/client_manager/qbittorrent_manager.py @@ -11,23 +11,26 @@ class QbittorrentManager(ClientManager): @classmethod - def add_magnet(cls, magnet_link: Union[str, List[str]], category: str = None) -> None: + def add_magnet(cls, magnet_link: Union[str, List[str]], category: str = None) -> bool: if category == "None": category = None with qbittorrentapi.Client(**Configs.config.client.connection_string) as qbt_client: logger.debug(f"Adding magnet with category {category}") - qbt_client.torrents_add(urls=magnet_link, category=category) + result = qbt_client.torrents_add(urls=magnet_link, category=category) + + return result == "Ok." @classmethod - def add_torrent(cls, file_name: str, category: str = None) -> None: + def add_torrent(cls, file_name: str, category: str = None) -> bool: if category == "None": category = None try: with qbittorrentapi.Client(**Configs.config.client.connection_string) as qbt_client: logger.debug(f"Adding torrent with category {category}") - qbt_client.torrents_add(torrent_files=file_name, category=category) + result = qbt_client.torrents_add(torrent_files=file_name, category=category) + return result == "Ok." except qbittorrentapi.exceptions.UnsupportedMediaType415Error: pass From 5bd60da7c3d7a5e008d03ceb93aeec533119ee74 Mon Sep 17 00:00:00 2001 From: ch3p4ll3 Date: Wed, 20 Dec 2023 19:47:14 +0100 Subject: [PATCH 25/25] add toggle speed limit --- src/bot/custom_filters.py | 1 + .../settings/client_settings_callbacks.py | 44 +++++++++++++++++-- src/client_manager/client_manager.py | 10 +++++ src/client_manager/qbittorrent_manager.py | 11 +++++ 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/bot/custom_filters.py b/src/bot/custom_filters.py index 3077ead..3479187 100644 --- a/src/bot/custom_filters.py +++ b/src/bot/custom_filters.py @@ -50,6 +50,7 @@ check_connection_filter = filters.regex(r"^check_connection$") edit_client_setting_filter = filters.regex(r'^edit_clt(#.+|$)?$') reload_settings_filter = filters.regex(r"^reload_settings$") +toggle_speed_limit_filter = filters.regex(r"^toggle_speed_limit$") # Other diff --git a/src/bot/plugins/callbacks/settings/client_settings_callbacks.py b/src/bot/plugins/callbacks/settings/client_settings_callbacks.py index 515bc30..0c119b9 100644 --- a/src/bot/plugins/callbacks/settings/client_settings_callbacks.py +++ b/src/bot/plugins/callbacks/settings/client_settings_callbacks.py @@ -12,6 +12,41 @@ async def edit_client_settings_callback(client: Client, callback_query: CallbackQuery) -> None: confs = '\n- '.join(iter([f"**{key.capitalize()}:** {item}" for key, item in Configs.config.client.model_dump().items()])) + repository = ClientRepo.get_client_manager(Configs.config.client.type) + speed_limit = repository.get_speed_limit_mode() + + confs += f"\n\n**Speed Limit**: {'Enabled' if speed_limit else 'Disabled'}" + + await callback_query.edit_message_text( + f"Edit Qbittorrent Client Settings \n\n**Current Settings:**\n- {confs}", + reply_markup=InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton("📝 Edit Client Settings", "lst_client") + ], + [ + InlineKeyboardButton("🐢 Toggle Speed Limit", "toggle_speed_limit") + ], + [ + InlineKeyboardButton("✅ Check Client connection", "check_connection") + ], + [ + InlineKeyboardButton("🔙 Settings", "settings") + ] + ] + ) + ) + + +@Client.on_callback_query(custom_filters.toggle_speed_limit_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) +async def toggle_speed_limit_callback(client: Client, callback_query: CallbackQuery) -> None: + confs = '\n- '.join(iter([f"**{key.capitalize()}:** {item}" for key, item in Configs.config.client.model_dump().items()])) + + repository = ClientRepo.get_client_manager(Configs.config.client.type) + speed_limit = repository.toggle_speed_limit() + + confs += f"\n\n**Speed Limit**: {'Enabled' if speed_limit else 'Disabled'}" + await callback_query.edit_message_text( f"Edit Qbittorrent Client Settings \n\n**Current Settings:**\n- {confs}", reply_markup=InlineKeyboardMarkup( @@ -19,6 +54,9 @@ async def edit_client_settings_callback(client: Client, callback_query: Callback [ InlineKeyboardButton("📝 Edit Client Settings", "lst_client") ], + [ + InlineKeyboardButton("🐢 Toggle Speed Limit", "toggle_speed_limit") + ], [ InlineKeyboardButton("✅ Check Client connection", "check_connection") ], @@ -30,7 +68,7 @@ async def edit_client_settings_callback(client: Client, callback_query: Callback ) -@Client.on_callback_query(custom_filters.check_connection_filter) +@Client.on_callback_query(custom_filters.check_connection_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def check_connection_callback(client: Client, callback_query: CallbackQuery) -> None: try: repository = ClientRepo.get_client_manager(Configs.config.client.type) @@ -41,7 +79,7 @@ async def check_connection_callback(client: Client, callback_query: CallbackQuer await callback_query.answer("❌ Unable to establish connection with QBittorrent", show_alert=True) -@Client.on_callback_query(custom_filters.list_client_settings_filter) +@Client.on_callback_query(custom_filters.list_client_settings_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def list_client_settings_callback(client: Client, callback_query: CallbackQuery) -> None: # get all fields of the model dynamically fields = [ @@ -63,7 +101,7 @@ async def list_client_settings_callback(client: Client, callback_query: Callback ) -@Client.on_callback_query(custom_filters.edit_client_setting_filter) +@Client.on_callback_query(custom_filters.edit_client_setting_filter & custom_filters.check_user_filter & custom_filters.user_is_administrator) async def edit_client_setting_callback(client: Client, callback_query: CallbackQuery) -> None: data = callback_query.data.split("#")[1] field_to_edit = data.split("-")[0] diff --git a/src/client_manager/client_manager.py b/src/client_manager/client_manager.py index ae61095..be5633d 100644 --- a/src/client_manager/client_manager.py +++ b/src/client_manager/client_manager.py @@ -88,3 +88,13 @@ def check_connection(cls) -> str: def export_torrent(cls, torrent_hash: str) -> BytesIO: """Export a .torrent file for the torrent.""" raise NotImplementedError + + @classmethod + def get_speed_limit_mode(cls) -> bool: + """Get speed limit of the client, returns True if speed limit is active""" + raise NotImplementedError + + @classmethod + def toggle_speed_limit(cls) -> bool: + """Toggle speed limit of the client, returns True if speed limit is active""" + raise NotImplementedError diff --git a/src/client_manager/qbittorrent_manager.py b/src/client_manager/qbittorrent_manager.py index a3670e7..fe26d45 100644 --- a/src/client_manager/qbittorrent_manager.py +++ b/src/client_manager/qbittorrent_manager.py @@ -155,3 +155,14 @@ def export_torrent(cls, torrent_hash: str) -> BytesIO: file_to_return = BytesIO(torrent_bytes) file_to_return.name = f"{torrent_name}.torrent" return file_to_return + + @classmethod + def get_speed_limit_mode(cls) -> bool: + with qbittorrentapi.Client(**Configs.config.client.connection_string) as qbt_client: + return qbt_client.transfer.speedLimitsMode == "1" + + @classmethod + def toggle_speed_limit(cls) -> bool: + with qbittorrentapi.Client(**Configs.config.client.connection_string) as qbt_client: + qbt_client.transfer.setSpeedLimitsMode() + return qbt_client.transfer.speedLimitsMode == "1"