diff --git a/BotBase/database/query.py b/BotBase/database/query.py index 3a46776..6e87840 100644 --- a/BotBase/database/query.py +++ b/BotBase/database/query.py @@ -4,6 +4,25 @@ from ..config import DB_GET_USERS, DB_GET_USER, DB_RELPATH, DB_SET_USER, DB_GET_ import logging import time from types import FunctionType +import os + +def create_database(path: str, query: str): + if os.path.exists(path): + logging.warning(f"Database file exists at {path}, running query") + else: + logging.warning(f"No database found, creating it at {path}") + try: + database = sqlite3.connect(path) + except sqlite3.Error as connection_error: + logging.error(f"An error has occurred while connecting to database: {connection_error}") + else: + try: + with database: + cursor = database.cursor() + cursor.executescript(query) + cursor.close() + except sqlite3.Error as query_error: + logging.info(f"An error has occurred while executing query: {query_error}") def get_user(tg_id: int): diff --git a/BotBase/methods/safe_edit.py b/BotBase/methods/safe_edit.py index caacb02..a794241 100644 --- a/BotBase/methods/safe_edit.py +++ b/BotBase/methods/safe_edit.py @@ -1,44 +1,84 @@ from pyrogram.errors import RPCError, FloodWait import time import logging +from pyrogram import Client, CallbackQuery +from typing import Union -def edit_message_text(update, *args, **kwargs): - """Edits a message in a way that never triggers exceptions and logs errors""" +def edit_message_text(update: Union[CallbackQuery, Client], sleep: bool = True *args, **kwargs): + """Edits a message in a way that never triggers exceptions and logs errors + + :param update: The pyrogram.Client instance or pyrogram.CallbackQuery + object to call the method for + :type update: Union[Client, CallbackQuery] + :param sleep: If True, the default, the function will call time.sleep() + in case of a FloodWait exception and return the exception object + after the sleep is done, otherwise the ``FloodWait`` exception is returned + immediately + :returns: Whatever the called pyrogram method returns, or an exception if + the method call caused an error + """ try: return update.edit_message_text(*args, **kwargs) except FloodWait as fw: - logging.warning(f"FloodWait! Sleeping {fw.x} seconds") - time.sleep(fw.x) + logging.warning(f"FloodWait! A wait of {fw.x} seconds is required") + if sleep: + time.sleep(fw.x) + return fw except RPCError as generic_error: logging.error(f"An exception occurred: {generic_error}") - return False + return generic_error -def edit_message_caption(update, *args, **kwargs): - """Edits a message caption in a way that never triggers exceptions and logs errors""" +def edit_message_caption(update: Union[CallbackQuery, Client], sleep: bool = True, *args, **kwargs): + """Edits a message caption in a way that never triggers exceptions and logs errors + + :param update: The pyrogram.Client instance or pyrogram.CallbackQuery + object to call the method for + :type update: Union[Client, CallbackQuery] + :param sleep: If True, the default, the function will call time.sleep() + in case of a FloodWait exception and return the exception object + after the sleep is done, otherwise the ``FloodWait`` exception is returned + immediately + :returns: Whatever the called pyrogram method returns, or an exception if + the method call caused an error + """ try: return update.edit_message_caption(*args, **kwargs) except FloodWait as fw: - logging.warning(f"FloodWait! Sleeping {fw.x} seconds") - time.sleep(fw.x) + logging.warning(f"FloodWait! A wait of {fw.x} seconds is required") + if sleep: + time.sleep(fw.x) + return fw except RPCError as generic_error: logging.error(f"An exception occurred: {generic_error}") - return False + return generic_error -def edit_message_media(update, *args, **kwargs): - """Edits a message media in a way that never triggers exceptions and logs errors""" +def edit_message_media(update: Union[CallbackQuery, Client], sleep: bool = True, *args, **kwargs): + """Edits a message media in a way that never triggers exceptions and logs errors + + :param update: The pyrogram.Client instance or pyrogram.CallbackQuery + object to call the method for + :type update: Union[Client, CallbackQuery] + :param sleep: If True, the default, the function will call time.sleep() + in case of a FloodWait exception and return the exception object + after the sleep is done, otherwise the ``FloodWait`` exception is returned + immediately + :returns: Whatever the called pyrogram method returns, or an exception if + the method call caused an error + """ try: return update.edit_message_media(*args, **kwargs) except FloodWait as fw: - logging.warning(f"FloodWait! Sleeping {fw.x} seconds") - time.sleep(fw.x) + logging.warning(f"FloodWait! A wait of {fw.x} seconds is required") + if sleep: + time.sleep(fw.x) + return fw except RPCError as generic_error: logging.error(f"An exception occurred: {generic_error}") - return False - + return generic_error diff --git a/BotBase/methods/safe_send.py b/BotBase/methods/safe_send.py index a29a429..87961e8 100644 --- a/BotBase/methods/safe_send.py +++ b/BotBase/methods/safe_send.py @@ -4,69 +4,117 @@ import time import logging -def send_message(client: Client, *args, **kwargs): - """Sends a message in a way that never triggers exceptions and logs errors""" +def send_message(client: Client, sleep: bool = True, *args, **kwargs): + """Sends a message in a way that never triggers exceptions and logs errors + + :param client: The pyrogram.Client instance to call the method for + :type client: class: Client + :param sleep: If True, the default, the function will call time.sleep() + in case of a FloodWait exception and return the exception object + after the sleep is done, otherwise the ``FloodWait`` exception is returned + immediately + """ try: return client.send_message(*args, **kwargs) except FloodWait as fw: - logging.warning(f"FloodWait! Sleeping {fw.x} seconds") - time.sleep(fw.x) + logging.warning(f"FloodWait! A wait of {fw.x} seconds is required") + if sleep: + time.sleep(fw.x) + return fw except RPCError as generic_error: logging.error(f"An exception occurred: {generic_error}") - return False + return generic_error -def send_photo(client: Client, *args, **kwargs): - """Sends a photo in a way that never triggers exceptions and logs errors""" +def send_photo(client: Client, sleep: bool = True, *args, **kwargs): + """Sends a photo in a way that never triggers exceptions and logs errors + + :param client: The pyrogram.Client instance to call the method for + :type client: class: Client + :param sleep: If True, the default, the function will call time.sleep() + in case of a FloodWait exception and return the exception object + after the sleep is done, otherwise the ``FloodWait`` exception is returned + immediately + """ try: return client.send_photo(*args, **kwargs) except FloodWait as fw: - logging.warning(f"FloodWait! Sleeping {fw.x} seconds") - time.sleep(fw.x) + logging.warning(f"FloodWait! A wait of {fw.x} seconds is required") + if sleep: + time.sleep(fw.x) + return fw except RPCError as generic_error: logging.error(f"An exception occurred: {generic_error}") - return False + return generic_error -def send_audio(client: Client, *args, **kwargs): - """Sends an audio in a way that never triggers exceptions and logs errors""" +def send_audio(client: Client, sleep: bool = True, *args, **kwargs): + """Sends an audio in a way that never triggers exceptions and logs errors + + :param client: The pyrogram.Client instance to call the method for + :type client: class: Client + :param sleep: If True, the default, the function will call time.sleep() + in case of a FloodWait exception and return the exception object + after the sleep is done, otherwise the ``FloodWait`` exception is returned + immediately + """ try: return client.send_audio(*args, **kwargs) except FloodWait as fw: - logging.warning(f"FloodWait! Sleeping {fw.x} seconds") - time.sleep(fw.x) + logging.warning(f"FloodWait! A wait of {fw.x} seconds is required") + if sleep: + time.sleep(fw.x) + return fw except RPCError as generic_error: logging.error(f"An exception occurred: {generic_error}") - return False + return generic_error -def send_sticker(client: Client, *args, **kwargs): - """Sends a sticker in a way that never triggers exceptions and logs errors""" +def send_sticker(client: Client, sleep: bool = True, *args, **kwargs): + """Sends a sticker in a way that never triggers exceptions and logs errors + :param client: The pyrogram.Client instance to call the method for + :type client: class: Client + :param sleep: If True, the default, the function will call time.sleep() + in case of a FloodWait exception and return the exception object + after the sleep is done, otherwise the ``FloodWait`` exception is returned + immediately + """ try: return client.send_sticker(*args, **kwargs) except FloodWait as fw: - logging.warning(f"FloodWait! Sleeping {fw.x} seconds") - time.sleep(fw.x) + logging.warning(f"FloodWait! A wait of {fw.x} seconds is required") + if sleep: + time.sleep(fw.x) + return fw except RPCError as generic_error: logging.error(f"An exception occurred: {generic_error}") - return False + return generic_error -def send_animation(client: Client, *args, **kwargs): - """Sends an animation in a way that never triggers exceptions and logs errors""" +def send_animation(client: Client, sleep: bool = True, *args, **kwargs): + """Sends an animation in a way that never triggers exceptions and logs errors + :param client: The pyrogram.Client instance to call the method for + :type client: class: Client + :param sleep: If True, the default, the function will call time.sleep() + in case of a FloodWait exception and return the exception object + after the sleep is done, otherwise the ``FloodWait`` exception is returned + immediately + """ try: return client.send_animation(*args, **kwargs) except FloodWait as fw: - logging.warning(f"FloodWait! Sleeping {fw.x} seconds") - time.sleep(fw.x) + logging.warning(f"FloodWait! A wait of {fw.x} seconds is required") + if sleep: + time.sleep(fw.x) + return fw except RPCError as generic_error: logging.error(f"An exception occurred: {generic_error}") - return False + return generic_error diff --git a/BotBase/methods/various.py b/BotBase/methods/various.py index 113ebe0..076e6ed 100644 --- a/BotBase/methods/various.py +++ b/BotBase/methods/various.py @@ -1,29 +1,44 @@ from pyrogram.errors import RPCError, FloodWait import time +from pyrogram import CallbackQuery import logging -def answer(query, *args, **kwargs): - """Answers a query in a way that never triggers exceptions and logs errors""" +def answer(query: CallbackQuery, sleep: bool = True, *args, **kwargs): + """Answers a query in a way that never triggers exceptions and logs errors + + :param update: The pyrogram.CallbackQuery object to call the method for + :type update: class: CallbackQuery + :param sleep: If True, the default, the function will call time.sleep() + in case of a FloodWait exception and return the exception object + after the sleep is done, otherwise the ``FloodWait`` exception is returned + immediately + :returns: Whatever the called pyrogram method returns, or an exception if + the method call caused an error + """ try: return query.answer(*args, **kwargs) except FloodWait as fw: - logging.warning(f"FloodWait! Sleeping {fw.x} seconds") - time.sleep(fw.x) + logging.warning(f"FloodWait! A wait of {fw.x} seconds is required") + if sleep: + time.sleep(fw.x) + return fw except RPCError as generic_error: logging.error(f"An exception occurred: {generic_error}") - return False + return generic_error -def delete_messages(client, *args, **kwargs): +def delete_messages(client, sleep: bool = True, *args, **kwargs): """Deletes messages in a way that never triggers exceptions and logs errors""" try: return client.delete_messages(*args, **kwargs) except FloodWait as fw: - logging.warning(f"FloodWait! Sleeping {fw.x} seconds") - time.sleep(fw.x) + logging.warning(f"FloodWait! A wait of {fw.x} seconds is required") + if sleep: + time.sleep(fw.x) + return fw except RPCError as generic_error: logging.error(f"An exception occurred: {generic_error}") - return False + return generic_error diff --git a/BotBase/modules/admin.py b/BotBase/modules/admin.py index 0cf2f77..ef448b3 100644 --- a/BotBase/modules/admin.py +++ b/BotBase/modules/admin.py @@ -25,7 +25,7 @@ def get_user_info(client, message): user = get_user(message.command[1]) if user: logging.warning(f"Admin with id {message.from_user.id} sent /getuser {message.command[1]}") - rowid, uid, uname, date, admin = user + _, uid, uname, date, banned = user text = USER_INFO.format(uid=uid, uname='@' + uname if uname != 'null' else uname, date=date, status='User' if not admin else 'Admin') send_message(client, message.chat.id, text) else: diff --git a/BotBase/modules/livechat.py b/BotBase/modules/livechat.py index bdbc597..dfe6ebe 100644 --- a/BotBase/modules/livechat.py +++ b/BotBase/modules/livechat.py @@ -35,15 +35,16 @@ def begin_chat(_, query): reply_markup=BUTTONS) join_chat_button = InlineKeyboardMarkup([[InlineKeyboardButton(JOIN_CHAT_BUTTON, f"join_{query.from_user.id}")]]) user = get_user(query.from_user.id) - rowid, uid, uname, date, last_call, imei = user + _, uid, uname, date = user admin = uid in ADMINS text = USER_INFO.format(uid=uid, uname='@' + uname if uname != 'null' else uname, date=date, status='User' if not admin else 'Admin', last_call=datetime.utcfromtimestamp(int(last_call)), imei=imei) CACHE[query.from_user.id].append([]) for admin in ADMINS: - message = send_message(_, admin, SUPPORT_NOTIFICATION.format(uinfo=text), reply_markup=join_chat_button) - CACHE[query.from_user.id][-1].append((message.chat.id, message.message_id)) + if STATUSES[admin][0] == "free": + message = send_message(_, admin, SUPPORT_NOTIFICATION.format(uinfo=text), reply_markup=join_chat_button) + CACHE[query.from_user.id][-1].append((message.chat.id, message.message_id)) CACHE[query.from_user.id][-1].append((msg.chat.id, msg.message_id)) diff --git a/DATABASE.md b/DATABASE.md new file mode 100644 index 0000000..59e8c22 --- /dev/null +++ b/DATABASE.md @@ -0,0 +1,49 @@ +# BotBase - Notes on database interaction + +BotBase has an built-in API to interact with a SQLite3 database, located in the +`BotBase/database/query.py` module. The reason why SQLite3 was chosen among the +lots of options is that it's lightweight, has less security concerns (no user +and password to remember) and requires literally no setup at all. + +The configuration is hassle-free, you can keep the default values and they'll +work just fine. If you need a more complex database structure, just edit +the `DB_CREATE` SQL query to fit your needs, but do not alter the default +`users` table unless you also change all the SQL queries in the `config.py` +file as this would otherwise break the whole internal machinery of BotBase. + +## Available methods + +The module `BotBase.database.query` implements the following default methods +to interact with the database. All methods either return the result of a query +or `True` if the operation was successful, or an exception if the query errored. + +Please note that the methods are **NOT** locked and that proper locking is +needed if you think that your bot might get a `sqlite3.OoerationalError: database +is locked` error when accessing the database. + +All queries are performed within a `with` block and therefore rollbacked +automatically if an error occurs or committed if the transaction was successful. + + - `get_user()` -> Given a Telegram ID as input, returns a tuple containing + the unique id of the user in the database, its telegram id, username, + the date and time the user was inserted in the database as a string + (formatted as d/m/Y H:M:S) and an integer (0 for `False` and 1 for `True`) + that represents the user's status (whether it is banned or not) + + - `get_users()` -> This acts similarly to the above `get_user`, but takes + no parameters and returns a list of all the users in the database. The + list contains tuples of the same structure of the ones returned by `get_user` + + - `set_user()` -> Saves an ID/username pair (in this order) + to the database. The username parameter can be `None` + + - `ban_user()` -> Bans the user with the given user ID (Coming soon) + + - `unban_user()` -> Unbans a user with the given ID (Coming soon) + +# I need MySQL/other DBMS! + +The API has been designed in a way that makes it easy to swap between different +database managers, so if you feel in the right mood make a PR to support a new +database and it'll be reviewed ASAP. + diff --git a/METHODS.md b/METHODS.md new file mode 100644 index 0000000..7fcf4d7 --- /dev/null +++ b/METHODS.md @@ -0,0 +1,47 @@ +# BotBase - Methods overview + +BotBase has a builtin collection of wrappers around Pyrogram methods that make +it even easier to use them properly. + +**DISCLAIMER**: These methods are just wrappers around Pyrogram's ones and behave +exactly the same. Every method listed here takes 2 extra positional arguments, +namely a `Client`/`CallbackQuery` instance and a boolean parameter (read below) + +All other arguments, including keyword ones, are passed to pyrogram directly. + +The methods are wrapped in try/except blocks and log automatically all errors +to the console. Also, if `sleep=True` (which is by default) if the method raises +a `FloodWait` exception, the wrapper will sleep the required amount of time and +then return the `FloodWait` exception. If `sleep=False` the exception is returned +immediately. All other exceptions are catched under `RPCError` and are returned +if they get raised, too. If no exception occurs the wrapper will return whatever +the corresponding pyrogram method returns. + +## Methods - Safe send + +List of the available functions in `BotBase.methods.safe_send` + + - `send_message` + - `send_photo` + - `send_audio` + - `send_animation` + - `send_sticker` + +These are the exact names that pyrogram uses, to see their docs refer to +[pyrogram docs](https://docs.pyrogram.org/api/methods/) + +## Methods - Safe edit + +List of the available functions in `BotBase.methods.safe_edit` + + - `edit_message_text` + - `edit_message_media` + - `edit_message_caption` + +## Methods - Various + +List of the available functions in `BotBase.methods.various` + + - `answer` (for `CallbackQuery` objects) + - `delete_messages` + diff --git a/README.md b/README.md index a57ad62..e0ab71f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,98 @@ # BotBase -Private base for Telegram bots using pyrogram + +BotBase is a collection of plugins that use [Pyrogram's](https://github.com/pyrogram/pyrogram) API to make bot development extremely easy. + +### Disclaimer + +BotBase requires a solid knowledge of pyrogram and of the Telegram MTProto API itself, you can check pyrogram' docs [here](https://docs.pyrogram.org) + +Also, you need to know how to host a bot yourself. I mean, I coded all of this for you already, make some effort! + +## BotBase - Setup + +To setup a project using BotBase, follow this step-by-step guide (assuming `pip` and `git` are already installed and up to date): + + - Open a terminal and type `git clone https://github.com/nocturn9x/BotBase` + - `cd` into the newly created directory and run `python3 -m pip install -r requirements.txt` + - Once that is done, open the `BotBase/config.py` module with a text editor and start changing the default settings + - The first thing you might want to do is change the `API_ID`, `API_HASH` and `BOT_TOKEN` global variables. Check [this page](https://my.telegram.org/apps) and login with your telegram account to create an API_ID/API_HASH pair. For the bot token, just create one with [BotFather](https://telegram.me/BotFather) + +**Note**: The configuration file is still a python file, so when it will be imported any python code that you typed inside it will be executed, so be careful! If you need to perform pre-startup operations it is advised to do them in the `if __name__ == "main":` block inside `bot.py`, before `bot.start()` + +## BotBase - Plugins + +BotBase comes with lots of default plugins and tools to manage database interaction. + +As of now, the following plugins are active: + + - An advanced live chat accessible trough buttons + - A start module that simply replies to /start and adds the user to the database if not already present + - An highly customizable antiflood module that protects your both from callback and message flood + - An administration module with lots of pre-built features such as /global and /whisper + + +### Plugins - Live Chat + +The livechat is probably the most complicated plugin, because it needs to save the user's status and takes advantage of custom filters to +work properly. To customize this plugin, go the the appropriate section in `config.py` and edit the available options at your heart's desire. + +This plugin works the following way: + + - When a user presses the button to trigger the live chat, all admins get notified that user xyz is asking for support + - The notification will contain the user information such as ID and username, if available + - At the same time, the user will see a waiting queue. Available admins will be shown with a green sphere next to their name, while admins that are already chatting will be shown as busy + - The user can press the update button to update the admin's statuses + - When an administrator joins, all notifications from all admins are automatically deleted and the admin is marked as busy + - When an admin joins a chat, other admins won't be able to join + - Admins that are busy will not receive other support notifications + - An admin cannot join a chat if it's already busy + +Most of the working of the module is pre-defined, but you can customize the texts that the bot will use in the appropriate section of `config.py` + + +### Plugins - Admin + +This is the administrative module for the bot, and it also has its own section in the `config.py` file. + +To configure this plugin, go to the appropriate section in `config.py` and change the default values in the `ADMINS` dictionary. Do **NOT** change the value of `ADMINS`, just update the dictionary as follows: + + - Use the admin's ID as a key + - As a value you can set the name that will be displayed to users in the admin's queue + + +The available commands are: + + - `/getuser ID`: Fetches user information by its Telegram ID + - `/getranduser`: Fetches a random user from the database + - `/ban ID`: Bans a user from using the bot, permanently (Coming soon) + - `/unban ID`: Unbans a user from using the bot (Coming soon) + - `/count`: Shows the current number of registered users + - `/global msg`: Broadcast whatever comes after `/global` to all users, supports HTML and markdown formatting + - `/whisper ID msg`: Send `msg` to a specific user given its ID. HTML and markdown formatting supported (Coming soon) + - `/update ID`: Updates the user's info in the database, if they've changed (Coming soon) + +### Plugins - Antiflood + +The antiflood plugin is a well-designed protection system that works by accumulating a fixed number of messages from a user and then subtract their timestamps in groups of 2. + +To configure this plugin and to know more about how it works, check the appropriate section in `config.py` + +### Plugins - Start + +This module is simple, it will reply to private messages containing the /start command with a pre-defined greeting and inline buttons + +To configure it, check its section in the `config.py` file + + +## Extending the default functionality + +Extending BotBase's functionality is easy, just create a pyrogram smart plugin in the `BotBase/modules` section and you're ready to go! + +If you don't know what a smart plugin is, check [this link](https://docs.pyrogram.org/topics/smart-plugins) from pyrogram's official documentation to know more. + +There are some things to keep in mind, though: + + - If you want to protect your plugin from flood, import the `BotBase.modules.antiflood.BANNED_USERS` filter (basically a `Filters.user()` object) and use it like this: `~BANNED_USERS`. This will restrict banned users from reaching your handler at all + - To avoid repetition with try/except blocks, BotBase also implements some wrappers around `pyrogram.Client` and `pyrogram.CallbackQuery` (and many more soon) that perform automatic exception handling and log to the console automatically, check the `METHODS.md` file in this repo to know more + - Nothing restricts you from changing how the default plugins work, but this is not advised. The default plugins have been design to cooperate together and breaking this might lead to obscure tracebacks and errors that are hard to debug + - BotBase also has many default methods to handle database interaction, check the `DATABASE.md` file in this repo to know more diff --git a/bot.py b/bot.py index f6b6e41..4d84d82 100644 --- a/bot.py +++ b/bot.py @@ -1,32 +1,11 @@ import logging from pyrogram import Client -import sqlite3.dbapi2 as sqlite3 -import os from pyrogram.session import Session import importlib -def create_database(path: str, query: str): - if os.path.exists(path): - logging.warning(f"Database file exists at {path}, running query") - else: - logging.warning(f"No database found, creating it at {path}") - try: - database = sqlite3.connect(path) - except sqlite3.Error as connection_error: - logging.error(f"An error has occurred while connecting to database: {connection_error}") - else: - try: - with database: - cursor = database.cursor() - cursor.executescript(query) - cursor.close() - except sqlite3.Error as query_error: - logging.info(f"An error has occurred while executing query: {query_error}") - - if __name__ == "__main__": - MODULE_NAME = "BotBase" + MODULE_NAME = "BotBase" # Change this to match the FOLDER name that contains the config.py file conf = importlib.import_module(MODULE_NAME) logging.basicConfig(format=conf.LOGGING_FORMAT, datefmt=conf.DATE_FORMAT, level=conf.LOGGING_LEVEL) bot = Client(api_id=conf.API_ID, api_hash=conf.API_HASH, bot_token=conf.BOT_TOKEN, plugins=conf.PLUGINS_ROOT, @@ -34,11 +13,11 @@ if __name__ == "__main__": Session.notice_displayed = True try: logging.warning("Running create_database()") - create_database(conf.DB_RELPATH, conf.DB_CREATE) + conf.create_database(conf.DB_RELPATH, conf.DB_CREATE) logging.warning("Database interaction complete") logging.warning("Starting bot") bot.start() logging.warning("Bot started") - except KeyboardInterrupt: - logging.warning("Stopping bot") + except Exception as e: + logging.warning(f"Stopping bot due to a {type(e).__name__}: {e}") bot.stop()