From 9dbb50ecdd7cf76892f36db48ec76aa381807642 Mon Sep 17 00:00:00 2001 From: Cezar H Date: Sun, 29 Oct 2023 00:06:13 -0300 Subject: [PATCH] release v3.0 (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: remove identifier of listen() and ask() in favor of new kwargs * refactor: split classes into new files, extend from original classes * refactor: rename patch -> patch_into, patchable -> should_patch * style: format with ruff * feat(precedence): return most specific listener * refactor!: rename get_single_listener -> get_matching_listener, get_many_listeners -> get_many_matching_listeners * refactor: improve variable names and only remove checking attribute from null listener * refactor: transform config.py into package pyromod.config * chore!: remove handlers from pyromod exports * docs: bring new docs with docsify * docs: update README * Create CNAME * Delete CNAME * docs: add missing backtick * Create CNAME * fix: fix order of positional args in bound method ask * refactor(ask)!: rename request to sent_message * feat(ask): send message only if text is not empty * refactor: search for message_id (pyrogram v1) if message.id is not found * refactor: only use query.message.chat.id if both message and chat are not null * supports pyrogram v1 (#11) * supports pyrogram v1 * Update listen.py forgot, delete comma --------- Co-authored-by: Cezar H * request to sent_message (#13) I think using the sent_message statement instead of request is much more descriptive * ask function will behave like listen if text is empty (#14) Co-authored-by: Cezar H * Update for filters and if from_user is None (#17) * Update listen.py * Update listen.py * Update utils.py * Changes to be committed: modified: pyromod/listen/listen.py modified: pyromod/utils/utils.py * Changes to be committed: modified: pyromod/utils/utils.py * Changes to be committed: modified: pyromod/utils/utils.py * Changes to be committed: modified: pyromod/utils/utils.py * Update utils.py * Update utils.py * Update utils.py * Update utils.py * Update utils.py * Update utils.py * Update utils.py * Update utils.py * Changes to be committed: modified: pyromod/listen/listen.py * Update pyproject.toml * Update poetry.lock * Update poetry.lock * Update pyproject.toml * Update listen.py * Update listen.py --------- Co-authored-by: Cezar H * feat: remove identifier of listen() and ask() in favor of new kwargs * refactor: split classes into new files, extend from original classes * refactor: rename patch -> patch_into, patchable -> should_patch * refactor: rename patch -> patch_into * style: format with ruff * chore: remaking many changes that got undone after the merge * refactor!: rename get_single_listener -> get_matching_listener, get_many_listeners -> get_many_matching_listeners * feat(precedence): return most specific listener * fix: replace @should_patch -> @should_patch() * fix: use Dict and List from typing instead of builtin --------- Co-authored-by: Tofik Denianto <77754555+tofikdn@users.noreply.github.com> Co-authored-by: Eikosa Co-authored-by: Jusidama Bot <97734754+Jusidama-Bot@users.noreply.github.com> --- README.md | 207 +++++----- docs/.nojekyll | 0 docs/CNAME | 1 + docs/README.md | 37 ++ docs/_sidebar.md | 30 ++ docs/cover.md | 10 + docs/get-started/configuration.md | 70 ++++ docs/get-started/examples.md | 56 +++ docs/get-started/initialization.md | 18 + docs/get-started/installation.md | 21 + docs/index.html | 57 +++ docs/pyromod/config/index.md | 26 ++ docs/pyromod/exceptions/listener-stopped.md | 17 + docs/pyromod/exceptions/listener-timeout.md | 15 + docs/pyromod/helpers/index.md | 70 ++++ docs/pyromod/index.md | 41 ++ docs/pyromod/listen/chat.md | 44 +++ docs/pyromod/listen/client.md | 95 +++++ docs/pyromod/listen/message.md | 27 ++ docs/pyromod/listen/user.md | 46 +++ docs/pyromod/nav/pagination.md | 27 ++ docs/pyromod/types/identifier.md | 30 ++ docs/pyromod/types/listener-types.md | 9 + docs/pyromod/types/listener.md | 23 ++ docs/pyromod/utils/patch.md | 27 ++ pyproject.toml | 2 +- pyromod/__init__.py | 28 +- pyromod/config.py | 9 + pyromod/config/__init__.py | 11 + pyromod/exceptions/__init__.py | 4 + pyromod/exceptions/listener_stopped.py | 2 + pyromod/exceptions/listener_timeout.py | 2 + pyromod/helpers/__init__.py | 4 +- pyromod/helpers/helpers.py | 16 +- pyromod/listen/__init__.py | 16 +- pyromod/listen/callback_query_handler.py | 116 ++++++ pyromod/listen/chat.py | 21 + pyromod/listen/client.py | 161 ++++++++ pyromod/listen/listen.py | 413 -------------------- pyromod/listen/message.py | 32 ++ pyromod/listen/message_handler.py | 71 ++++ pyromod/listen/user.py | 21 + pyromod/nav/__init__.py | 4 +- pyromod/nav/pagination.py | 79 ++-- pyromod/types/__init__.py | 5 + pyromod/types/identifier.py | 31 ++ pyromod/types/listener.py | 16 + pyromod/types/listener_types.py | 6 + pyromod/utils/__init__.py | 4 +- pyromod/utils/{utils.py => patch.py} | 234 ++++++----- 50 files changed, 1633 insertions(+), 679 deletions(-) create mode 100644 docs/.nojekyll create mode 100644 docs/CNAME create mode 100644 docs/README.md create mode 100644 docs/_sidebar.md create mode 100644 docs/cover.md create mode 100644 docs/get-started/configuration.md create mode 100644 docs/get-started/examples.md create mode 100644 docs/get-started/initialization.md create mode 100644 docs/get-started/installation.md create mode 100644 docs/index.html create mode 100644 docs/pyromod/config/index.md create mode 100644 docs/pyromod/exceptions/listener-stopped.md create mode 100644 docs/pyromod/exceptions/listener-timeout.md create mode 100644 docs/pyromod/helpers/index.md create mode 100644 docs/pyromod/index.md create mode 100644 docs/pyromod/listen/chat.md create mode 100644 docs/pyromod/listen/client.md create mode 100644 docs/pyromod/listen/message.md create mode 100644 docs/pyromod/listen/user.md create mode 100644 docs/pyromod/nav/pagination.md create mode 100644 docs/pyromod/types/identifier.md create mode 100644 docs/pyromod/types/listener-types.md create mode 100644 docs/pyromod/types/listener.md create mode 100644 docs/pyromod/utils/patch.md create mode 100644 pyromod/config.py create mode 100644 pyromod/config/__init__.py create mode 100644 pyromod/exceptions/__init__.py create mode 100644 pyromod/exceptions/listener_stopped.py create mode 100644 pyromod/exceptions/listener_timeout.py create mode 100644 pyromod/listen/callback_query_handler.py create mode 100644 pyromod/listen/chat.py create mode 100644 pyromod/listen/client.py delete mode 100644 pyromod/listen/listen.py create mode 100644 pyromod/listen/message.py create mode 100644 pyromod/listen/message_handler.py create mode 100644 pyromod/listen/user.py create mode 100644 pyromod/types/__init__.py create mode 100644 pyromod/types/identifier.py create mode 100644 pyromod/types/listener.py create mode 100644 pyromod/types/listener_types.py rename pyromod/utils/{utils.py => patch.py} (70%) diff --git a/README.md b/README.md index e9c34b7..401e797 100644 --- a/README.md +++ b/README.md @@ -1,148 +1,151 @@ # pyromod + [![Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=flat&logo=telegram&logoColor=white)](https://t.me/pyromodchat) ![GitHub release (latest by date)](https://img.shields.io/github/v/release/usernein/pyromod) [![Downloads](https://static.pepy.tech/badge/pyromod)](https://pepy.tech/project/pyromod) [![Downloads](https://static.pepy.tech/badge/pyromod/month)](https://pepy.tech/project/pyromod) -A monkeypatcher add-on for Pyrogram which does conversation handling and other cool stuff. +pyromod is a versatile Python add-on for the Pyrogram library, designed to make developing Telegram bots faster and more +efficient. -In other words, it is a compilation of utilities i developed for improving my personal experience with Pyrogram. -It works **together** with Pyrogram, it is **not** a fork/modded version. It does monkeypatching to add features to Pyrogram classes on the go (so i don't need to update on every Pyrogram's release). +It's based on **monkeypatching**, which means it works together with Pyrogram, rather than being a fork or modified +version. It +adds features to Pyrogram classes on the go, so you don't need to update it every time Pyrogram is updated. -## Usage -Import `pyromod` one time in your script and you'll already be able to use the modified pyrogram in all your handlers. Example: -```python -# config.py -import pyromod -from pyrogram import Client +Whether you're building a simple chatbot or a complex form to get multiple responses from the user, pyromod has you +covered. It enhances Pyrogram with a range +of advanced features, simplifies conversation handling, and offers a high degree of customizability. -app = Client('my_session') -``` +## Key Features -Then you can, from another file, do `from config import app` to import the modded Pyrogram Client we created above. It will be modded globally. +- **Effortless Bot Development:** pyromod streamlines the process of building conversational Telegram bots, saving you + time and effort + during development. -All the patches are applied automatically as soon as pyromod is imported. +- **Advanced Conversation Management:** Managing conversations with users is made easier, allowing you to create dynamic + and interactive interactions much easier, without having to save states anywhere, by leveraging the power of + async/await syntax. -## Methods -All pyromod methods are callable by any of these ways: -- `await Client.(identifier, ...)` -- `await Chat.()` -- `await User.()` +- **Effortless Inline Keyboards Creation:** Creating inline keyboards is easier than ever with pyromod's inline keyboard + helper functions. -In the last two, Pyrogram automatically gets the ids from the object, to compound the `identifier` tuple that `Client.listen` uses. +- **User-Friendly Pagination:** Enhance the user experience by providing easy navigation tools with the pyromod's + pagination + helpers. -These are the methods pyromod adds: -- `listen(identifier, filters=None, listener_type=ListenerTypes.MESSAGE, timeout=None, unallowed_click_alert=True)` -Awaits for a new message in the specified chat and returns its Message object. If listener_type is set to `ListenerTypes.CALLBACK_QUERY`, it awaits and returns a CallbackQuery object. -You can pass Update Filters to the `filters` parameter just like you do for the update handlers. e.g. `filters=filters.photo & filters.bot` -`identifier` is a tuple containing, in this exact order, (chat_id, user_id, message_id). It lets you specify exactly which update you want. You don't need to worry about that if you mostly use the bound methods. -`unnalowed_click_alert` is the text that users will see in an alert when the button is not waiting for them to click. If True, it uses the default text at `PyromodConfig.unnalowed_click_alert_text`. If False, no text is shown. +- **Highly Customizable:** pyromod's configuration options let you customize its behavior to meet your specific project + requirements. -- `ask(text, identifier, filters=None, listener_type=ListenerTypes.MESSAGE, timeout=None, unallowed_click_alert=True)` -Same as `listen`, but sends a message to identifier[0] before and only then waits for a response. -You can additionally pass any of the `Client.send_message()` parameters. Check the example below. -The object of the sent message is returned inside of the attribute `request` +## Examples + +**Awaiting a single message from a specific chat:** -Example: ```python -answer = await message.chat.ask('*Send me your name:*', parse_mode=enums.ParseMode.MARKDOWN) -await answer.request.edit_text("Name received!") -await answer.reply(f'Your name is: {answer.text}', quote=True) +response = await client.listen(chat_id=chat_id) ``` -- `Message.wait_for_click(from_user_id=None, timeout=None, filters=None, alert=True)` -Awaits from a click on any button on the Message object. If `from_user_id` is passed, pyromod will wait for a click of that user. -If you pass any text to `alert`, it will be shown to any other user. If `alert` is True, it will use the default text. If False, no text will be shown. +**Awaiting a single message from a specific user in a specific chat:** -## `pyromod.helpers` -Tools for creating inline keyboards a lot easier. +```python +response = await client.listen(chat_id=chat_id, user_id=user_id) +``` -### `pyromod.helpers.ikb` +**Asking the user a question then await for the response:** -Creates a inline keyboard. -Its first and only argument is a list (the keyboard itself) containing lists (the lines) of buttons, which can be lists or tuples. I use tuples to avoid a mess with a lot of brackets. Tuples makes it easier to read. +```python +response = await client.ask(chat_id=chat_id, text='What is your name?') +``` -The button syntax is very simple: `(TEXT, VALUE, TYPE)`, with TYPE being any existent button type (e.g. `url`) and VALUE is its value. If you omit the type, it will be considered as a callback button. -If you pass only a string as button, it will be used as text and callback_data for the InlineKeyboardButton. -This syntax will be automagically converted by pyromod. +**Asking the user a question then await for the response, with a timeout:** -Examples: ```python -from pyromod.helpers import ikb -... -keyboard = ikb([ - [('Button 1', 'call_1'), ('Button 2', 'call_2')], - [('Another button', 't.me/pyromodchat', 'url')] -]) -await message.reply('Easy inline keyboard', reply_markup=keyboard) +try: + response = await client.ask(chat_id=chat_id, text='What is your name?', timeout=10) +except ListenerTimeout: + await message.reply('You took too long to answer.') +``` + +**Full handler example, getting user's name and age with bound method Chat.ask:** + +```python +from pyromod import Client, Message +from pyrogram import filters + + +@Client.on_message(filters.command('form')) +async def on_form(client: Client, message: Message): + chat = message.chat + + name = await chat.ask('What is your name?', filters=filters.text) + age = await chat.ask('What is your age?', filters=filters.text) + + await message.reply(f'Your name is {name.text} and you are {age.text} years old.') ``` +**Easier inline keyboard creation:** + ```python +from pyromod.helpers import ikb + keyboard = ikb([ - ["Mars", "Earth", "Venus"], - ["Saturn", "Jupyter"] + [('Button 1', 'callback_data_1'), ('Button 2', 'callback_data_2')], + [('Another button', 't.me/pyromodchat', 'url')] ]) -await message.reply("Easiest inline keyboard", reply_markup=keyboard) ``` -- `pyromod.helpers.array_chunk` -Chunk the elements of a list into small lists. i.e. [1, 2, 3, 4] can become [[1,2], [3,4]]. This is extremely useful if you want to build a keyboard dinamically with more than 1 column. You just put all buttons together in a list and run: -```python -lines = array_chunk(buttons, 2) -keyboard = ikb(lines) +## Installation + +To get started with pyromod, you can install it using pip: + +```bash +pip install pyromod ``` -This will generate a list of lines with 2 buttons on each one. -### `pyromod.nav` -Tools for creating navigation keyboards. +Or poetry: -- `pyromod.nav.Pagination` -Creates a full paginated keyboard. Usage: -```python -from pyrogram import Client, filters -from pyromod.nav import Pagination -from pyromod.helpers import ikb +```bash +poetry add pyromod +``` + +Or rye: -def page_data(page): - return f'view_page {page}' -def item_data(item, page): - return f'view_item {item} {page}' -def item_title(item, page): - return f'Item {item} of page {page}' - -@Client.on_message(filters.regex('/nav')) -async def on_nav(c,m): - objects = [*range(1,100)] - page = Pagination( - objects, - page_data=page_data, # callback to define the callback_data for page buttons in the bottom - item_data=item_data, # callback to define the callback_data for each item button - item_title=item_title # callback to define the text for each item button - ) - index = 0 # in which page is it now? (used to calculate the offset) - lines = 5 # how many lines of the keyboard to include for the items - columns = how many columns include in each items' line - kb = page.create(index, lines, columns) - await m.reply('Test', reply_markup=ikb(kb)) +```bash +rye add pyromod ``` -## pyromod.PyrogramConfig -It lets you do some tweaks on pyromod behavior. +## Initialization + +To initialize pyromod, on the file that creates the client instance, simply import the Client class from pyromod instead +of pyrogram: + ```python -class PyromodConfig: - timeout_handler = None - stopped_handler = None - throw_exceptions = True - unallowed_click_alert = True - unallowed_click_alert_text = ( - "[pyromod] You're not expected to click this button." - ) +from pyromod import Client ``` -`timeout_handler` and `stopped_handler` are callbacks that receive (identifier, listener_data) as arguments. timeout_handler receives an extra arg `timeout`. When they are in use, pyromod won't throw the exceptions ListenerStopped and ListenedTimeout. + +And that's all! You can still use the `Client` class as you would normally do with Pyrogram, but now having all the +extra features. + +You don't need to change the imports on the plugins files. Even by importing `Client` from pyrogram, the pyromod +features will be available anyway. In order to monkeyatch pyromod features successfully, it's just required that the +first `Client` class imported to your project code should be from pyromod. Then all the other future `Client` instances +will be patched automatically. + +You just need to import from pyromod if you want your IDE to recognize and suggest +the extra features based on `pyromod.Client` type. + +## Contributing + +We welcome contributions from the community to make pyromod even better. + +Feel free to open issues, submit pull requests, +or contribute in any way that aligns with our goals. ### Copyright & License + This project may include snippets of Pyrogram code -- Pyrogram - Telegram MTProto API Client Library for Python. Copyright (C) 2017-2022 Dan <> + +- Pyrogram - Telegram MTProto API Client Library for Python. Copyright (C) 2017-2022 + Dan <> Licensed under the terms of the [GNU Lesser General Public License v3 or later (LGPLv3+)](COPYING.lesser) diff --git a/docs/.nojekyll b/docs/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..c4f2c1b --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +pyromod.pauxis.dev \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..ac59272 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,37 @@ +# pyromod + +[![Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=flat&logo=telegram&logoColor=white)](https://t.me/pyromodchat) +![GitHub release (latest by date)](https://img.shields.io/github/v/release/usernein/pyromod) +[![Downloads](https://static.pepy.tech/badge/pyromod)](https://pepy.tech/project/pyromod) +[![Downloads](https://static.pepy.tech/badge/pyromod/month)](https://pepy.tech/project/pyromod) + +pyromod is a versatile Python add-on for the Pyrogram library, designed to make developing Telegram bots faster and more +efficient. + +It's based on **monkeypatching**, which means it works together with Pyrogram, rather than being a fork or modified +version. It +adds features to Pyrogram classes on the go, so you don't need to update it every time Pyrogram is updated. + +Whether you're building a simple chatbot or a complex form to get multiple responses from the user, pyromod has you +covered. It enhances Pyrogram with a range +of advanced features, simplifies conversation handling, and offers a high degree of customizability. + +## Key Features + +- **Effortless Bot Development:** pyromod streamlines the process of building conversational Telegram bots, saving you + time and effort + during development. + +- **Advanced Conversation Management:** Managing conversations with users is made easier, allowing you to create dynamic + and interactive interactions much easier, without having to save states anywhere, by leveraging the power of + async/await syntax. + +- **Effortless Inline Keyboards Creation:** Creating inline keyboards is easier than ever with pyromod's inline keyboard + helper functions. + +- **User-Friendly Pagination:** Enhance the user experience by providing easy navigation tools with the pyromod's + pagination + helpers. + +- **Highly Customizable:** pyromod's configuration options let you customize its behavior to meet your specific project + requirements. diff --git a/docs/_sidebar.md b/docs/_sidebar.md new file mode 100644 index 0000000..dc2ee74 --- /dev/null +++ b/docs/_sidebar.md @@ -0,0 +1,30 @@ + + +- **Home** + - [Introduction](/#pyromod) + - [Key features](/#key-features) +- **Get Started** + - [Installation](/get-started/installation.md) + - [Initializing pyromod](/get-started/initialization.md) + - [Configuration](/get-started/configuration.md) + - [Examples](/get-started/examples.md) +- **API Reference** + - [pyromod](/pyromod/index) + - [pyromod.config](/pyromod/config/index) + - pyromod.listen + - [Client](/pyromod/listen/client) + - [Message](/pyromod/listen/message) + - [User](/pyromod/listen/user) + - [Chat](/pyromod/listen/chat) + - pyromod.exceptions + - [ListenerTimeout](/pyromod/exceptions/listener-timeout) + - [ListenerStopped](/pyromod/exceptions/listener-stopped) + - [pyromod.helpers](/pyromod/helpers/index) + - pyromod.types + - [Identifier](/pyromod/types/identifier) + - [ListenerTypes](/pyromod/types/listener-types) + - [Listener](/pyromod/types/listener) + - pyromod.utils + - [pyromod.utils.patch](/pyromod/utils/patch) + - pyromod.nav + - [Pagination](/pyromod/nav/pagination) \ No newline at end of file diff --git a/docs/cover.md b/docs/cover.md new file mode 100644 index 0000000..3cad2cd --- /dev/null +++ b/docs/cover.md @@ -0,0 +1,10 @@ +# pyromod 3.0.0 + +> A collection of monkeypatched tools for Pyrogram. + +- Simplifies bot development with Pyrogram. +- Advanced features for conversation management. +- Many tools that enhance the user experience. + +[GitHub](https://github.com/usernein/pyromod) +[Get Started](#pyromod) diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md new file mode 100644 index 0000000..4224da1 --- /dev/null +++ b/docs/get-started/configuration.md @@ -0,0 +1,70 @@ +## Configuration + +pyromod offers various configuration options to customize its behavior according to your specific needs. This guide will walk you through the process of configuring pyromod using the `pyromod.config` object. + +This is completely optional though. You can use pyromod as normal without configuring it. + +### Import the Configuration Object + +Before you can start configuring pyromod, you need to import the `config` object: + +```python +from pyromod.config import config +``` + +### Available Configuration Options + +#### 1. Timeout Handler + +The `timeout_handler` is an optional callback function that you can set to handle timeouts for listeners. If you want to define a custom function to handle timeouts, you can assign it to `config.timeout_handler`. + +Example: + +```python +## Set a custom timeout handler function +config.timeout_handler = my_custom_timeout_handler +``` + +#### 2. Stopped Handler + +The `stopped_handler` is an optional callback function that you can set to handle events when a listener is stopped. You can assign a custom function to `config.stopped_handler` to define your stopped event handling. + +Example: + +```python +## Set a custom stopped handler function +config.stopped_handler = my_custom_stopped_handler +``` + +#### 3. Exception Handling + +The `throw_exceptions` attribute is a boolean flag that determines whether pyromod should raise exceptions for certain events. You can set it to `True` or `False` based on your preferences. + +Example: + +```python +## Disable raising exceptions for listener events +config.throw_exceptions = False +``` + +#### 4. Unallowed Click Alert + +The `unallowed_click_alert` is a boolean flag that controls whether users should be alerted when they click a button that doesn't match the filters. Setting it to `True` displays an alert, while `False` disables it. + +Example: + +```python +## Disable unallowed click alerts +config.unallowed_click_alert = False +``` + +#### 5. Custom Alert Text + +When `unallowed_click_alert` is `True`, you can customize the alert text displayed to users. Set the `unallowed_click_alert_text` attribute to your desired text. + +Example: + +```python +## Set a custom alert text for unallowed clicks +config.unallowed_click_alert_text = "Unauthorized action: You cannot click this button." +``` \ No newline at end of file diff --git a/docs/get-started/examples.md b/docs/get-started/examples.md new file mode 100644 index 0000000..3f5a868 --- /dev/null +++ b/docs/get-started/examples.md @@ -0,0 +1,56 @@ +## Examples + +**Awaiting a single message from a specific chat:** + +```python +response = await client.listen(chat_id=chat_id) +``` + +**Awaiting a single message from a specific user in a specific chat:** + +```python +response = await client.listen(chat_id=chat_id, user_id=user_id) +``` + +**Asking the user a question then await for the response:** + +```python +response = await client.ask(chat_id=chat_id, text='What is your name?') +``` + +**Asking the user a question then await for the response, with a timeout:** + +```python +try: + response = await client.ask(chat_id=chat_id, text='What is your name?', timeout=10) +except ListenerTimeout: + await message.reply('You took too long to answer.') +``` + +**Full handler example, getting user's name and age with bound method Chat.ask:** + +```python +from pyromod import Client, Message +from pyrogram import filters + + +@Client.on_message(filters.command('form')) +async def on_form(client: Client, message: Message): + chat = message.chat + + name = await chat.ask('What is your name?', filters=filters.text) + age = await chat.ask('What is your age?', filters=filters.text) + + await message.reply(f'Your name is {name.text} and you are {age.text} years old.') +``` + +**Easier inline keyboard creation:** + +```python +from pyromod.helpers import ikb + +keyboard = ikb([ + [('Button 1', 'callback_data_1'), ('Button 2', 'callback_data_2')], + [('Another button', 't.me/pyromodchat', 'url')] +]) +``` diff --git a/docs/get-started/initialization.md b/docs/get-started/initialization.md new file mode 100644 index 0000000..2868f5c --- /dev/null +++ b/docs/get-started/initialization.md @@ -0,0 +1,18 @@ +## Initializing pyromod + +To initialize pyromod, on the file that creates the client instance, simply import the Client class from pyromod instead +of pyrogram: + +```python +from pyromod import Client +``` + +And that's all! You can still use the `Client` class as you would normally do with Pyrogram, but now having all the +extra features. + +>You don't need to change the imports on the plugins files. Even by importing `Client` from pyrogram, the pyromod features will be available anyway. + +>In order to monkeyatch pyromod features successfully, it's just required that the first `Client` class imported to your project code should be from pyromod. Then all the other future `Client` instances will be patched automatically. + +>On custom plugins, you just need to import Client from pyromod if you want your IDE to recognize and suggest +the extra features based on `pyromod.Client` type. diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md new file mode 100644 index 0000000..83c6b89 --- /dev/null +++ b/docs/get-started/installation.md @@ -0,0 +1,21 @@ +## Installation + +To get started with pyromod, you can install it using pip: + +```bash +pip install pyromod +``` + +Or poetry: + +```bash +poetry add pyromod +``` + +Or rye: + +```bash +rye add pyromod +``` + +!> pyromod requires pyrogram to be installed, since it's a plugin that only does monkeypatching, rather than a standalone fork of pyrogram. \ No newline at end of file diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..f4f7ea5 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,57 @@ + + + + + + + + pyromod docs + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + diff --git a/docs/pyromod/config/index.md b/docs/pyromod/config/index.md new file mode 100644 index 0000000..8f75ea4 --- /dev/null +++ b/docs/pyromod/config/index.md @@ -0,0 +1,26 @@ +### *object* pyromod.config + +The `config` object in Pyromod is a `SimpleNamespace` object that stores configuration settings for your Pyromod-powered +bot. These settings allow you to customize the behavior of your bot according to your specific needs. + +* **Attributes:** + * **timeout_handler:** An optional callback function that can be set to handle timeouts for listeners. + * **stopped_handler:** An optional callback function that can be set to handle when a listener is stopped. + * **throw_exceptions:** A boolean flag that determines whether Pyromod should raise exceptions for certain events. + * **unallowed_click_alert:** A boolean flag that controls whether users should be alerted when they click a button that + doesn't match the filters (i.e. clicking on a button that is supposed for other user to click). + * **unallowed_click_alert_text:** The text to display in the alert when `unallowed_click_alert` is `True`. + +* **Example Usage** + +Here's an example of how to configure Pyromod using the `config` object: + +```python +from pyromod.config import config + +# Set a custom timeout handler function +config.timeout_handler = my_custom_timeout_handler + +# Disable raising exceptions for listener events +config.throw_exceptions = False +``` \ No newline at end of file diff --git a/docs/pyromod/exceptions/listener-stopped.md b/docs/pyromod/exceptions/listener-stopped.md new file mode 100644 index 0000000..b507cb1 --- /dev/null +++ b/docs/pyromod/exceptions/listener-stopped.md @@ -0,0 +1,17 @@ +### *exception* pyromod.exceptions.ListenerStopped + +The `ListenerStopped` exception is raised in Pyromod when a listener is explicitly stopped ( +using `Client.stop_listening`) during bot execution. This +exception is used to indicate that a specific listener was intentionally terminated and will only be raised if +the `throw_exceptions` setting in the Pyromod `config` is set to `True`. + +* **Usage** + +```python +from pyromod.exceptions import ListenerStopped + +try: + message = await Client.listen(...) +except ListenerStopped: + print("Listener was stopped") +``` \ No newline at end of file diff --git a/docs/pyromod/exceptions/listener-timeout.md b/docs/pyromod/exceptions/listener-timeout.md new file mode 100644 index 0000000..35d93d1 --- /dev/null +++ b/docs/pyromod/exceptions/listener-timeout.md @@ -0,0 +1,15 @@ +### *exception* pyromod.exceptions.ListenerTimeout + +The `ListenerTimeout` exception in Pyromod is raised when a listener waits for a specified duration but times out +without receiving the expected event. It is thrown only if the `config.throw_exceptions` attribute is set to `True`. + +* **Usage** + +```python +from pyromod.exceptions import ListenerTimeout + +try: + message = await Client.listen(..., timeout=10) +except ListenerTimeout: + print("Listener timed out") +``` diff --git a/docs/pyromod/helpers/index.md b/docs/pyromod/helpers/index.md new file mode 100644 index 0000000..8e02fbc --- /dev/null +++ b/docs/pyromod/helpers/index.md @@ -0,0 +1,70 @@ +### *function* pyromod.array_chunk(input_array, size) + +Split an array into chunks. + +* **Parameters:** + * **input_array** – The array to split. + * **size** – The size of each chunk. +* **Returns:** + A list of chunks. + +### *function* pyromod.bki(keyboard) + +Deserialize an InlineKeyboardMarkup to a list of lists of buttons. + +* **Parameters:** + **keyboard** – An InlineKeyboardMarkup. +* **Returns:** + A list of lists of buttons. + +### *function* pyromod.btn(text, value, type='callback_data') + +Create an InlineKeyboardButton. + +* **Parameters:** + * **text** – The text of the button. + * **value** – The value of the button. + * **type** – The type of the button. +* **Returns:** + An InlineKeyboardButton. + +### *function* pyromod.force_reply(selective=True) + +Create a ForceReply. + +* **Parameters:** + **selective** – Whether the reply should be selective. +* **Returns:** + A ForceReply. + +### *function* pyromod.ikb(rows=None) + +Create an InlineKeyboardMarkup from a list of lists of buttons. + +* **Parameters:** + **rows** – A list of lists of buttons. +* **Returns:** + An InlineKeyboardMarkup. + +### *function* pyromod.kb(rows=None, \*\*kwargs) + +Create a ReplyKeyboardMarkup from a list of lists of buttons. + +* **Parameters:** + * **rows** – A list of lists of buttons. + * **kwargs** – Keyword arguments to pass to ReplyKeyboardMarkup. +* **Returns:** + A ReplyKeyboardMarkup. + +### *function* pyromod.kbtn + +alias of `KeyboardButton` + +### *function* pyromod.ntb(button) + +Deserialize an InlineKeyboardButton to btn() format. + +* **Parameters:** + **button** – An InlineKeyboardButton. +* **Returns:** + A btn() format button. diff --git a/docs/pyromod/index.md b/docs/pyromod/index.md new file mode 100644 index 0000000..72f3d27 --- /dev/null +++ b/docs/pyromod/index.md @@ -0,0 +1,41 @@ +### *package* pyromod + +This is a concise list of the main modules, objects, helpers, and decorators provided by pyromod. + +- Modules: + - pyromod.config + - pyromod.helpers + - pyromod.listen + - pyromod.nav + - pyromod.utils + - pyromod.exceptions + - pyromod.types + +- Objects: + - pyromod.config.config + - pyromod.listen.Client + - pyromod.listen.Message + - pyromod.listen.Chat + - pyromod.listen.User + - pyromod.nav.Pagination + - pyromod.types.Identifier + - pyromod.types.ListenerTypes + - pyromod.types.Listener + - pyromod.exceptions.ListenerTimeout + - pyromod.exceptions.ListenerStopped + - pyromod.utils.patch_into + - pyromod.utils.should_patch + +- Helpers: + - pyromod.helpers.ikb + - pyromod.helpers.bki + - pyromod.helpers.ntb + - pyromod.helpers.btn + - pyromod.helpers.kb + - pyromod.helpers.kbtn + - pyromod.helpers.array_chunk + - pyromod.helpers.force_reply + +- Decorators: + - pyromod.utils.patch_into(target_class) + - pyromod.utils.should_patch(func) diff --git a/docs/pyromod/listen/chat.md b/docs/pyromod/listen/chat.md new file mode 100644 index 0000000..32a6a66 --- /dev/null +++ b/docs/pyromod/listen/chat.md @@ -0,0 +1,44 @@ +### *class* pyromod.listen.Chat + +Bases: `pyrogram.types.user_and_chats.chat.Chat` + +The `pyromod.listen.Chat` class is an extension of the `pyrogram.types.user_and_chats.chat.Chat` class. It provides additional +methods for working with chats with pyromod. + +### *bound method* listen(*args, **kwargs): + +Listen for a message or a callback query on the chat. This method is a bound method that calls the `listen` method of +the `Client` +class, passing its own `Chat.id` as the `chat_id` parameter. + +**Parameters:** + +* **args** - The arguments to pass to the `Client.listen` method. +* **kwargs** - The keyword arguments to pass to the `Client.listen` method. + +**Returns:** +The message that was listened for. + +### *bound method* ask(text: str, *args, **kwargs): + +Sends a message with the specified text and wait for a response from the same chat. This method is a bound method that +calls +the `ask` method of the `Client` class, passing its own `Chat.id` as the `chat_id` parameter. + +**Parameters:** + +* **text** (*str*) – The text of the message to send. +* **args** - The arguments to pass to the `Client.ask` method. +* **kwargs** - The keyword arguments to pass to the `Client.ask` method. + +**Returns:** +The message that was listened for. In the attribute `request`, you can find Message object of the message that was sent. + +### *bound method* stop_listening() + +Stop listening for messages and/or callback queries. This method is a bound method that calls the `stop_listening` +method +of the `Client` class, passing its own `Chat.id` as the `chat_id` parameter. + +**Returns:** +None diff --git a/docs/pyromod/listen/client.md b/docs/pyromod/listen/client.md new file mode 100644 index 0000000..0738f4d --- /dev/null +++ b/docs/pyromod/listen/client.md @@ -0,0 +1,95 @@ +### *class* pyromod.listen.Client + +Bases: `pyrogram.Client` + +### *async* listen(filters: Filter | None = None, listener_type: [ListenerTypes](/build/markdown/pyromod.types#pyromod.types.listener_types.ListenerTypes) = ListenerTypes.MESSAGE, timeout: int | None = None, unallowed_click_alert: bool = True, chat_id: int | None = None, user_id: int | None = None, message_id: int | None = None, inline_message_id: str | None = None) + +Listen for a message, callback query, etc. + +* **Parameters:** + * **filters** (*pyrogram.filters.Filter* *or* *None*) – A filter to check the incoming message against. + * **listener_type** (*pyromod.types.ListenerTypes*) – The type of listener to listen for. + * **timeout** (*int* *or* *None*) – The maximum amount of time to wait for a message. + * **unallowed_click_alert** (*bool*) – Whether to alert the user if they click a button that doesn’t match the + filters. + * **chat_id** (*int* *or* *None*) – The chat ID to listen for. + * **user_id** (*int* *or* *None*) – The user ID to listen for. + * **message_id** (*int* *or* *None*) – The message ID to listen for. + * **inline_message_id** (*str* *or* *None*) – The inline message ID to listen for. +* **Raises:** + * **pyromod.exceptions.ListenerStopped** – If the listener was stopped. + * **pyromod.exceptions.ListenerTimeout** – If the listener timed out. +* **Returns:** + The message that was listened for. +* **Return type:** + pyrogram.types.Message or pyrogram.types.CallbackQuery + +### *async* ask(chat_id: int, text: str, filters: Filter | None = None, listener_type: [ListenerTypes](/build/markdown/pyromod.types#pyromod.types.listener_types.ListenerTypes) = ListenerTypes.MESSAGE, timeout: int | None = None, unallowed_click_alert: bool = True, user_id: int | None = None, message_id: int | None = None, inline_message_id: str | None = None, \*args, \*\*kwargs) + +Send a message and calls Client.listen to wait for a response. + +* **Parameters:** + * **chat_id** (*int*) – The chat ID to send the message to. It will also be used to listen for a response. + * **text** (*str*) – The text of the message to send. + * **filters** (*pyrogram.filters.Filter* *or* *None*) – A filter to check the incoming message against. + * **listener_type** (*pyromod.types.ListenerTypes*) – The type of listener to listen for. + * **timeout** (*int* *or* *None*) – The maximum amount of time to wait for a message. + * **unallowed_click_alert** (*bool*) – Whether to alert the user if they click a button that doesn’t match the + filters. + * **user_id** (*int* *or* *None*) – The user ID to listen for. + * **message_id** (*int* *or* *None*) – The message ID to listen for. + * **inline_message_id** (*str* *or* *None*) – The inline message ID to listen for. + * **args** – + * **kwargs** – +* **Returns:** + The message that was listened for. In the attribute `request` you can find the message that was sent. +* **Return type:** + pyrogram.types.Message or pyrogram.types.CallbackQuery + +### get_matching_listener(pattern: [Identifier](/build/markdown/pyromod.types#pyromod.types.identifier.Identifier), listener_type: [ListenerTypes](/build/markdown/pyromod.types#pyromod.types.listener_types.ListenerTypes)) + +Get a listener that matches the given pattern. + +* **Parameters:** + * **pattern** (*pyromod.types.Identifier*) – + * **listener_type** (*pyromod.types.ListenerTypes*) – +* **Returns:** + The listener that matches the given pattern. +* **Return type:** + pyromod.types.Listener or None + +### get_many_matching_listeners(pattern: [Identifier](/build/markdown/pyromod.types#pyromod.types.identifier.Identifier), listener_type: [ListenerTypes](/build/markdown/pyromod.types#pyromod.types.listener_types.ListenerTypes)) + +Get all listeners that match the given pattern. + +* **Parameters:** + * **pattern** (*pyromod.types.Identifier*) – + * **listener_type** (*pyromod.types.ListenerTypes*) – +* **Returns:** + All listeners that match the given pattern. +* **Return type:** + list[pyromod.types.Listener] + +### remove_listener(listener: [Listener](/build/markdown/pyromod.types#pyromod.types.listener.Listener)) + +Remove a listener. + +* **Parameters:** + **listener** (*pyromod.types.Listener*) – +* **Returns:** + None + +### stop_listening(listener_type: [ListenerTypes](/build/markdown/pyromod.types#pyromod.types.listener_types.ListenerTypes) = ListenerTypes.MESSAGE, chat_id: int | None = None, user_id: int | None = None, message_id: int | None = None, inline_message_id: str | None = None) + +Stop listening for a message, callback query, etc. + +* **Parameters:** + * **listener_type** (*pyromod.types.ListenerTypes*) – The type of listener to stop listening for. + * **chat_id** (*int* *or* *None*) – The chat ID to stop listening for. + * **user_id** (*int* *or* *None*) – The user ID to stop listening for. + * **message_id** (*int* *or* *None*) – The message ID to stop listening for. + * **inline_message_id** (*str* *or* *None*) – The inline message ID to stop listening for. +* **Returns:** + None + +### listeners*: dict[[ListenerTypes](/build/markdown/pyromod.types#pyromod.types.listener_types.ListenerTypes), list[[Listener](/build/markdown/pyromod.types#pyromod.types.listener.Listener)]]* \ No newline at end of file diff --git a/docs/pyromod/listen/message.md b/docs/pyromod/listen/message.md new file mode 100644 index 0000000..3ae564b --- /dev/null +++ b/docs/pyromod/listen/message.md @@ -0,0 +1,27 @@ +### *class* pyromod.listen.Message + +Bases: `pyrogram.Message` + +The `pyromod.listen.Message` class is an extension of the `pyrogram.Message` class. It provides additional methods for waiting +for user clicks on inline buttons within messages. + +### *async* wait_for_click(from_user_id: int | None = None, timeout: int | None = None, filters: Filter | None = None, + +alert: str | bool = True) + +Wait for a user to click any inline button within the message. This method is a shorthand bound method to +call `Client.listen` with `ListenerTypes.CALLBACK_QUERY` as the `listener_type`. + +**Parameters:** + +* **from_user_id** (*int* *or* *None*) – The user ID to wait for clicks from. If `None`, the method waits for clicks + from any user. +* **timeout** (*int* *or* *None*) – The maximum amount of time to wait for a button click. If `None`, there is no + timeout. +* **filters** (*pyrogram.filters.Filter* *or* *None*) – A filter to check the incoming click event against. Can be used + to filter clicks by the button data. +* **alert** (*str* *or* *bool*) – The alert text to show to users whose ID does not match `from_user_id`. If `True`, the + default alert text is shown. If `False`, no alert is shown. + +**Returns:** +The CallbackQuery object of the button click. diff --git a/docs/pyromod/listen/user.md b/docs/pyromod/listen/user.md new file mode 100644 index 0000000..a8f247f --- /dev/null +++ b/docs/pyromod/listen/user.md @@ -0,0 +1,46 @@ +### *class* pyromod.listen.User + +Bases: `pyrogram.types.user_and_chats.user.User` + +The `pyromod.listen.User` class is an extension of the `pyrogram.types.user_and_chats.user.User` class. It provides additional +methods for working with User objects with pyromod. + +### *bound method* listen(*args, **kwargs): + +Listen for a message or a callback query from the user. This method is a bound method that calls the `listen` method of +the `Client` +class, passing its own `User.id` as the `user_id` parameter. + +**Parameters:** + +* **args** - The arguments to pass to the `Client.listen` method. +* **kwargs** - The keyword arguments to pass to the `Client.listen` method. + +**Returns:** +The message or callback query that was listened for. + +### *bound method* ask(text: str, *args, **kwargs): + +Sends a message with the specified text to the `User.id` as chat_id (i.e. user's private conversation) and wait for a +response from the user on the same chat. This method is a bound +method that calls +the `ask` method of the `Client` class, passing its own `User.id` as both `chat_id` and `user_id` parameters. + +**Parameters:** + +* **text** (*str*) – The text of the message to send. +* **args** - The arguments to pass to the `Client.ask` method. +* **kwargs** - The keyword arguments to pass to the `Client.ask` method. + +**Returns:** +The message that was listened for. In the attribute `request`, you can find the Message object of the message that was +sent. + +### *bound method* stop_listening() + +Stop listening for messages and/or callback queries. This method is a bound method that calls the `stop_listening` +method +of the `Client` class, passing its own `User.id` as the `user_id` parameter. + +**Returns:** +None diff --git a/docs/pyromod/nav/pagination.md b/docs/pyromod/nav/pagination.md new file mode 100644 index 0000000..4b1067b --- /dev/null +++ b/docs/pyromod/nav/pagination.md @@ -0,0 +1,27 @@ +### *class* pyromod.nav.Pagination + +The `pyromod.nav.Pagination` class provides a utility for creating paginated interfaces with customizable pagination +controls. It is designed to handle a list of objects and display them in a paginated manner. + +### *Parameters:* + +- **objects** (*list*) – The list of items to paginate. +- **page_data** (*function*) – A function to customize the data displayed in the pagination controls for each page. The + default function returns the page number as a string. +- **item_data** (*function*) – A function to customize the data displayed for each item in the pagination. The default + function prefixes each item with the page number. +- **item_title** (*function*) – A function to customize the title displayed for each item. The default function prefixes + each item with the page number in square brackets. + +### *create(page, lines=5, columns=1)* + +Creates a paginated interface for the specified page. + +**Parameters:** + +- **page** (*int*) – The page number to display. +- **lines** (*int*) – The number of lines (rows) per page. +- **columns** (*int*) – The number of columns per page. + +**Returns:** +A list of paginated items and pagination controls in the form of button data. diff --git a/docs/pyromod/types/identifier.md b/docs/pyromod/types/identifier.md new file mode 100644 index 0000000..0d06102 --- /dev/null +++ b/docs/pyromod/types/identifier.md @@ -0,0 +1,30 @@ +### *class* pyromod.types.Identifier + +The `pyromod.types.Identifier` class is a dataclass that serves as a utility for matching listeners to the data of updates. + +### *Parameters:* + +- **inline_message_id** (*str or None*) – The inline message ID to match. If `None`, it is not considered for matching. +- **chat_id** (*int or None*) – The chat ID to match. If `None`, it is not considered for matching. +- **message_id** (*int or None*) – The message ID to match. If `None`, it is not considered for matching. +- **from_user_id** (*int or None*) – The user ID to match. If `None`, it is not considered for matching. + +### *matches(other: Identifier) -> bool* + +Compares the `Identifier` with another `Identifier` instance to determine if they match. + +**Parameters:** + +- **other** (*pyromod.types.Identifier*) – The `Identifier` to compare against. + +**Returns:** +`True` if the two `Identifier` instances match, meaning that for each property in `other`, +either the property in the current `Identifier` (self) is `None` (i.e. allowing `other` to have any value) or it has the +same value. `False` otherwise. + +### *count_populated() -> int* + +Counts the number of non-null properties in the `Identifier`. + +**Returns:** +An integer representing the count of non-null properties in the `Identifier`. diff --git a/docs/pyromod/types/listener-types.md b/docs/pyromod/types/listener-types.md new file mode 100644 index 0000000..19324af --- /dev/null +++ b/docs/pyromod/types/listener-types.md @@ -0,0 +1,9 @@ +### *enum* pyromod.types.ListenerTypes + +The `pyromod.types.ListenerTypes` enum defines the various types of listeners that you can use in pyromod. + +### *Member Values:* + +- **MESSAGE** ("message") + +- **CALLBACK_QUERY** ("callback_query") diff --git a/docs/pyromod/types/listener.md b/docs/pyromod/types/listener.md new file mode 100644 index 0000000..dba28a0 --- /dev/null +++ b/docs/pyromod/types/listener.md @@ -0,0 +1,23 @@ +### *class* pyromod.types.Listener + +The `pyromod.types.Listener` class is designed to manage and handle different types of listeners used in pyromod. It enables +you to wait for specific events like messages or callback queries and provides mechanisms for defining the conditions +and filters that trigger these listeners. + +### *Parameters:* + +- **listener_type** (*pyromod.types.ListenerTypes*) – The type of listener that specifies the event you want to listen for. It + can be either a "message" or a "callback_query." + +- **future** (*asyncio.Future*) – A `Future` object representing the asynchronous task that waits for the event. When + the event occurs, the `Future` will be resolved, and the listener will be able to proceed. + +- **filters** (*pyrogram.filters.Filter*) – A filter to check the incoming event against. The listener will only be + triggered when the event matches the provided filter. + +- **unallowed_click_alert** (*bool*) – A flag that determines whether to send an alert if a button click event doesn't + match the filter conditions. Setting this to `True` will send an alert message to the user in such cases. + +- **identifier** (*pyromod.Identifier*) – An `Identifier` instance that defines the criteria for the event. It includes + properties like `chat_id`, `message_id`, `from_user_id`, and `inline_message_id` that you want to match against the + incoming event. diff --git a/docs/pyromod/utils/patch.md b/docs/pyromod/utils/patch.md new file mode 100644 index 0000000..8177f41 --- /dev/null +++ b/docs/pyromod/utils/patch.md @@ -0,0 +1,27 @@ +### *function* pyromod.utils.patch_into(target_class) + +The `pyromod.utils.patch_into` decorator is a function used to facilitate monkeypatching of pyrogram classes with custom +methods from pyromod. + +### *Parameters:* + +- **target_class** (*Type*) - The target class or Pyrogram class to which you want to apply the patch. + +### *Returns:* + +A decorated class containing the patched methods. Each replaced method is now available prefixed with `old` in the +decorated class (e.g. `__init__` becomes `old__init__`). + +### *function* pyromod.utils.should_patch(func) + +The `pyromod.utils.should_patch` decorator is a function used to specify that a method should be patched into a target class. +It marks a method as patchable, indicating that it should be considered for monkeypatching by `pyromod.utils.patch_into`. This +decorator is used in conjunction with the `pyromod.utils.patch_into` decorator. + +### *Parameters:* + +- **func** (*Type*) - The method to be marked as patchable. + +### *Returns:* + +The same method with the `should_patch` attribute set to `True`. diff --git a/pyproject.toml b/pyproject.toml index 350e72e..a9caf05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pyromod" -version = "2.0.0" +version = "3.0.0" description = "A monkeypatcher add-on for Pyrogram that does conversation handling" authors = ["usernein"] license = "LGPLv3+" diff --git a/pyromod/__init__.py b/pyromod/__init__.py index 8ec1a7d..ca54316 100644 --- a/pyromod/__init__.py +++ b/pyromod/__init__.py @@ -18,5 +18,29 @@ along with pyromod. If not, see . """ -from .listen import * -from .utils import PyromodConfig +from .config import config +from .helpers import ikb, bki, ntb, btn, kb, kbtn, array_chunk, force_reply +from .listen import Client, MessageHandler, CallbackQueryHandler, Message, Chat, User +from .nav import Pagination +from .utils import patch_into, should_patch + +__all__ = [ + "config", + "Client", + "MessageHandler", + "Message", + "Chat", + "User", + "CallbackQueryHandler", + "patch_into", + "should_patch", + "ikb", + "bki", + "ntb", + "btn", + "kb", + "kbtn", + "array_chunk", + "force_reply", + "Pagination", +] diff --git a/pyromod/config.py b/pyromod/config.py new file mode 100644 index 0000000..03b2afc --- /dev/null +++ b/pyromod/config.py @@ -0,0 +1,9 @@ +from types import SimpleNamespace + +config = SimpleNamespace( + timeout_handler=None, + stopped_handler=None, + throw_exceptions=True, + unallowed_click_alert=True, + unallowed_click_alert_text=("[pyromod] You're not expected to click this button."), +) diff --git a/pyromod/config/__init__.py b/pyromod/config/__init__.py new file mode 100644 index 0000000..e254733 --- /dev/null +++ b/pyromod/config/__init__.py @@ -0,0 +1,11 @@ +from types import SimpleNamespace + +config = SimpleNamespace( + timeout_handler=None, + stopped_handler=None, + throw_exceptions=True, + unallowed_click_alert=True, + unallowed_click_alert_text=("[pyromod] You're not expected to click this button."), +) + +__all__ = ["config"] diff --git a/pyromod/exceptions/__init__.py b/pyromod/exceptions/__init__.py new file mode 100644 index 0000000..00921aa --- /dev/null +++ b/pyromod/exceptions/__init__.py @@ -0,0 +1,4 @@ +from .listener_stopped import ListenerStopped +from .listener_timeout import ListenerTimeout + +__all__ = ["ListenerStopped", "ListenerTimeout"] diff --git a/pyromod/exceptions/listener_stopped.py b/pyromod/exceptions/listener_stopped.py new file mode 100644 index 0000000..05a1016 --- /dev/null +++ b/pyromod/exceptions/listener_stopped.py @@ -0,0 +1,2 @@ +class ListenerStopped(Exception): + pass diff --git a/pyromod/exceptions/listener_timeout.py b/pyromod/exceptions/listener_timeout.py new file mode 100644 index 0000000..9c2accd --- /dev/null +++ b/pyromod/exceptions/listener_timeout.py @@ -0,0 +1,2 @@ +class ListenerTimeout(Exception): + pass diff --git a/pyromod/helpers/__init__.py b/pyromod/helpers/__init__.py index 3c93d28..c76bc0d 100644 --- a/pyromod/helpers/__init__.py +++ b/pyromod/helpers/__init__.py @@ -17,4 +17,6 @@ You should have received a copy of the GNU General Public License along with pyromod. If not, see . """ -from .helpers import ikb, bki, ntb, btn, kb, kbtn, array_chunk, force_reply \ No newline at end of file +from .helpers import ikb, bki, ntb, btn, kb, kbtn, array_chunk, force_reply + +__all__ = ["ikb", "bki", "ntb", "btn", "kb", "kbtn", "array_chunk", "force_reply"] diff --git a/pyromod/helpers/helpers.py b/pyromod/helpers/helpers.py index 941f479..b8c0a60 100644 --- a/pyromod/helpers/helpers.py +++ b/pyromod/helpers/helpers.py @@ -7,13 +7,16 @@ ) -def ikb(rows=[]): +def ikb(rows=None): + if rows is None: + rows = [] + lines = [] for row in rows: line = [] for button in row: button = ( - btn(button, button) if type(button) == str else btn(*button) + btn(button, button) if isinstance(button, str) else btn(*button) ) # InlineKeyboardButton line.append(button) lines.append(line) @@ -57,7 +60,10 @@ def ntb(button): # return {'text': text, type: value} -def kb(rows=[], **kwargs): +def kb(rows=None, **kwargs): + if rows is None: + rows = [] + lines = [] for row in rows: line = [] @@ -80,5 +86,5 @@ def force_reply(selective=True): return ForceReply(selective=selective) -def array_chunk(input, size): - return [input[i : i + size] for i in range(0, len(input), size)] +def array_chunk(input_array, size): + return [input_array[i : i + size] for i in range(0, len(input_array), size)] diff --git a/pyromod/listen/__init__.py b/pyromod/listen/__init__.py index 3c3e75d..2779d69 100644 --- a/pyromod/listen/__init__.py +++ b/pyromod/listen/__init__.py @@ -18,4 +18,18 @@ along with pyromod. If not, see . """ -from .listen import Client, MessageHandler, Chat, User \ No newline at end of file +from .callback_query_handler import CallbackQueryHandler +from .chat import Chat +from .client import Client +from .message import Message +from .message_handler import MessageHandler +from .user import User + +__all__ = [ + "Client", + "MessageHandler", + "Message", + "Chat", + "User", + "CallbackQueryHandler", +] diff --git a/pyromod/listen/callback_query_handler.py b/pyromod/listen/callback_query_handler.py new file mode 100644 index 0000000..82466ea --- /dev/null +++ b/pyromod/listen/callback_query_handler.py @@ -0,0 +1,116 @@ +from typing import Callable + +import pyrogram +from pyrogram.filters import Filter +from pyrogram.types import CallbackQuery + +from .client import Client +from ..config import config +from ..types import ListenerTypes, Identifier, Listener +from ..utils import patch_into, should_patch + + +@patch_into(pyrogram.handlers.callback_query_handler.CallbackQueryHandler) +class CallbackQueryHandler( + pyrogram.handlers.callback_query_handler.CallbackQueryHandler +): + old__init__: Callable + + @should_patch() + def __init__(self, callback: Callable, filters: Filter = None): + self.original_callback = callback + self.old__init__(self.resolve_future, filters) + + @should_patch() + def compose_data_identifier(self, query: CallbackQuery): + from_user = query.from_user + from_user_id = from_user.id if from_user else None + + chat_id = None + message_id = None + + if query.message: + message_id = getattr( + query.message, "id", getattr(query.message, "message_id", None) + ) + + if query.message.chat: + chat_id = query.message.chat.id + + return Identifier( + message_id=message_id, + chat_id=chat_id, + from_user_id=from_user_id, + inline_message_id=query.inline_message_id, + ) + + @should_patch() + async def check_if_has_matching_listener( + self, client: Client, query: CallbackQuery + ) -> tuple[bool, Listener]: + data = self.compose_data_identifier(query) + + listener = client.get_matching_listener(data, ListenerTypes.MESSAGE) + + listener_does_match = False + + if listener: + filters = listener.filters + listener_does_match = ( + await filters(client, query) if callable(filters) else True + ) + + return listener_does_match, listener + + @should_patch() + async def check(self, client: Client, query: CallbackQuery): + listener_does_match, listener = await self.check_if_has_matching_listener( + client, query + ) + + handler_does_match = ( + await self.filters(client, query) if callable(self.filters) else True + ) + + data = self.compose_data_identifier(query) + + if config.unallowed_click_alert: + # matches with the current query but from any user + permissive_identifier = Identifier( + chat_id=data.chat_id, + message_id=data.message_id, + inline_message_id=data.inline_message_id, + from_user_id=None, + ) + + matches = permissive_identifier.matches(data) + + if ( + listener + and (matches and not listener_does_match) + and listener.unallowed_click_alert + ): + alert = ( + listener.unallowed_click_alert + if isinstance(listener.unallowed_click_alert, str) + else config.unallowed_click_alert_text + ) + await query.answer(alert) + return False + + # let handler get the chance to handle if listener + # exists but its filters doesn't match + return listener_does_match or handler_does_match + + @should_patch() + async def resolve_future(self, client: Client, query: CallbackQuery, *args): + listener_does_match, listener = await self.check_if_has_matching_listener( + client, query + ) + + if listener and not listener.future.done(): + listener.future.set_result(query) + client.remove_listener(listener) + raise pyrogram.StopPropagation + else: + await self.original_callback(client, query, *args) diff --git a/pyromod/listen/chat.py b/pyromod/listen/chat.py new file mode 100644 index 0000000..7cf5495 --- /dev/null +++ b/pyromod/listen/chat.py @@ -0,0 +1,21 @@ +import pyrogram + +from .client import Client +from ..utils import patch_into, should_patch + + +@patch_into(pyrogram.types.user_and_chats.chat.Chat) +class Chat(pyrogram.types.user_and_chats.chat.Chat): + _client: Client + + @should_patch() + def listen(self, *args, **kwargs): + return self._client.listen(*args, chat_id=self.id, **kwargs) + + @should_patch() + def ask(self, text, *args, **kwargs): + return self._client.ask(self.id, text, *args, **kwargs) + + @should_patch() + def stop_listening(self, *args, **kwargs): + return self._client.stop_listening(*args, chat_id=self.id, **kwargs) diff --git a/pyromod/listen/client.py b/pyromod/listen/client.py new file mode 100644 index 0000000..616e4c2 --- /dev/null +++ b/pyromod/listen/client.py @@ -0,0 +1,161 @@ +import asyncio +from typing import Optional, Callable, Dict, List + +import pyrogram +from pyrogram.filters import Filter + +from ..config import config +from ..exceptions import ListenerTimeout, ListenerStopped +from ..types import ListenerTypes, Identifier, Listener +from ..utils import should_patch, patch_into + + +@patch_into(pyrogram.client.Client) +class Client(pyrogram.client.Client): + listeners: Dict[ListenerTypes, List[Listener]] + old__init__: Callable + + @should_patch() + def __init__(self, *args, **kwargs): + self.listeners = {listener_type: [] for listener_type in ListenerTypes} + self.old__init__(*args, **kwargs) + + @should_patch() + async def listen( + self, + filters: Optional[Filter] = None, + listener_type: ListenerTypes = ListenerTypes.MESSAGE, + timeout: Optional[int] = None, + unallowed_click_alert: bool = True, + chat_id: int = None, + user_id: int = None, + message_id: int = None, + inline_message_id: str = None, + ): + pattern = Identifier( + from_user_id=user_id, + chat_id=chat_id, + message_id=message_id, + inline_message_id=inline_message_id, + ) + + loop = asyncio.get_event_loop() + future = loop.create_future() + future.add_done_callback( + lambda f: self.stop_listening( + listener_type, + user_id=user_id, + chat_id=chat_id, + message_id=message_id, + inline_message_id=inline_message_id, + ) + ) + + listener = Listener( + future=future, + filters=filters, + unallowed_click_alert=unallowed_click_alert, + identifier=pattern, + listener_type=listener_type, + ) + + self.listeners[listener_type].append(listener) + + try: + return await asyncio.wait_for(future, timeout) + except asyncio.exceptions.TimeoutError: + if callable(config.timeout_handler): + config.timeout_handler(pattern, listener, timeout) + elif config.throw_exceptions: + raise ListenerTimeout(timeout) + + @should_patch() + async def ask( + self, + chat_id: int, + text: str, + filters: Optional[Filter] = None, + listener_type: ListenerTypes = ListenerTypes.MESSAGE, + timeout: Optional[int] = None, + unallowed_click_alert: bool = True, + user_id: int = None, + message_id: int = None, + inline_message_id: str = None, + *args, + **kwargs, + ): + sent_message = None + if text.strip() != "": + sent_message = await self.send_message(chat_id, text, *args, **kwargs) + + response = await self.listen( + filters=filters, + listener_type=listener_type, + timeout=timeout, + unallowed_click_alert=unallowed_click_alert, + chat_id=chat_id, + user_id=user_id, + message_id=message_id, + inline_message_id=inline_message_id, + ) + if response: + response.sent_message = sent_message + + return response + + @should_patch() + def get_matching_listener( + self, pattern: Identifier, listener_type: ListenerTypes + ) -> Optional[Listener]: + matching = [] + for listener in self.listeners[listener_type]: + if listener.identifier.matches(pattern): + matching.append(listener) + + # in case of multiple matching listeners, the most specific should be returned + def count_populated_attributes(listener_item: Listener): + return listener_item.identifier.count_populated() + + return max(matching, key=count_populated_attributes, default=None) + + @should_patch() + def remove_listener(self, listener: Listener): + self.listeners[listener.listener_type].remove(listener) + + @should_patch() + def get_many_matching_listeners( + self, pattern: Identifier, listener_type: ListenerTypes + ) -> List[Listener]: + listeners = [] + for listener in self.listeners[listener_type]: + if listener.identifier.matches(pattern): + listeners.append(listener) + return listeners + + @should_patch() + def stop_listening( + self, + listener_type: ListenerTypes = ListenerTypes.MESSAGE, + chat_id: int = None, + user_id: int = None, + message_id: int = None, + inline_message_id: str = None, + ): + pattern = Identifier( + from_user_id=user_id, + chat_id=chat_id, + message_id=message_id, + inline_message_id=inline_message_id, + ) + listeners = self.get_many_matching_listeners(pattern, listener_type) + + for listener in listeners: + self.remove_listener(listener) + + if listener.future.done(): + return + + if callable(config.stopped_handler): + config.stopped_handler(pattern, listener) + elif config.throw_exceptions: + listener.future.set_exception(ListenerStopped()) diff --git a/pyromod/listen/listen.py b/pyromod/listen/listen.py deleted file mode 100644 index 39bf40d..0000000 --- a/pyromod/listen/listen.py +++ /dev/null @@ -1,413 +0,0 @@ -""" -pyromod - A monkeypatcher add-on for Pyrogram -Copyright (C) 2020 Cezar H. - -This file is part of pyromod. - -pyromod 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. - -pyromod 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 pyromod. If not, see . -""" - -import asyncio -import pyrogram -import logging - -from inspect import iscoroutinefunction -from typing import Optional, Callable, Union -from enum import Enum - -from ..utils import patch, patchable, PyromodConfig - -logger = logging.getLogger(__name__) - -class ListenerStopped(Exception): - pass - - -class ListenerTimeout(Exception): - pass - - -class ListenerTypes(Enum): - MESSAGE = "message" - CALLBACK_QUERY = "callback_query" - - -@patch(pyrogram.client.Client) -class Client: - @patchable() - def __init__(self, *args, **kwargs): - self.listeners = {listener_type: {} for listener_type in ListenerTypes} - self.old__init__(*args, **kwargs) - - @patchable() - async def listen( - self, - identifier: tuple, - filters=None, - listener_type=ListenerTypes.MESSAGE, - timeout=None, - unallowed_click_alert=True, - ): - if type(listener_type) != ListenerTypes: - raise TypeError( - "Parameter listener_type should be a" - " value from pyromod.listen.ListenerTypes" - ) - - future = self.loop.create_future() - future.add_done_callback( - lambda f: self.stop_listening(identifier, listener_type) - ) - - listener_data = { - "future": future, - "filters": filters, - "unallowed_click_alert": unallowed_click_alert, - } - - self.listeners[listener_type].update({identifier: listener_data}) - - try: - return await asyncio.wait_for(future, timeout) - except asyncio.exceptions.TimeoutError: - if callable(PyromodConfig.timeout_handler): - PyromodConfig.timeout_handler( - identifier, listener_data, timeout - ) - elif PyromodConfig.throw_exceptions: - raise ListenerTimeout(timeout) - - @patchable() - async def ask( - self, - text, - identifier: tuple, - filters=None, - listener_type=ListenerTypes.MESSAGE, - timeout=None, - *args, - **kwargs, - ): - if text.strip() != "": - sent_message = await self.send_message(identifier[0], text, *args, **kwargs) - response = await self.listen( - identifier, filters, listener_type, timeout - ) - if response: - response.sent_message = sent_message - - return response - - """ - needed for matching when message_id or - user_id is null, and to take precedence - """ - - @patchable() - def match_listener( - self, - data: Optional[tuple] = None, - listener_type: ListenerTypes = ListenerTypes.MESSAGE, - identifier_pattern: Optional[tuple] = None, - ) -> tuple: - if data: - listeners = self.listeners[listener_type] - # case with 3 args on identifier - # most probably waiting for a specific user - # to click a button in a specific message - if data in listeners: - return listeners[data], data - - # cases with 2 args on identifier - # (None, user, message) does not make - # sense since the message_id is not unique - elif (data[0], data[1], None) in listeners: - matched = (data[0], data[1], None) - elif (data[0], None, data[2]) in listeners: - matched = (data[0], None, data[2]) - - # cases with 1 arg on identifier - # (None, None, message) does not make sense as well - elif (data[0], None, None) in listeners: - matched = (data[0], None, None) - elif (None, data[1], None) in listeners: - matched = (None, data[1], None) - else: - return None, None - - return listeners[matched], matched - elif identifier_pattern: - - def match_identifier(pattern, identifier): - comparison = ( - pattern[0] in (identifier[0], None), - pattern[1] in (identifier[1], None), - pattern[2] in (identifier[2], None), - ) - return comparison == (True, True, True) - - for identifier, listener in self.listeners[listener_type].items(): - if match_identifier(identifier_pattern, identifier): - return listener, identifier - return None, None - - @patchable() - def stop_listening( - self, - data: Optional[tuple] = None, - listener_type: ListenerTypes = ListenerTypes.MESSAGE, - identifier_pattern: Optional[tuple] = None, - ): - listener, identifier = self.match_listener( - data, listener_type, identifier_pattern - ) - - if not listener: - return - elif listener["future"].done(): - del self.listeners[listener_type][identifier] - return - - if callable(PyromodConfig.stopped_handler): - PyromodConfig.stopped_handler(identifier, listener) - elif PyromodConfig.throw_exceptions: - listener["future"].set_exception(ListenerStopped()) - - del self.listeners[listener_type][identifier] - - -@patch(pyrogram.handlers.message_handler.MessageHandler) -class MessageHandler: - @patchable() - def __init__(self, callback: Callable, filters=None): - self.registered_handler = callback - self.old__init__(self.resolve_future, filters) - - @patchable() - async def check(self, client, message): - if user := getattr(message, "from_user", None): - user = user.id - try: - listener = client.match_listener( - (message.chat.id, user, getattr(message, "id", getattr(message, "message_id", None))), - ListenerTypes.MESSAGE, - )[0] - except AttributeError as err: - logger.warning(f"Get : {err}\n\n{message}") - raise err - - listener_does_match = handler_does_match = False - - if listener: - filters = listener["filters"] - if callable(filters): - if iscoroutinefunction(filters.__call__): - listener_does_match = await filters(client, message) - else: - listener_does_match = await client.loop.run_in_executor( - None, filters, client, message - ) - else: - listener_does_match = True - - if callable(self.filters): - if iscoroutinefunction(self.filters.__call__): - handler_does_match = await self.filters(client, message) - else: - handler_does_match = await client.loop.run_in_executor( - None, self.filters, client, message - ) - else: - handler_does_match = True - - # let handler get the chance to handle if listener - # exists but its filters doesn't match - return listener_does_match or handler_does_match - - @patchable() - async def resolve_future(self, client, message, *args): - listener_type = ListenerTypes.MESSAGE - - if not message.from_user: - message_from_user_id = None - else: - message_from_user_id = message.from_user.id - - - data = ( - message.chat.id, - message_from_user_id, - getattr(message, "id", getattr(message, "message_id", None)), - ) - - - listener, identifier = client.match_listener( - data, - listener_type, - ) - listener_does_match = False - if listener: - filters = listener["filters"] - if callable(filters): - if iscoroutinefunction(filters.__call__): - listener_does_match = await filters(client, message) - else: - listener_does_match = await client.loop.run_in_executor( - None, filters, client, message - ) - else: - listener_does_match = True - - if listener_does_match: - if not listener["future"].done(): - listener["future"].set_result(message) - del client.listeners[listener_type][identifier] - raise pyrogram.StopPropagation - else: - await self.registered_handler(client, message, *args) - - -@patch(pyrogram.handlers.callback_query_handler.CallbackQueryHandler) -class CallbackQueryHandler: - @patchable() - def __init__(self, callback: Callable, filters=None): - self.registered_handler = callback - self.old__init__(self.resolve_future, filters) - - @patchable() - async def check(self, client, query): - chatID, mID = None, None - if message := getattr(query, "message", None): - chatID, mID = message.chat.id, getattr(query.message, "id", getattr(query.message, "message_id", None)) - try: - listener = client.match_listener( - (chatID, query.from_user.id, mID), - ListenerTypes.CALLBACK_QUERY, - )[0] - except AttributeError as err: - logger.warning(f"Get : {err}\n\n{message}") - raise err - - # managing unallowed user clicks - if PyromodConfig.unallowed_click_alert: - permissive_listener = client.match_listener( - identifier_pattern=( - chatID, - None, - mID, - ), - listener_type=listener_type, - )[0] - - if (permissive_listener and not listener) and permissive_listener[ - "unallowed_click_alert" - ]: - alert = ( - permissive_listener["unallowed_click_alert"] - if type(permissive_listener["unallowed_click_alert"]) - == str - else PyromodConfig.unallowed_click_alert_text - ) - await query.answer(alert) - return False - - filters = listener["filters"] if listener else self.filters - - if callable(filters): - if iscoroutinefunction(filters.__call__): - return await filters(client, query) - else: - return await client.loop.run_in_executor( - None, filters, client, query - ) - else: - return True - - @patchable() - async def resolve_future(self, client, query, *args): - data = ( - query.message.chat.id, - query.from_user.id, - getattr(query.message, "id", getattr(query.message, "message_id", None)), - ) - listener_type = ListenerTypes.CALLBACK_QUERY - chatID, mID = None, None - if message := getattr(query, "message", None): - chatID, mID = message.chat.id, getattr(query.message, "id", getattr(query.message, "message_id", None)) - listener, identifier = client.match_listener( - (chatID, query.from_user.id, mID), - listener_type, - ) - - if listener and not listener["future"].done(): - listener["future"].set_result(query) - del client.listeners[listener_type][identifier] - else: - await self.registered_handler(client, query, *args) - - -@patch(pyrogram.types.messages_and_media.message.Message) -class Message(pyrogram.types.messages_and_media.message.Message): - @patchable() - async def wait_for_click( - self, - from_user_id: Optional[int] = None, - timeout: Optional[int] = None, - filters=None, - alert: Union[str, bool] = True, - ): - msg_id = getattr(self, "id", getattr(self, "message_id", None)) - return await self._client.listen( - (self.chat.id, from_user_id, msg_id), - listener_type=ListenerTypes.CALLBACK_QUERY, - timeout=timeout, - filters=filters, - unallowed_click_alert=alert, - ) - - -@patch(pyrogram.types.user_and_chats.chat.Chat) -class Chat(pyrogram.types.Chat): - @patchable() - def listen(self, *args, **kwargs): - return self._client.listen((self.id, None, None), *args, **kwargs) - - @patchable() - def ask(self, text, *args, **kwargs): - return self._client.ask(text, (self.id, None, None), *args, **kwargs) - - @patchable() - def stop_listening(self, *args, **kwargs): - return self._client.stop_listening( - *args, identifier_pattern=(self.id, None, None), **kwargs - ) - - -@patch(pyrogram.types.user_and_chats.user.User) -class User(pyrogram.types.User): - @patchable() - def listen(self, *args, **kwargs): - return self._client.listen((None, self.id, None), *args, **kwargs) - - @patchable() - def ask(self, text, *args, **kwargs): - return self._client.ask( - text, (self.id, self.id, None), *args, **kwargs - ) - - @patchable() - def stop_listening(self, *args, **kwargs): - return self._client.stop_listening( - *args, identifier_pattern=(None, self.id, None), **kwargs - ) diff --git a/pyromod/listen/message.py b/pyromod/listen/message.py new file mode 100644 index 0000000..4f187a7 --- /dev/null +++ b/pyromod/listen/message.py @@ -0,0 +1,32 @@ +from typing import Optional, Union + +import pyrogram + +from .client import Client +from ..types import ListenerTypes +from ..utils import patch_into, should_patch + + +@patch_into(pyrogram.types.messages_and_media.message.Message) +class Message(pyrogram.types.messages_and_media.message.Message): + _client = Client + + @should_patch() + async def wait_for_click( + self, + from_user_id: Optional[int] = None, + timeout: Optional[int] = None, + filters=None, + alert: Union[str, bool] = True, + ): + message_id = getattr(self, "id", getattr(self, "message_id", None)) + + return await self._client.listen( + listener_type=ListenerTypes.CALLBACK_QUERY, + timeout=timeout, + filters=filters, + unallowed_click_alert=alert, + chat_id=self.chat.id, + user_id=from_user_id, + message_id=message_id, + ) diff --git a/pyromod/listen/message_handler.py b/pyromod/listen/message_handler.py new file mode 100644 index 0000000..db61aea --- /dev/null +++ b/pyromod/listen/message_handler.py @@ -0,0 +1,71 @@ +from typing import Callable + +import pyrogram +from pyrogram.filters import Filter +from pyrogram.types import Message + +from .client import Client +from ..types import ListenerTypes, Identifier +from ..utils import should_patch, patch_into + + +@patch_into(pyrogram.handlers.message_handler.MessageHandler) +class MessageHandler(pyrogram.handlers.message_handler.MessageHandler): + filters: Filter + old__init__: Callable + + @should_patch() + def __init__(self, callback: Callable, filters: Filter = None): + self.original_callback = callback + self.old__init__(self.resolve_future, filters) + + @should_patch() + async def check_if_has_matching_listener(self, client: Client, message: Message): + from_user = message.from_user + from_user_id = from_user.id if from_user else None + + message_id = getattr(message, "id", getattr(message, "message_id", None)) + + data = Identifier( + message_id=message_id, chat_id=message.chat.id, from_user_id=from_user_id + ) + + listener = client.get_matching_listener(data, ListenerTypes.MESSAGE) + + listener_does_match = False + + if listener: + filters = listener.filters + listener_does_match = ( + await filters(client, message) if callable(filters) else True + ) + + return listener_does_match, listener + + @should_patch() + async def check(self, client: Client, message: Message): + listener_does_match = ( + await self.check_if_has_matching_listener(client, message) + )[0] + + handler_does_match = ( + await self.filters(client, message) if callable(self.filters) else True + ) + + # let handler get the chance to handle if listener + # exists but its filters doesn't match + return listener_does_match or handler_does_match + + @should_patch() + async def resolve_future(self, client: Client, message: Message, *args): + listener_does_match, listener = await self.check_if_has_matching_listener( + client, message + ) + + if listener_does_match: + if not listener.future.done(): + listener.future.set_result(message) + client.remove_listener(listener) + raise pyrogram.StopPropagation + else: + await self.original_callback(client, message, *args) diff --git a/pyromod/listen/user.py b/pyromod/listen/user.py new file mode 100644 index 0000000..2e1d03e --- /dev/null +++ b/pyromod/listen/user.py @@ -0,0 +1,21 @@ +import pyrogram + +from .client import Client +from ..utils import patch_into, should_patch + + +@patch_into(pyrogram.types.user_and_chats.user.User) +class User(pyrogram.types.user_and_chats.user.User): + _client: Client + + @should_patch() + def listen(self, *args, **kwargs): + return self._client.listen(*args, user_id=self.id, **kwargs) + + @should_patch() + def ask(self, text, *args, **kwargs): + return self._client.ask(self.id, text, *args, user_id=self.id, **kwargs) + + @should_patch() + def stop_listening(self, *args, **kwargs): + return self._client.stop_listening(*args, user_id=self.id, **kwargs) diff --git a/pyromod/nav/__init__.py b/pyromod/nav/__init__.py index 2b94bbd..95ea9c1 100644 --- a/pyromod/nav/__init__.py +++ b/pyromod/nav/__init__.py @@ -17,4 +17,6 @@ You should have received a copy of the GNU General Public License along with pyromod. If not, see . """ -from .pagination import Pagination \ No newline at end of file +from .pagination import Pagination + +__all__ = ["Pagination"] diff --git a/pyromod/nav/pagination.py b/pyromod/nav/pagination.py index 37a4d7e..e9de53d 100644 --- a/pyromod/nav/pagination.py +++ b/pyromod/nav/pagination.py @@ -18,68 +18,79 @@ along with pyromod. If not, see . """ import math + from ..helpers import array_chunk + class Pagination: def __init__(self, objects, page_data=None, item_data=None, item_title=None): - default_page_callback = (lambda x: str(x)) - default_item_callback = (lambda i, pg: f'[{pg}] {i}') + def default_page_callback(x): + return str(x) + + def default_item_callback(i, pg): + return f"[{pg}] {i}" + self.objects = objects self.page_data = page_data or default_page_callback self.item_data = item_data or default_item_callback self.item_title = item_title or default_item_callback - + def create(self, page, lines=5, columns=1): - quant_per_page = lines*columns + quant_per_page = lines * columns page = 1 if page <= 0 else page - offset = (page-1)*quant_per_page - stop = offset+quant_per_page + offset = (page - 1) * quant_per_page + stop = offset + quant_per_page cutted = self.objects[offset:stop] - + total = len(self.objects) - pages_range = [*range(1, math.ceil(total/quant_per_page)+1)] # each item is a page + pages_range = [ + *range(1, math.ceil(total / quant_per_page) + 1) + ] # each item is a page last_page = len(pages_range) - - + nav = [] if page <= 3: - for n in [1,2,3]: + for n in [1, 2, 3]: if n not in pages_range: continue text = f"· {n} ·" if n == page else n - nav.append( (text, self.page_data(n)) ) + nav.append((text, self.page_data(n))) if last_page >= 4: - nav.append( - ('4 ›' if last_page > 5 else 4, self.page_data(4)) - ) + nav.append(("4 ›" if last_page > 5 else 4, self.page_data(4))) if last_page > 4: nav.append( - (f'{last_page} »' if last_page > 5 else last_page, self.page_data(last_page)) + ( + f"{last_page} »" if last_page > 5 else last_page, + self.page_data(last_page), + ) ) - elif page >= last_page-2: - nav.extend([ - (f'« 1' if last_page-4 > 1 else 1, self.page_data(1)), - (f'‹ {last_page-3}' if last_page-4 > 1 else last_page-3, self.page_data(last_page-3)) - ]) - for n in range(last_page-2, last_page+1): + elif page >= last_page - 2: + nav.extend( + [ + ("« 1" if last_page - 4 > 1 else 1, self.page_data(1)), + ( + f"‹ {last_page - 3}" if last_page - 4 > 1 else last_page - 3, + self.page_data(last_page - 3), + ), + ] + ) + for n in range(last_page - 2, last_page + 1): text = f"· {n} ·" if n == page else n - nav.append( (text, self.page_data(n)) ) + nav.append((text, self.page_data(n))) else: nav = [ - (f'« 1', self.page_data(1)), - (f'‹ {page-1}', self.page_data(page-1)), - (f'· {page} ·', "noop"), - (f'{page+1} ›', self.page_data(page+1)), - (f'{last_page} »', self.page_data(last_page)), + ("« 1", self.page_data(1)), + (f"‹ {page - 1}", self.page_data(page - 1)), + (f"· {page} ·", "noop"), + (f"{page + 1} ›", self.page_data(page + 1)), + (f"{last_page} »", self.page_data(last_page)), ] - + buttons = [] for item in cutted: - buttons.append( - (self.item_title(item, page), self.item_data(item, page)) - ) + buttons.append((self.item_title(item, page), self.item_data(item, page))) kb_lines = array_chunk(buttons, columns) if last_page > 1: kb_lines.append(nav) - - return kb_lines \ No newline at end of file + + return kb_lines diff --git a/pyromod/types/__init__.py b/pyromod/types/__init__.py new file mode 100644 index 0000000..d630313 --- /dev/null +++ b/pyromod/types/__init__.py @@ -0,0 +1,5 @@ +from .identifier import Identifier +from .listener import Listener +from .listener_types import ListenerTypes + +__all__ = ["Identifier", "Listener", "ListenerTypes"] diff --git a/pyromod/types/identifier.py b/pyromod/types/identifier.py new file mode 100644 index 0000000..8e9b58f --- /dev/null +++ b/pyromod/types/identifier.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class Identifier: + inline_message_id: Optional[str] = None + chat_id: Optional[int] = None + message_id: Optional[int] = None + from_user_id: Optional[int] = None + + def matches(self, other: "Identifier") -> bool: + # Compare each property of other with the corresponding property in self + # If the property in self is None, the property in other can be anything + # If the property in self is not None, the property in other must be the same + for field in self.__annotations__: + self_value = getattr(self, field) + other_value = getattr(other, field) + + if self_value is not None and other_value != self_value: + return False + return True + + def count_populated(self): + non_null_count = 0 + + for attr in self.__annotations__: + if getattr(self, attr) is not None: + non_null_count += 1 + + return non_null_count diff --git a/pyromod/types/listener.py b/pyromod/types/listener.py new file mode 100644 index 0000000..f7828c2 --- /dev/null +++ b/pyromod/types/listener.py @@ -0,0 +1,16 @@ +from asyncio import Future +from dataclasses import dataclass + +from pyrogram.filters import Filter + +from .identifier import Identifier +from .listener_types import ListenerTypes + + +@dataclass +class Listener: + listener_type: ListenerTypes + future: Future + filters: Filter + unallowed_click_alert: bool + identifier: Identifier diff --git a/pyromod/types/listener_types.py b/pyromod/types/listener_types.py new file mode 100644 index 0000000..5b7c41d --- /dev/null +++ b/pyromod/types/listener_types.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class ListenerTypes(Enum): + MESSAGE = "message" + CALLBACK_QUERY = "callback_query" diff --git a/pyromod/utils/__init__.py b/pyromod/utils/__init__.py index c79b2d7..3264934 100644 --- a/pyromod/utils/__init__.py +++ b/pyromod/utils/__init__.py @@ -18,4 +18,6 @@ along with pyromod. If not, see . """ -from .utils import patch, patchable, PyromodConfig +from .patch import patch_into, should_patch + +__all__ = ["patch_into", "should_patch"] diff --git a/pyromod/utils/utils.py b/pyromod/utils/patch.py similarity index 70% rename from pyromod/utils/utils.py rename to pyromod/utils/patch.py index eb1eb3e..ca2259e 100644 --- a/pyromod/utils/utils.py +++ b/pyromod/utils/patch.py @@ -1,118 +1,116 @@ -""" -pyromod - A monkeypatcher add-on for Pyrogram -Copyright (C) 2020 Cezar H. - -This file is part of pyromod. - -pyromod 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. - -pyromod 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 pyromod. If not, see . -""" -from typing import Callable -from logging import getLogger -from inspect import iscoroutinefunction -from contextlib import contextmanager, asynccontextmanager - -from pyrogram.sync import async_to_sync - -logger = getLogger(__name__) - -class PyromodConfig: - timeout_handler = None - stopped_handler = None - throw_exceptions = True - unallowed_click_alert = True - unallowed_click_alert_text = ( - "[pyromod] You're not expected to click this button." - ) - - -def patch(obj): - def is_patchable(item): - # item = (name, value) - # item[0] = name - # item[1] = value - return getattr(item[1], "patchable", False) - - def wrapper(container): - for name, func in filter(is_patchable, container.__dict__.items()): - old = getattr(obj, name, None) - if old is not None: # Not adding 'old' to new func - setattr(obj, "old" + name, old) - - # Worse Code - tempConf = {i: getattr(func, i, False) for i in ["is_property", "is_static", "is_context"]} - - async_to_sync(container, name) - func = getattr(container, name) - - for tKey, tValue in tempConf.items(): - setattr(func, tKey, tValue) - - if func.is_property: - func = property(func) - elif func.is_static: - func = staticmethod(func) - elif func.is_context: - if iscoroutinefunction(func.__call__): - func = asynccontextmanager(func) - else: - func = contextmanager(func) - - logger.info(f"Patch Attribute To {obj.__name__} From {container.__name__} : {name}") - setattr(obj, name, func) - return container - - return wrapper - - -def patchable(is_property: bool = False, is_static: bool = False, is_context: bool = False) -> Callable: - """ - A decorator that marks a function as patchable. - - Usage: - - @patchable(is_property=True) - def my_property(): - ... - - @patchable(is_static=True) - def my_static_method(): - ... - - @patchable(is_context=True) - def my_context_manager(): - ... - - @patchable(is_property=False, is_static=False, is_context=False) - def my_function(): - ... - - @patchable() - def default_usage(): - ... - - Parameters: - - is_property (bool): whether the function is a property. Default is False. - - is_static (bool): whether the function is a static method. Default is False. - - is_context (bool): whether the function is a context manager. Default is False. - - Returns: - - A callable object that marks the function as patchable. - """ - def wrapper(func: Callable) -> Callable: - func.patchable = True - func.is_property = is_property - func.is_static = is_static - func.is_context = is_context - return func - return wrapper +""" +pyromod - A monkeypatcher add-on for Pyrogram +Copyright (C) 2020 Cezar H. + +This file is part of pyromod. + +pyromod 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. + +pyromod 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 pyromod. If not, see . +""" +from contextlib import contextmanager, asynccontextmanager +from inspect import iscoroutinefunction +from logging import getLogger +from typing import Callable, T, Type + +from pyrogram.sync import async_to_sync + +logger = getLogger(__name__) + + +def patch_into(target_class): + def is_patchable(item): + func = item[1] + return getattr(func, "should_patch", False) + + def wrapper(container: Type[T]) -> T: + for name, func in filter(is_patchable, container.__dict__.items()): + old = getattr(target_class, name, None) + if old is not None: # Not adding 'old' to new func + setattr(target_class, "old" + name, old) + + # Worse Code + tempConf = { + i: getattr(func, i, False) + for i in ["is_property", "is_static", "is_context"] + } + + async_to_sync(container, name) + func = getattr(container, name) + + for tKey, tValue in tempConf.items(): + setattr(func, tKey, tValue) + + if func.is_property: + func = property(func) + elif func.is_static: + func = staticmethod(func) + elif func.is_context: + if iscoroutinefunction(func.__call__): + func = asynccontextmanager(func) + else: + func = contextmanager(func) + + logger.info( + f"Patch Attribute To {target_class.__name__} From {container.__name__} : {name}" + ) + setattr(target_class, name, func) + return container + + return wrapper + + +def should_patch( + is_property: bool = False, is_static: bool = False, is_context: bool = False +) -> Callable: + """ + A decorator that marks a function as patchable. + + Usage: + + @patchable(is_property=True) + def my_property(): + ... + + @patchable(is_static=True) + def my_static_method(): + ... + + @patchable(is_context=True) + def my_context_manager(): + ... + + @patchable(is_property=False, is_static=False, is_context=False) + def my_function(): + ... + + @patchable() + def default_usage(): + ... + + Parameters: + - is_property (bool): whether the function is a property. Default is False. + - is_static (bool): whether the function is a static method. Default is False. + - is_context (bool): whether the function is a context manager. Default is False. + + Returns: + - A callable object that marks the function as patchable. + """ + + def wrapper(func: Callable) -> Callable: + func.should_patch = True + func.is_property = is_property + func.is_static = is_static + func.is_context = is_context + return func + + return wrapper