Major refactoring and added various docs

This commit is contained in:
nocturn9x 2020-06-05 19:22:17 +00:00
parent 4aafdbb71e
commit 0acc9c51f8
10 changed files with 374 additions and 80 deletions

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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))

49
DATABASE.md Normal file
View File

@ -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.

47
METHODS.md Normal file
View File

@ -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`

View File

@ -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

29
bot.py
View File

@ -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()