├── .editorconfig ├── .github ├── ISSUE_TEMPLATE.md └── workflows │ ├── build.yml │ ├── docs.yml │ ├── post-release.yml │ ├── publish.yml │ └── test-telegram-notifications.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .run └── pytest in tests.run.xml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── .gitignore ├── _TODO.md ├── api-mkapi.md ├── api-mkdocstrings.md ├── api_overrides │ └── headers.py ├── assets │ ├── examples_botlistbot.png │ ├── favicon.png │ ├── github-social-logo.png │ ├── github-social-logo.xcf │ ├── pycharm-logos │ │ ├── icon-pycharm.svg │ │ └── logo-pycharm.svg │ ├── screencast-botlistbot-tests.mp4 │ ├── start_botlistbot.png │ ├── testing-pyramid.png │ ├── tgintegration-logo-animated.mp4 │ ├── tgintegration-logo-beige.png │ ├── tgintegration-logo-neon.png │ ├── tgintegration-logo-orig.png │ └── tgintegration-logo.png ├── contributing.md ├── extra_templates.yml ├── getting-started.md ├── index.md ├── installation.md ├── prerequisites.md ├── setup.md ├── styles │ ├── mkapi-overrides.css │ └── mkdocstrings-overrides.css └── tutorials │ └── testing.md ├── examples ├── README.md ├── _assets │ ├── photo.jpg │ └── voice.ogg ├── automation │ ├── dinoparkbot.py │ └── idletown.py ├── config_template.ini ├── playground.py ├── pytest │ ├── conftest.py │ ├── test_basic.py │ ├── test_commands.py │ ├── test_explore.py │ └── test_inlinequeries.py └── readme_example │ ├── __init__.py │ └── readmeexample.py ├── mkdocs.yml ├── poetry.lock ├── pyproject.toml ├── scripts ├── __init__.py ├── copy_readme.py └── create_session_strings.py ├── setup.cfg ├── tests ├── __init__.py ├── conftest.py ├── integration │ ├── __init__.py │ ├── conftest.py │ └── test_examples.py └── unit │ ├── __init__.py │ ├── containers │ └── test_inline_keyboard.py │ ├── test_expectation.py │ ├── test_frame_utils.py │ └── test_handler_utils.py └── tgintegration ├── __init__.py ├── botcontroller.py ├── collector.py ├── containers ├── __init__.py ├── exceptions.py ├── inline_keyboard.py ├── inlineresults.py ├── reply_keyboard.py └── responses.py ├── expectation.py ├── handler_utils.py ├── timeout_settings.py ├── update_recorder.py └── utils ├── __init__.py ├── frame_utils.py ├── iter_utils.py └── sentinel.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | max_line_length = 120 13 | 14 | [{*.yml,*.yaml}] 15 | indent_size = 2 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * tgintegration version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ master, develop ] 6 | pull_request: 7 | branches: [ master, develop ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: [ 3.7, 3.8 ] 15 | 16 | steps: 17 | - name: Cancel Previous Runs 18 | uses: styfle/cancel-workflow-action@0.5.0 19 | with: 20 | access_token: ${{ github.token }} 21 | - uses: actions/checkout@v2 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v1 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: "Install Poetry" 27 | uses: Gr1N/setup-poetry@v3 28 | with: 29 | poetry-version: 1.0 30 | - name: "Configure Poetry for in-project venvs" 31 | run: poetry config virtualenvs.in-project true 32 | - name: Set up cache 33 | uses: actions/cache@v1 34 | with: 35 | path: .venv 36 | # key: venv-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} 37 | key: venv-${{ matrix.python-version }} 38 | - name: Poetry install 39 | run: poetry install 40 | - name: Show outdated packages 41 | run: poetry show --outdated 42 | continue-on-error: true 43 | - uses: pre-commit/action@v2.0.0 44 | if: ${{ matrix.python-version == '3.8' }} 45 | - name: Test with pytest 46 | env: 47 | API_ID: ${{ secrets.API_ID }} 48 | API_HASH: ${{ secrets.API_HASH }} 49 | SESSION_STRING: ${{ secrets.SESSION_STRING }} 50 | run: poetry run pytest tests/ examples/pytest/ --doctest-modules --junitxml=junit/test-results-${{ matrix.python-version }}.xml --cov=tgintegration --cov-report=xml --cov-report=html 51 | if: ${{ matrix.python-version == '3.8' }} 52 | - name: Upload pytest test results 53 | uses: actions/upload-artifact@v2 54 | with: 55 | name: pytest-results-${{ matrix.python-version }} 56 | path: junit/test-results-${{ matrix.python-version }}.xml 57 | if: ${{ always() }} 58 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Set up Python 3.8 12 | uses: actions/setup-python@v1 13 | with: 14 | python-version: 3.8 15 | - name: "Install Poetry" 16 | uses: Gr1N/setup-poetry@v3 17 | with: 18 | poetry-version: 1.0 19 | - name: Poetry install 20 | run: poetry install 21 | - run: poetry run mkdocs gh-deploy --force 22 | -------------------------------------------------------------------------------- /.github/workflows/post-release.yml: -------------------------------------------------------------------------------- 1 | name: Post release 2 | on: 3 | release: 4 | types: [ published ] 5 | workflow_dispatch: 6 | 7 | jobs: 8 | changelog: 9 | name: Update changelog 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | with: 14 | ref: master 15 | - uses: rhysd/changelog-from-release/action@v2 16 | with: 17 | file: CHANGELOG.md 18 | github-token: ${{ secrets.GITHUB_TOKEN }} 19 | - name: Send message to @TgIntegration # https://github.com/appleboy/telegram-action 20 | uses: appleboy/telegram-action@master 21 | with: 22 | to: -1001246107966 23 | token: ${{ secrets.TELEGRAM_TOKEN }} 24 | message: | 25 | A new version of TgIntegration has been released! 🎉 26 | See what's new at https://github.com/JosXa/tgintegration/releases 27 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish PyPI 2 | 3 | on: 4 | release: 5 | types: [ published ] 6 | repository_dispatch: 7 | types: [ publish_pypi ] 8 | workflow_dispatch: 9 | 10 | 11 | jobs: 12 | publish: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Cancel Previous Runs 16 | uses: styfle/cancel-workflow-action@0.5.0 17 | with: 18 | access_token: ${{ github.token }} 19 | - uses: actions/checkout@v2 20 | - uses: actions/setup-python@v2 21 | with: 22 | python-version: 3.8 23 | - name: "Install Poetry" 24 | uses: Gr1N/setup-poetry@v7 25 | with: 26 | poetry-version: 1.1.7 27 | - name: Poetry install 28 | run: poetry install --no-dev 29 | - name: Poetry publish 30 | run: poetry publish --build -u ${{ secrets.PYPI_USERNAME }} -p ${{ secrets.PYPI_PASSWORD }} 31 | -------------------------------------------------------------------------------- /.github/workflows/test-telegram-notifications.yml: -------------------------------------------------------------------------------- 1 | name: Test Telegram Notifications 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | 7 | jobs: 8 | notify: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Cancel Previous Runs 12 | uses: styfle/cancel-workflow-action@0.5.0 13 | with: 14 | access_token: ${{ github.token }} 15 | - uses: actions/checkout@v2 16 | - name: Send message to @TgIntegration # https://github.com/appleboy/telegram-action 17 | uses: appleboy/telegram-action@master 18 | with: 19 | to: -1001246107966 20 | token: ${{ secrets.TELEGRAM_TOKEN }} 21 | message: | 22 | A new version of TgIntegration has been released! 🎉 23 | See what's new at https://github.com/JosXa/tgintegration/releases 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | # pyenv python configuration file 62 | .python-version 63 | /examples/test.py 64 | /examples/config.ini 65 | /examples/*/config.ini 66 | 67 | *.session* 68 | /.env 69 | .idea 70 | 71 | .mypy_cache 72 | /site/ 73 | 74 | test-results* 75 | docs/api 76 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.8 3 | repos: 4 | # - repo: https://gitlab.com/PyCQA/flake8 5 | # rev: 3.8.3 6 | # hooks: 7 | # - id: flake8 8 | - repo: https://github.com/psf/black 9 | rev: 22.1.0 10 | hooks: 11 | - id: black 12 | # - repo: https://github.com/pre-commit/mirrors-mypy 13 | # rev: 'v0.770' 14 | # hooks: 15 | # - id: mypy 16 | - repo: https://github.com/asottile/reorder_python_imports 17 | rev: v2.7.1 18 | hooks: 19 | - id: reorder-python-imports 20 | # - repo: git@github.com:humitos/mirrors-autoflake.git 21 | # rev: v1.1 22 | # hooks: 23 | # - id: autoflake 24 | # args: [ '--in-place', '--remove-all-unused-imports' ] 25 | - repo: https://github.com/pre-commit/pre-commit-hooks 26 | rev: v3.3.0 27 | hooks: 28 | - id: check-docstring-first 29 | - id: debug-statements 30 | - id: flake8 31 | - id: trailing-whitespace 32 | - id: check-ast 33 | - id: check-builtin-literals 34 | - id: detect-private-key 35 | - id: mixed-line-ending 36 | -------------------------------------------------------------------------------- /.run/pytest in tests.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # [TgIntegration 1.2.0 - Adapt to changes in Pyrogram (v1.2.0)](https://github.com/JosXa/tgintegration/releases/tag/v1.2.0) - 04 Jan 2022 3 | 4 | Thanks to @LuchoTurtle for [fixing 'bot_info' attribute from GetFullUser](https://github.com/JosXa/tgintegration/pull/34)! 5 | 6 | [Changes][v1.2.0] 7 | 8 | 9 | 10 | # [TgIntegration 1.1.0 - Fixes for clicking inline and reply keyboard buttons (v1.1.0)](https://github.com/JosXa/tgintegration/releases/tag/v1.1.0) - 26 Oct 2020 11 | 12 | ## Bugfixes 13 | - Resolved #2 14 | 15 | ## Changes 16 | - The project structure changed slightly, but all the root imports stay the same. 17 | 18 | [Changes][v1.1.0] 19 | 20 | 21 | 22 | # [Bugfix for inline querying (v1.0.1)](https://github.com/JosXa/tgintegration/releases/tag/v1.0.1) - 25 Oct 2020 23 | 24 | - Fixed a bug where ALL results from `limit` were returned for inline queries as duplicates 25 | 26 | [Changes][v1.0.1] 27 | 28 | 29 | 30 | # [TgIntegration 1.0 Stable Release (v1.0)](https://github.com/JosXa/tgintegration/releases/tag/v1.0) - 19 Oct 2020 31 | 32 | After two years of neglect, _tgintegration_ is finally back! 33 | With Pyrogram turning `asyncio`-native and a huge demand in proper testing capabilities for Telegram Bots, it was time to give the library the love it deserves. Thus I hereby present to you the initial 1.0 Release - async by default, fully capable, and well-tested. 34 | 35 | ## Features 36 | 37 | - 👤 Log into a Telegram user account and interact with bots or other users 38 | - ✅ Write **realtime integration tests** to ensure that your bot works as expected! ▶️ [Pytest examples](https://github.com/JosXa/tgintegration/tree/master/examples/pytest) 39 | - ⚡️ **Automate any interaction** on Telegram! ▶️ [Automation examples](https://github.com/JosXa/tgintegration/tree/master/examples/automation) 40 | 41 | 42 | See the [README](https://github.com/JosXa/tgintegration/blob/master/README.md) for more information and check out the [Quick Start Guide](https://github.com/JosXa/tgintegration/blob/master/README.md#quick-start-guide)! 43 | 44 | [Changes][v1.0] 45 | 46 | 47 | [v1.2.0]: https://github.com/JosXa/tgintegration/compare/v1.1.0...v1.2.0 48 | [v1.1.0]: https://github.com/JosXa/tgintegration/compare/v1.0.1...v1.1.0 49 | [v1.0.1]: https://github.com/JosXa/tgintegration/compare/v1.0...v1.0.1 50 | [v1.0]: https://github.com/JosXa/tgintegration/tree/v1.0 51 | 52 | 53 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. 5 | 6 | You can contribute in many ways: 7 | 8 | Types of Contributions 9 | ---------------------- 10 | 11 | ### Report Bugs 12 | 13 | Report bugs at . 14 | 15 | If you are reporting a bug, please include: 16 | 17 | - Your operating system name and version. 18 | - Any details about your local setup that might be helpful in troubleshooting. 19 | - Detailed steps to reproduce the bug. 20 | 21 | ### Fix Bugs 22 | 23 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help wanted" is open to whoever wants to implement it. 24 | 25 | ### Implement Features 26 | 27 | Look through the GitHub issues for features. Anything tagged with "enhancement" and "help wanted" is open to whoever wants to implement it. 28 | 29 | ### Write Documentation 30 | 31 | tgintegration could always use more documentation, whether as part of the official tgintegration docs, in docstrings, or even on the web in blog posts, articles, and such. 32 | 33 | ### Submit Feedback 34 | 35 | The best way to send feedback is to file an issue at . 36 | 37 | If you are proposing a feature: 38 | 39 | - Explain in detail how it would work. 40 | - Keep the scope as narrow as possible, to make it easier to implement. 41 | - Remember that this is a volunteer-driven project, and that contributions are welcome :) 42 | 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2020, Joscha Götzer 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | TgIntegration 2 | ============= 3 | 4 | An integration test and automation library for [Telegram Bots](https://core.telegram.org/bots) based on [Pyrogram](https://github.com/pyrogram/pyrogram). 5 |
**Test your bot in realtime scenarios!** 6 | 7 | **Are you a user of TgIntegration?** I'm actively looking for feedback and ways to improve the library, come and let me know in the [official group](https://t.me/TgIntegration)! 8 | 9 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/tgintegration)](https://pypi.org/project/tgintegration/) 10 | [![PyPI - Downloads](https://img.shields.io/pypi/dm/tgintegration)](https://pypi.org/project/tgintegration/) 11 | [![PyPI](https://img.shields.io/pypi/v/tgintegration)](https://pypi.org/project/tgintegration/) 12 | ![GitHub top language](https://img.shields.io/github/languages/top/josxa/tgintegration) 13 | [![GitHub Workflow Status (branch)](https://img.shields.io/github/workflow/status/josxa/tgintegration/Build/master)](https://github.com/JosXa/tgintegration/actions?query=workflow%3ABuild) 14 | [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/josxa/tgintegration/Docs?label=docs)](https://josxa.github.io/tgintegration) 15 | 16 | [Features](#features) • [Requirements](#prerequisites) • [Installation](#installation) • [**Quick Start Guide**](#quick-start-guide) • [Test Frameworks](#integrating-with-test-frameworks) 17 | 18 | - 📖 [Documentation](https://josxa.github.io/tgintegration/) 19 | - 👥 [Telegram Chat](https://t.me/TgIntegration) 20 | - 📄 Free software: [MIT License](https://tldrlegal.com/license/mit-license) 21 | - []((https://www.jetbrains.com/?from=tgintegration)) Built with [PyCharm](https://www.jetbrains.com/?from=tgintegration) 22 | 23 | Features 24 | -------- 25 | 26 | ▶️ [**See it in action!** 🎬](https://josxa.github.io/tgintegration/#see-it-in-action) 27 | 28 | - 👤 Log into a Telegram user account and interact with bots or other users 29 | - ✅ Write **realtime integration tests** to ensure that your bot works as expected! ▶️ [Pytest examples](https://github.com/JosXa/tgintegration/tree/master/examples/pytest) 30 | - ⚡️ **Automate any interaction** on Telegram! ▶️ [Automatically play @IdleTownBot](https://github.com/JosXa/tgintegration/blob/master/examples/automation/idletown.py) | [More examples](https://github.com/JosXa/tgintegration/tree/master/examples/automation) 31 | - 🛡 Fully typed for safety and **autocompletion** with your favorite IDE 32 | - 🐍 Built for modern Python (3.8+) with high test coverage 33 | 34 | 35 | Prerequisites 36 | ------------- 37 | 38 | [Same as Pyrogram](https://github.com/pyrogram/pyrogram#requirements): 39 | 40 | - A [Telegram API key](https://docs.pyrogram.ml/start/ProjectSetup#api-keys). 41 | - A user session (seeing things happen in your own account is great for getting started) 42 | - But: **Python 3.8** or higher! 43 | 44 | A basic understanding of async/await and [asynchronous context managers](https://docs.python.org/3/library/contextlib.html#contextlib.asynccontextmanager) is assumed, as TgIntegration heavily relies on the latter to automate conversations. 45 | 46 | 47 | Installation 48 | ------------ 49 | 50 | All hail pip! 51 | 52 | $ `pip install tgintegration --upgrade` 53 | 54 |
55 |
Feeling adventurous?

56 | 57 | For bleeding edge, install the master branch: 58 | 59 | $ `pip install git+https://github.com/JosXa/tgintegration.git` 60 | 61 |

62 | 63 | 64 | Quick Start Guide 65 | ----------------- 66 | 67 | _You can [follow along by running the example](https://github.com/JosXa/tgintegration/blob/master/examples/readme_example/readmeexample.py) ([README](https://github.com/JosXa/tgintegration/blob/master/examples/README.md))_ 68 | 69 | #### Setup 70 | 71 | Suppose we want to write integration tests for [@BotListBot](https://t.me/BotListBot) by sending it a couple of 72 | messages and checking that it responds the way it should. 73 | 74 | After [configuring a Pyrogram **user client**](https://docs.pyrogram.org/start/setup), 75 | let's start by creating a `BotController`: 76 | 77 | ``` python 78 | from tgintegration import BotController 79 | 80 | controller = BotController( 81 | peer="@BotListBot", # The bot under test is https://t.me/BotListBot 🤖 82 | client=client, # This assumes you already have a Pyrogram user client available 83 | max_wait=8, # Maximum timeout for responses (optional) 84 | wait_consecutive=2, # Minimum time to wait for more/consecutive messages (optional) 85 | raise_no_response=True, # Raise `InvalidResponseError` when no response is received (defaults to True) 86 | global_action_delay=2.5 # Choosing a rather high delay so we can observe what's happening (optional) 87 | ) 88 | 89 | await controller.clear_chat() # Start with a blank screen (⚠️) 90 | ``` 91 | 92 | Now, let's send `/start` to the bot and wait until exactly three messages have been received by using the asynchronous `collect` context manager: 93 | 94 | ``` python 95 | async with controller.collect(count=3) as response: 96 | await controller.send_command("start") 97 | 98 | assert response.num_messages == 3 # Three messages received, bundled under a `Response` object 99 | assert response.messages[0].sticker # The first message is a sticker 100 | ``` 101 | 102 | The result should look like this: 103 | 104 | ![image](https://raw.githubusercontent.com/JosXa/tgintegration/master/docs/assets/start_botlistbot.png) 105 | 106 | Examining the buttons in the response... 107 | 108 | ``` python 109 | # Get first (and only) inline keyboard from the replies 110 | inline_keyboard = response.inline_keyboards[0] 111 | 112 | # Three buttons in the first row 113 | assert len(inline_keyboard.rows[0]) == 3 114 | ``` 115 | 116 | We can also press the inline keyboard buttons, for example based on a regular expression: 117 | 118 | ``` python 119 | examples = await inline_keyboard.click(pattern=r".*Examples") 120 | ``` 121 | 122 | As the bot edits the message, `.click()` automatically listens for "message edited" updates and returns 123 | the new state as another `Response`. 124 | 125 | ![image](https://raw.githubusercontent.com/JosXa/tgintegration/master/docs/assets/examples_botlistbot.png) 126 | 127 | ``` python 128 | assert "Examples for contributing to the BotList" in examples.full_text 129 | ``` 130 | 131 | #### Error handling 132 | 133 | So what happens when we send an invalid query or the peer fails to respond? 134 | 135 | The following instruction will raise an `InvalidResponseError` after `controller.max_wait` seconds. 136 | This is because we passed `raise_no_response=True` during controller initialization. 137 | 138 | ``` python 139 | try: 140 | async with controller.collect(): 141 | await controller.send_command("ayylmao") 142 | except InvalidResponseError: 143 | pass # OK 144 | ``` 145 | 146 | Let's explicitly set `raise_` to `False` so that no exception occurs: 147 | 148 | ``` python 149 | async with controller.collect(raise_=False) as response: 150 | await client.send_message(controller.peer_id, "Henlo Fren") 151 | ``` 152 | 153 | In this case, _tgintegration_ will simply emit a warning, but you can still assert 154 | that no response has been received by using the `is_empty` property: 155 | 156 | ``` python 157 | assert response.is_empty 158 | ``` 159 | 160 | 161 | Integrating with Test Frameworks 162 | -------------------------------- 163 | 164 | ### [pytest](https://docs.pytest.org/en/stable/index.html) 165 | 166 | Pytest is the recommended test framework for use with _tgintegration_. You can 167 | [browse through several examples](https://github.com/JosXa/tgintegration/tree/master/examples/pytest) 168 | and _tgintegration_ also uses pytest for its own test suite. 169 | 170 | ### unittest 171 | 172 | I haven't tried out the builtin `unittest` library in combination with _tgintegration_ yet, 173 | but theoretically I don't see any problems with it. 174 | If you do decide to try it, it would be awesome if you could tell me about your 175 | experience and whether anything could be improved 🙂 176 | Let us know at 👉 https://t.me/TgIntegration or in an issue. 177 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | /tgintegration.rst 2 | /tgintegration.*.rst 3 | /modules.rst 4 | !/modules.rst 5 | -------------------------------------------------------------------------------- /docs/_TODO.md: -------------------------------------------------------------------------------- 1 | # TgIntegration Documentation TODOs 2 | 3 | - [ ] Guide on how to create a CI pipeline with tgintegration 4 | - [ ] Add setup instructions on the library itself (for contributors) 5 | -------------------------------------------------------------------------------- /docs/api-mkapi.md: -------------------------------------------------------------------------------- 1 | # mkapi 2 | 3 | # ![mkapi](tgintegration.botcontroller|upper|all) 4 | -------------------------------------------------------------------------------- /docs/api-mkdocstrings.md: -------------------------------------------------------------------------------- 1 | # mkdocstrings 2 | 3 | ::: tgintegration.botcontroller 4 | -------------------------------------------------------------------------------- /docs/api_overrides/headers.py: -------------------------------------------------------------------------------- 1 | from mkapi.plugins.mkdocs import MkapiPlugin 2 | from mkdocs.config import Config 3 | 4 | 5 | def on_config_with_mkapi(config: Config, mkapi: MkapiPlugin): 6 | pass 7 | -------------------------------------------------------------------------------- /docs/assets/examples_botlistbot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JosXa/tgintegration/39dfc82eb5c80bb6845c12edf0c112e2fa7de26f/docs/assets/examples_botlistbot.png -------------------------------------------------------------------------------- /docs/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JosXa/tgintegration/39dfc82eb5c80bb6845c12edf0c112e2fa7de26f/docs/assets/favicon.png -------------------------------------------------------------------------------- /docs/assets/github-social-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JosXa/tgintegration/39dfc82eb5c80bb6845c12edf0c112e2fa7de26f/docs/assets/github-social-logo.png -------------------------------------------------------------------------------- /docs/assets/github-social-logo.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JosXa/tgintegration/39dfc82eb5c80bb6845c12edf0c112e2fa7de26f/docs/assets/github-social-logo.xcf -------------------------------------------------------------------------------- /docs/assets/pycharm-logos/icon-pycharm.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 60 | 61 | 62 | 63 | 64 | 66 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /docs/assets/pycharm-logos/logo-pycharm.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 11 | 14 | 15 | 16 | 17 | 19 | 21 | 24 | 26 | 30 | 32 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /docs/assets/screencast-botlistbot-tests.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JosXa/tgintegration/39dfc82eb5c80bb6845c12edf0c112e2fa7de26f/docs/assets/screencast-botlistbot-tests.mp4 -------------------------------------------------------------------------------- /docs/assets/start_botlistbot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JosXa/tgintegration/39dfc82eb5c80bb6845c12edf0c112e2fa7de26f/docs/assets/start_botlistbot.png -------------------------------------------------------------------------------- /docs/assets/testing-pyramid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JosXa/tgintegration/39dfc82eb5c80bb6845c12edf0c112e2fa7de26f/docs/assets/testing-pyramid.png -------------------------------------------------------------------------------- /docs/assets/tgintegration-logo-animated.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JosXa/tgintegration/39dfc82eb5c80bb6845c12edf0c112e2fa7de26f/docs/assets/tgintegration-logo-animated.mp4 -------------------------------------------------------------------------------- /docs/assets/tgintegration-logo-beige.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JosXa/tgintegration/39dfc82eb5c80bb6845c12edf0c112e2fa7de26f/docs/assets/tgintegration-logo-beige.png -------------------------------------------------------------------------------- /docs/assets/tgintegration-logo-neon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JosXa/tgintegration/39dfc82eb5c80bb6845c12edf0c112e2fa7de26f/docs/assets/tgintegration-logo-neon.png -------------------------------------------------------------------------------- /docs/assets/tgintegration-logo-orig.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JosXa/tgintegration/39dfc82eb5c80bb6845c12edf0c112e2fa7de26f/docs/assets/tgintegration-logo-orig.png -------------------------------------------------------------------------------- /docs/assets/tgintegration-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JosXa/tgintegration/39dfc82eb5c80bb6845c12edf0c112e2fa7de26f/docs/assets/tgintegration-logo.png -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JosXa/tgintegration/39dfc82eb5c80bb6845c12edf0c112e2fa7de26f/docs/contributing.md -------------------------------------------------------------------------------- /docs/extra_templates.yml: -------------------------------------------------------------------------------- 1 | tgi: '_tgintegration_' 2 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Getting Started 4 | 5 | _You can [follow along by running the example](https://github.com/JosXa/tgintegration/blob/master/examples/readme_example/readmeexample.py) ([README](https://github.com/JosXa/tgintegration/blob/master/examples/README.md))_ 6 | 7 | #### Setup 8 | 9 | Suppose we want to write integration tests for [@BotListBot](https://t.me/BotListBot) by sending it a couple of 10 | messages and checking that it responds the way it should. 11 | 12 | After [configuring a Pyrogram **user client**](https://docs.pyrogram.org/intro/setup), 13 | let's start by creating a `BotController`: 14 | 15 | ``` python 16 | from tgintegration import BotController 17 | 18 | controller = BotController( 19 | peer="@BotListBot", # The bot under test is https://t.me/BotListBot 🤖 20 | client=client, # This assumes you already have a Pyrogram user client available 21 | max_wait=8, # Maximum timeout for responses (optional) 22 | wait_consecutive=2, # Minimum time to wait for more/consecutive messages (optional) 23 | raise_no_response=True, # Raise `InvalidResponseError` when no response is received (defaults to True) 24 | global_action_delay=2.5 # Choosing a rather high delay so we can observe what's happening (optional) 25 | ) 26 | 27 | await controller.clear_chat() # Start with a blank screen (⚠️) 28 | ``` 29 | 30 | Now, let's send `/start` to the bot and wait until exactly three messages have been received by using the asynchronous `collect` context manager: 31 | 32 | ``` python 33 | async with controller.collect(count=3) as response: 34 | await controller.send_command("start") 35 | 36 | assert response.num_messages == 3 # Three messages received, bundled under a `Response` object 37 | assert response.messages[0].sticker # The first message is a sticker 38 | ``` 39 | 40 | The result should look like this: 41 | 42 | ![image](https://raw.githubusercontent.com/JosXa/tgintegration/master/docs/assets/start_botlistbot.png) 43 | 44 | Examining the buttons in the response... 45 | 46 | ``` python 47 | # Get first (and only) inline keyboard from the replies 48 | inline_keyboard = response.inline_keyboards[0] 49 | 50 | # Three buttons in the first row 51 | assert len(inline_keyboard.rows[0]) == 3 52 | ``` 53 | 54 | We can also press the inline keyboard buttons, for example based on a regular expression: 55 | 56 | ``` python 57 | examples = await inline_keyboard.click(pattern=r".*Examples") 58 | ``` 59 | 60 | As the bot edits the message, `.click()` automatically listens for "message edited" updates and returns 61 | the new state as another `Response`. 62 | 63 | ![image](https://raw.githubusercontent.com/JosXa/tgintegration/master/docs/assets/examples_botlistbot.png) 64 | 65 | ``` python 66 | assert "Examples for contributing to the BotList" in examples.full_text 67 | ``` 68 | 69 | #### Error handling 70 | 71 | So what happens when we send an invalid query or the peer fails to respond? 72 | 73 | The following instruction will raise an `InvalidResponseError` after `controller.max_wait` seconds. 74 | This is because we passed `raise_no_response=True` during controller initialization. 75 | 76 | ``` python 77 | try: 78 | async with controller.collect(): 79 | await controller.send_command("ayylmao") 80 | except InvalidResponseError: 81 | pass # OK 82 | ``` 83 | 84 | Let's explicitly set `raise_` to `False` so that no exception occurs: 85 | 86 | ``` python 87 | async with controller.collect(raise_=False) as response: 88 | await client.send_message(controller.peer_id, "Henlo Fren") 89 | ``` 90 | 91 | In this case, _tgintegration_ will simply emit a warning, but you can still assert 92 | that no response has been received by using the `is_empty` property: 93 | 94 | ``` python 95 | assert response.is_empty 96 | ``` -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | {{tgi}} is a Python library that helps you test your [bots](https://core.telegram.org/bots) and automate 4 | routine tasks on [Telegram Messenger](https://telegram.org). It does so by logging in as a user or interacting in your 5 | name using the popular [Pyrogram](https://github.com/pyrogram/pyrogram) library and 6 | [mtproto](https://core.telegram.org/mtproto). 7 | 8 | ### See it in action 9 | 10 | 14 | 15 | 20 | 21 | ### The Testing Pyramid 22 | 23 | When writing software, it is almost always a good idea to have a number of tests for your code (this varies by the 24 | complexity of your project). What kinds of tests should be written typically follows the so-called ["testing 25 | pyramid"](https://martinfowler.com/bliki/TestPyramid.html). 26 | 27 | The Testing Pyramid 28 | 29 | This guideline recommends to have a test suite consisting of a **large base of unit tests**, a fair **number of 30 | integration tests**, and only **very few end-to-end (E2E) or manual tests**. In this classification, {{tgi}} lies in 31 | the center and should be seen as a supplement to unit tests that cover the core logic of your bot. 32 | 33 | By their nature, integration tests are slower since they interact with multiple systems over the network instead of 34 | everything happening on the same machine. The same is true for {{tgi}}, which reaches out to the Telegram servers to 35 | automate an interaction between two conversation partners. 36 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Installation 4 | 5 | All hail pip! 6 | 7 | $ `pip install tgintegration --upgrade` 8 | 9 | 10 |
Feeling adventurous?

11 | 12 | For bleeding edge, install the master branch: 13 | 14 |

pip install git+https://github.com/JosXa/tgintegration.git
15 | 16 |

-------------------------------------------------------------------------------- /docs/prerequisites.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Prerequisites 4 | 5 | [Same as Pyrogram](https://github.com/pyrogram/pyrogram#requirements): 6 | 7 | - Python **3.7** or higher. 8 | - A [Telegram API key](https://docs.pyrogram.ml/start/ProjectSetup#api-keys). 9 | - A user session (seeing things happen in your own account is great for getting started) -------------------------------------------------------------------------------- /docs/setup.md: -------------------------------------------------------------------------------- 1 | {!prerequisites.md!} 2 | {!installation.md!} 3 | -------------------------------------------------------------------------------- /docs/styles/mkapi-overrides.css: -------------------------------------------------------------------------------- 1 | /* http://127.0.0.1:8000/css/mkapi-common.css */ 2 | 3 | .mkapi-node { 4 | font-size: 0.8rem; 5 | } 6 | 7 | .mkapi-node a.mkapi-src-link, a.mkapi-docs-link { 8 | font-size: 0.64rem; 9 | } 10 | 11 | .mkapi-object.code .mkapi-object-body { 12 | font-size: inherit; 13 | } 14 | 15 | .mkapi-node .mkapi-object.code mkapi-object-body { 16 | font-size: 0.8rem; 17 | } 18 | 19 | .mkapi-node .mkapi-section-name .mkapi-section-name-body { 20 | font-size: 1em; 21 | } 22 | 23 | .mkapi-section-body { 24 | font-size: 1em; 25 | } 26 | 27 | div.mkapi-section-body.examples pre code { 28 | font-size: inherit; 29 | } 30 | 31 | .highlight { 32 | font-size: 1em; 33 | } 34 | 35 | .mkapi-node .mkapi-base { 36 | font-size: .9em; 37 | } 38 | 39 | .mkapi-node code.mkapi-item-name, 40 | .mkapi-node code.mkapi-object-signature, 41 | .mkapi-node code.mkapi-object-parenthesis, 42 | .mkapi-node span.mkapi-item-dash, 43 | .mkapi-node span.mkapi-item-type { 44 | font-size: 0.9em; 45 | } 46 | 47 | .mkapi-node .mkapi-item-name, 48 | .mkapi-node .mkapi-object, 49 | .mkapi-node .mkapi-object code, 50 | .mkapi-node .mkapi-object.code h2.mkapi-object-body, 51 | .mkapi-node h2 .mkapi-object { 52 | font-size: 1em; 53 | } 54 | 55 | .mkapi-node ul.mkapi-items li::before { 56 | font-size: 80%; 57 | } 58 | 59 | .mkapi-section-name { 60 | padding: 0px 8px 2px 8px; 61 | } 62 | 63 | .mkapi-object.plain .mkapi-object-kind { 64 | font-weight: normal; 65 | } 66 | -------------------------------------------------------------------------------- /docs/styles/mkdocstrings-overrides.css: -------------------------------------------------------------------------------- 1 | .md-typeset__table table { 2 | font-size: 30px; 3 | } 4 | 5 | .doc-heading { 6 | font-size: 0.9rem; 7 | } 8 | -------------------------------------------------------------------------------- /docs/tutorials/testing.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JosXa/tgintegration/39dfc82eb5c80bb6845c12edf0c112e2fa7de26f/docs/tutorials/testing.md -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # How to run the examples 2 | 3 | 1) Open `config_template.ini` and insert your `api_id` and `api_hash`. 4 | Pyrogram provides an explanation on how to obtain these over at the [Pyrogram Docs - API Keys](https://docs.pyrogram.ml/start/ProjectSetup#api-keys) 5 | 6 | 2) Rename or copy the template file `config_template.ini` to just `config.ini` in order to allow 7 | Pyrogram to parse it automatically. 8 | 9 | Remember that in a productive environment, you should probably use environment variables or 10 | another configuration source and pass them directly to the initializer of the Client. 11 | However, for the sake of simplicity in these example, the .ini approach works well to get 12 | up and running quickly. 13 | -------------------------------------------------------------------------------- /examples/_assets/photo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JosXa/tgintegration/39dfc82eb5c80bb6845c12edf0c112e2fa7de26f/examples/_assets/photo.jpg -------------------------------------------------------------------------------- /examples/_assets/voice.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JosXa/tgintegration/39dfc82eb5c80bb6845c12edf0c112e2fa7de26f/examples/_assets/voice.ogg -------------------------------------------------------------------------------- /examples/automation/dinoparkbot.py: -------------------------------------------------------------------------------- 1 | """ 2 | A tgintegration script that automatically farms for you in @DinoParkNextBot. 3 | 4 | This example uses a `config.ini` file for configuration (see examples/README)! 5 | """ 6 | import asyncio 7 | import logging 8 | import random 9 | import re 10 | import traceback 11 | from pathlib import Path 12 | from typing import List 13 | from typing import Optional 14 | from typing import Tuple 15 | 16 | from decouple import config 17 | from pyrogram import Client 18 | from pyrogram import filters as f 19 | 20 | from tgintegration import BotController 21 | from tgintegration.containers import ReplyKeyboard 22 | 23 | MAX_RUNS: int = -1 # No limit 24 | SESSION_NAME: str = "tgintegration_examples" 25 | examples_dir = Path(__file__).parent.parent.absolute() 26 | 27 | 28 | def create_client(session_name: str) -> Client: 29 | return Client( 30 | name=session_name, 31 | api_id=config("API_ID"), 32 | api_hash=config("API_HASH"), 33 | session_string=config("SESSION_STRING"), 34 | workdir=str(examples_dir), 35 | ) 36 | 37 | 38 | def create_game_controller(client: Client = None) -> "DinoParkGame": 39 | return DinoParkGame(client or create_client(SESSION_NAME), log_level=logging.INFO) 40 | 41 | 42 | class DinoParkGame(BotController): 43 | BOT_NAME = "@DinoParkNextBot" 44 | VALUE_PATTERN = re.compile(r"^.*?\s*(\w+): ([\d ]+).*$", re.MULTILINE) 45 | NUMBERS_ONLY_PATTERN = re.compile(r"\b(\d[\d ]+)\b") 46 | 47 | def __init__(self, client, log_level=logging.INFO): 48 | super().__init__(client, peer=self.BOT_NAME, global_action_delay=1.0) 49 | 50 | self.purchase_balance = None 51 | self.withdrawal_balance = None 52 | self.diamonds = None 53 | 54 | self.menu: Optional[ReplyKeyboard] = None 55 | self.logger = logging.getLogger(self.__class__.__name__) 56 | self.logger.setLevel(log_level) 57 | 58 | async def perform_full_run(self): 59 | await self.start() 60 | await self.buy_dinosaurs() 61 | await self.collect_diamonds() 62 | await self.sell_diamonds() 63 | await self.play_lucky_number() 64 | await self.get_bonus() 65 | 66 | async def start(self): 67 | await self.initialize() 68 | await self.reset() 69 | await self.update_balance() 70 | 71 | async def reset(self): 72 | async with self.collect(f.regex(r"Welcome")) as start: 73 | await self.send_command("start") 74 | self.menu = start.reply_keyboard 75 | 76 | def _extract_values(self, text): 77 | groups = self.VALUE_PATTERN.findall(text) 78 | try: 79 | return {g[0].lower(): str_to_int(g[1]) for g in groups} 80 | except KeyError: 81 | return {} 82 | 83 | async def update_balance(self): 84 | balance_menu = await self.menu.click(r".*Balance") 85 | values = self._extract_values(balance_menu.full_text) 86 | 87 | self.purchase_balance = values["purchases"] 88 | self.withdrawal_balance = values["withdrawals"] 89 | 90 | diamonds_menu = await self.menu.click(r".*Farm") 91 | diamonds_values = self._extract_values(diamonds_menu.full_text) 92 | 93 | self.diamonds = diamonds_values["total"] 94 | 95 | self.logger.debug( 96 | "Balance updated: +{} for purchases, +{} for withdrawals, +{} diamonds.".format( 97 | self.purchase_balance, self.withdrawal_balance, self.diamonds 98 | ) 99 | ) 100 | 101 | async def collect_diamonds(self): 102 | await self.reset() 103 | farm = await self.menu.click(".*Farm") 104 | collected = await farm.inline_keyboards[0].click(".*Collect diamonds") 105 | 106 | num_collected = self._extract_values(collected.full_text).get("collected", 0) 107 | self.diamonds += num_collected 108 | self.logger.info( 109 | "{} diamonds collected.".format( 110 | num_collected if num_collected > 0 else "No" 111 | ) 112 | ) 113 | 114 | async def sell_diamonds(self): 115 | market = await self.menu.click(r".*Marketplace") 116 | if not market.inline_keyboards: 117 | self.logger.debug("No selling available at the moment.") 118 | return 119 | 120 | await market.inline_keyboards[0].click(r"Sell diamonds.*") 121 | await self.update_balance() 122 | 123 | async def buy_dinosaurs(self, limit: int = 5): 124 | dinosaurs_menu = (await self.menu.click(r".*Dinosaurs")).inline_keyboards[0] 125 | dinos = await dinosaurs_menu.click(r".*Buy dinosaurs") 126 | 127 | dino_costs: List[Tuple[int, int]] = [] # (KeyboardIndex, Cost) 128 | for n, msg in enumerate(dinos.messages): 129 | # "Worth" in the message has no colon (:) before the number, therefore we use the numbers only pattern 130 | values = self.NUMBERS_ONLY_PATTERN.findall(msg.caption) 131 | cost = str_to_int(values[0]) 132 | dino_costs.append((n, cost)) 133 | 134 | num_bought = 0 135 | while num_bought < limit: 136 | affordable_dinos = (x for x in dino_costs if x[1] <= self.purchase_balance) 137 | most_expensive_affordable: Optional[Tuple[int, int]] = max( 138 | affordable_dinos, key=lambda v: v[1], default=None 139 | ) 140 | 141 | if most_expensive_affordable is None: 142 | break 143 | 144 | dino_msg_index, dino_cost = most_expensive_affordable 145 | 146 | bought = await dinos.inline_keyboards[dino_msg_index].click(r".*Buy") 147 | 148 | self.purchase_balance -= dino_cost 149 | self.logger.info( 150 | f"Bought dinosaur: {bought.full_text} -- Remaining balance: {self.purchase_balance}" 151 | ) 152 | num_bought += 1 153 | 154 | async def play_lucky_number(self): 155 | games = await self.menu.click(r".*Games") 156 | lucky_number = await games.reply_keyboard.click(r".*Lucky number") 157 | bet = await lucky_number.reply_keyboard.click(r".*Place your bet") 158 | 159 | if "only place one bet per" in bet.full_text.lower(): 160 | await bet.delete_all_messages() 161 | return 162 | 163 | await self.client.send_message(self.peer_id, str(random.randint(1, 30))) 164 | self.logger.debug("Bet placed.") 165 | 166 | async def get_bonus(self): 167 | await self.reset() 168 | menu = await self.menu.click(r".*Games") 169 | bonus = await menu.reply_keyboard.click(r".*Bonus.*") 170 | 171 | if "already claimed" in bonus.full_text.lower(): 172 | # Clean up 173 | await bonus.delete_all_messages() 174 | 175 | 176 | def str_to_int(value: str) -> int: 177 | return int(value.replace(" ", "")) 178 | 179 | 180 | async def main(): 181 | game = create_game_controller() 182 | await game.start() 183 | 184 | runs = 0 185 | while True: 186 | try: 187 | await asyncio.sleep(1.5) 188 | await game.perform_full_run() 189 | await asyncio.sleep(60) 190 | await game.clear_chat() 191 | except KeyboardInterrupt: 192 | break 193 | except BaseException: 194 | traceback.print_exc() 195 | finally: 196 | runs += 1 197 | if 0 < MAX_RUNS <= runs: 198 | break 199 | 200 | 201 | if __name__ == "__main__": 202 | asyncio.get_event_loop().run_until_complete(main()) 203 | -------------------------------------------------------------------------------- /examples/automation/idletown.py: -------------------------------------------------------------------------------- 1 | """ 2 | A tgintegration script plays Idle Town for you (@IdleTownBot). 3 | 4 | This example uses a `config.ini` file for configuration (see examples/README). 5 | It also expects that you have set up the bot with a town name and English as language. 6 | """ 7 | import asyncio 8 | import logging 9 | import traceback 10 | from pathlib import Path 11 | from typing import Dict 12 | 13 | from decouple import config 14 | from pyrogram import Client 15 | from pyrogram import filters as f 16 | 17 | from tgintegration import BotController 18 | from tgintegration.containers.responses import Response 19 | 20 | examples_dir = Path(__file__).parent.parent.absolute() 21 | SESSION_NAME: str = "tgintegration_examples" 22 | 23 | logging.basicConfig(format="%(levelname)s:%(message)s", level=logging.INFO) 24 | log = logging.getLogger(__name__) 25 | 26 | 27 | # This example uses the configuration of `config.ini` (see examples/README) 28 | def create_client(session_name: str = SESSION_NAME) -> Client: 29 | return Client( 30 | name=session_name, 31 | api_id=config("API_ID"), 32 | api_hash=config("API_HASH"), 33 | session_string=config("SESSION_STRING"), 34 | workdir=str(examples_dir), 35 | ) 36 | 37 | 38 | def create_game_controller(client: Client = None) -> BotController: 39 | return BotController( 40 | peer="@IdleTownBot", 41 | client=client or create_client(), 42 | global_action_delay=2.0, # The @IdleTownBot has a spam limit of about 1.9s 43 | max_wait=8, # Maximum time in seconds to wait for a response from the bot 44 | wait_consecutive=None, # Do not wait for more than one message 45 | ) 46 | 47 | 48 | def ascii_chars(text: str) -> str: 49 | return "".join(x for x in text if str.isalpha(x) or str.isdigit(x)).strip() 50 | 51 | 52 | def get_buttons(response: Response) -> Dict[str, str]: 53 | """ 54 | Helper function to create a dictionary for easy access to keyboard buttons 55 | """ 56 | return {ascii_chars(b).lower(): b for b in response.keyboard_buttons} 57 | 58 | 59 | async def perform_full_run(controller: BotController, max_upgrades_per_type: int = 5): 60 | # Setup 61 | await controller.clear_chat() 62 | await asyncio.sleep(2) 63 | 64 | async def restart() -> Response: 65 | async with controller.collect(f.text) as start: 66 | await controller.send_command("restart", add_bot_name=False) 67 | return start 68 | 69 | # Extract keyboard buttons of /start response 70 | main_menu = get_buttons(await restart()) 71 | 72 | async def click_button(menu: Dict[str, str], key: str) -> Dict[str, str]: 73 | async with controller.collect() as response: # type: Response 74 | await controller.client.send_message(controller.peer_id, menu[key]) 75 | 76 | return get_buttons(response) 77 | 78 | # Get World Exp if possible 79 | if "worldexp" in main_menu: 80 | worldexp_menu = await click_button(main_menu, "worldexp") 81 | confirm_menu = await click_button(worldexp_menu, "claimx1") 82 | await click_button(confirm_menu, "yes") 83 | 84 | # Construct buildings 85 | build_menu = await click_button(main_menu, "buildings") 86 | 87 | for building in ["lumbermill", "goldmine", "armory", "smithy"]: 88 | num_upgraded = 0 89 | while num_upgraded < max_upgrades_per_type: 90 | async with controller.collect() as build_response: 91 | await controller.client.send_message( 92 | controller.peer_id, build_menu[building] 93 | ) 94 | 95 | if "you don't have enough" in build_response.full_text.lower(): 96 | break 97 | num_upgraded += 1 98 | 99 | # Upgrade Hero Equipment 100 | hero_menu = await click_button(main_menu, "hero") 101 | equip_menu = await click_button(hero_menu, "equipment") 102 | 103 | # For every possible equipment, upgrade it until there are not enough resources left 104 | for equip_button in (k for k in equip_menu.keys() if k.startswith("up")): 105 | num_upgraded = 0 106 | while num_upgraded < max_upgrades_per_type: 107 | async with controller.collect() as upgrade_response: 108 | await controller.client.send_message( 109 | controller.peer_id, equip_menu[equip_button] 110 | ) 111 | if "you don't have enough" in upgrade_response.full_text.lower(): 112 | break 113 | num_upgraded += 1 114 | 115 | # Attack Player 116 | battle_menu = await click_button(main_menu, "battle") 117 | arena_menu = await click_button(battle_menu, "arena") 118 | normal_match_menu = await click_button(arena_menu, "normalmatch") 119 | 120 | if "fight" in normal_match_menu: 121 | await click_button(normal_match_menu, "fight") 122 | 123 | # Attack Boss 124 | bosses_menu = await click_button(battle_menu, "bosses") 125 | if "attackmax" in bosses_menu: 126 | await click_button(bosses_menu, "attackmax") 127 | 128 | 129 | async def main(): 130 | controller = create_game_controller() 131 | await controller.initialize() 132 | 133 | while True: 134 | try: 135 | await perform_full_run(controller) 136 | except KeyboardInterrupt: 137 | print("Done.") 138 | break 139 | except BaseException: 140 | traceback.print_exc() 141 | 142 | 143 | if __name__ == "__main__": 144 | asyncio.get_event_loop().run_until_complete(main()) 145 | -------------------------------------------------------------------------------- /examples/config_template.ini: -------------------------------------------------------------------------------- 1 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 2 | ;; BEFORE YOU START: ;; 3 | ;; ;; 4 | ;; 1) Insert your credentials ;; 5 | ;; 2) Rename or copy this file to "config.ini" ;; 6 | ;; ;; 7 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 8 | 9 | [pyrogram] 10 | api_id=CHANGEME 11 | api_hash=CHANGEME 12 | -------------------------------------------------------------------------------- /examples/playground.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | 4 | from pyrogram import Client 5 | from pyrogram import filters as f 6 | 7 | from tgintegration import BotController 8 | 9 | examples_dir = Path(__file__).parent 10 | print(examples_dir) 11 | 12 | # This example uses the configuration of `config.ini` (see examples/README) 13 | client = Client( 14 | "tgintegration_examples", 15 | config_file=str(examples_dir / "config.ini"), 16 | workdir=str(examples_dir), 17 | ) 18 | 19 | 20 | controller = BotController(peer="@deerspangle", client=client, raise_no_response=False) 21 | 22 | 23 | async def main(): 24 | await controller.initialize() 25 | 26 | while True: 27 | async with controller.collect( 28 | f.chat("@TgIntegration"), max_wait=30 29 | ) as response: 30 | await client.send_message( 31 | "@TgIntegration", 32 | "Hi @deerspangle! Please say something in the next 30 seconds...", 33 | ) 34 | 35 | await client.send_message( 36 | "@TgIntegration", 37 | "You did not reply :(" 38 | if response.is_empty 39 | else f"You replied with: {response.full_text}", 40 | ) 41 | 42 | 43 | if __name__ == "__main__": 44 | asyncio.get_event_loop().run_until_complete(main()) 45 | -------------------------------------------------------------------------------- /examples/pytest/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from pathlib import Path 4 | 5 | import pytest 6 | from decouple import config 7 | from pyrogram import Client 8 | 9 | from tgintegration import BotController 10 | 11 | examples_dir = Path(__file__).parent.parent 12 | 13 | logger = logging.getLogger("tgintegration") 14 | logger.setLevel(logging.DEBUG) 15 | logging.basicConfig(level=logging.DEBUG) 16 | logging.getLogger("pyrogram").setLevel(logging.WARNING) 17 | 18 | 19 | @pytest.yield_fixture(scope="session", autouse=True) 20 | def event_loop(request): 21 | """Create an instance of the default event loop for the session.""" 22 | loop = asyncio.get_event_loop_policy().new_event_loop() 23 | yield loop 24 | loop.close() 25 | 26 | 27 | @pytest.fixture(scope="session") 28 | async def client() -> Client: 29 | # noinspection PyCallingNonCallable 30 | client = Client( 31 | config("SESSION_STRING", default=None) or "tgintegration_examples", 32 | workdir=examples_dir, 33 | config_file=str(examples_dir / "config.ini"), 34 | ) 35 | await client.start() 36 | yield client 37 | await client.stop() 38 | 39 | 40 | @pytest.fixture(scope="module") 41 | async def controller(client): 42 | c = BotController( 43 | client=client, 44 | peer="@BotListBot", 45 | max_wait=10.0, 46 | wait_consecutive=0.8, 47 | ) 48 | await c.initialize(start_client=False) 49 | yield c 50 | -------------------------------------------------------------------------------- /examples/pytest/test_basic.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tgintegration import Response 4 | 5 | pytestmark = pytest.mark.asyncio 6 | 7 | 8 | async def test_start(controller, client): 9 | async with controller.collect(count=3) as res: # type: Response 10 | await controller.send_command("/start") 11 | 12 | assert res.num_messages == 3 13 | assert res[0].sticker # First message is a sticker 14 | 15 | 16 | async def test_ping(controller, client): 17 | assert await controller.ping_bot() 18 | 19 | 20 | async def test_help(controller): 21 | # Send /help and wait for one message 22 | async with controller.collect(count=1) as res: # type: Response 23 | await controller.send_command("/help") 24 | 25 | # Make some assertions about the response 26 | assert not res.is_empty, "Bot did not respond to /help command" 27 | assert "most reliable and unbiased bot catalog" in res.full_text.lower() 28 | keyboard = res.inline_keyboards[0] 29 | assert len(keyboard.rows[0]) == 3 # 3 buttons in first row 30 | assert len(keyboard.rows[1]) == 1 # 1 button in second row 31 | 32 | # Click the inline button that says "Contributing" 33 | contributing = await res.inline_keyboards[0].click(pattern=r".*Contributing") 34 | assert not contributing.is_empty, 'Pressing "Contributing" button had no effect.' 35 | assert "to contribute to the botlist" in contributing.full_text.lower() 36 | 37 | # Click the inline button that says "Help" 38 | help_ = await res.inline_keyboards[0].click(pattern=r".*Help") 39 | assert not contributing.is_empty, 'Pressing "Help" button had no effect.' 40 | assert "first steps" in help_.full_text.lower() 41 | 42 | # Click the inline button that says "Examples" 43 | examples = await res.inline_keyboards[0].click(pattern=r".*Examples") 44 | assert not examples.is_empty, 'Pressing "Examples" button had no effect.' 45 | assert "examples for contributing to the botlist:" in examples.full_text.lower() 46 | -------------------------------------------------------------------------------- /examples/pytest/test_commands.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tgintegration import BotController 4 | from tgintegration import Response 5 | 6 | 7 | @pytest.mark.asyncio 8 | async def test_commands(controller: BotController): 9 | # The BotController automatically loads the available commands and we test them all here 10 | for c in controller.command_list: 11 | async with controller.collect() as res: # type: Response 12 | await controller.send_command(c.command) 13 | assert not res.is_empty, "Bot did not respond to command /{}.".format(c.command) 14 | -------------------------------------------------------------------------------- /examples/pytest/test_explore.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tgintegration import BotController 4 | 5 | 6 | @pytest.mark.asyncio 7 | async def test_explore_button(controller: BotController): 8 | # Send /start to peer_user and wait for 3 messages 9 | async with controller.collect(count=3) as start: 10 | await controller.send_command("/start") 11 | 12 | # Click the "Explore" keyboard button 13 | explore = await start.reply_keyboard.click(pattern=r".*Explore") 14 | 15 | assert not explore.is_empty, 'Pressing the "Explore" button had no effect.' 16 | assert explore.inline_keyboards, 'The "Explore" message had no inline keyboard.' 17 | 18 | # Click the "Explore" inline keyboard button 10 times or until it says that 19 | # all bots have been explored 20 | count = 10 21 | while "explored all the bots" not in explore.full_text: 22 | if count == 0: 23 | break # ok 24 | 25 | # Pressing an inline button also makes the BotController listen for edit events. 26 | explore = await explore.inline_keyboards[0].click(index=2) 27 | assert not explore.is_empty, 'Pressing the "Explore" button had no effect.' 28 | count -= 1 29 | -------------------------------------------------------------------------------- /examples/pytest/test_inlinequeries.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | 5 | from tgintegration import BotController 6 | from tgintegration import Response 7 | 8 | pytestmark = pytest.mark.asyncio 9 | 10 | 11 | @pytest.fixture(scope="module") 12 | def bots(): 13 | return ["@bold", "@BotListBot", "@gif"] 14 | 15 | 16 | async def test_search(controller, client, bots): 17 | for username in bots: 18 | # First send the username in private chat to get target description of the peer_user 19 | async with controller.collect(count=1) as res: # type: Response 20 | await client.send_message(controller.peer, username) 21 | 22 | assert not res.is_empty, "Bot did not yield a response for username {}.".format( 23 | username 24 | ) 25 | full_expected = res.full_text 26 | 27 | inline_response = await controller.query_inline(username) 28 | results = list( 29 | inline_response.find_results( 30 | title_pattern=re.compile(r"{}\b.*".format(username), re.IGNORECASE) 31 | ) 32 | ) 33 | assert len(results) == 1, "Not exactly one result for {}".format(username) 34 | 35 | # Description of peer_user should be the same in inline query message and private message 36 | assert ( 37 | full_expected in results[0].result.send_message.message 38 | ), "Message texts did not match." 39 | 40 | 41 | @pytest.mark.parametrize("test_input", ["contributing", "rules", "examples"]) 42 | async def test_inline_queries(controller: BotController, test_input: str): 43 | inline_results = await controller.query_inline(test_input) 44 | single_result = inline_results.find_results( 45 | title_pattern=re.compile(r".*{}.*".format(test_input), re.IGNORECASE) 46 | ) 47 | assert len(single_result) == 1, "{} did not work".format(test_input) 48 | -------------------------------------------------------------------------------- /examples/readme_example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JosXa/tgintegration/39dfc82eb5c80bb6845c12edf0c112e2fa7de26f/examples/readme_example/__init__.py -------------------------------------------------------------------------------- /examples/readme_example/readmeexample.py: -------------------------------------------------------------------------------- 1 | """ 2 | Full version of the GitHub README. 3 | """ 4 | import asyncio 5 | from pathlib import Path 6 | 7 | from decouple import config 8 | from pyrogram import Client 9 | 10 | from tgintegration import BotController 11 | from tgintegration import Response 12 | 13 | # This example uses the configuration of `config.ini` (see examples/README) 14 | examples_dir = Path(__file__).parent.parent.absolute() 15 | SESSION_NAME: str = "tgintegration_examples" 16 | 17 | 18 | # This example uses the configuration of `config.ini` (see examples/README) 19 | def create_client(session_name: str = SESSION_NAME) -> Client: 20 | return Client( 21 | name=session_name, 22 | api_id=config("API_ID"), 23 | api_hash=config("API_HASH"), 24 | session_string=config("SESSION_STRING"), 25 | workdir=str(examples_dir), 26 | ) 27 | 28 | 29 | async def run_example(client: Client): 30 | controller = BotController( 31 | peer="@BotListBot", # We are going to run tests on https://t.me/BotListBot 32 | client=client, 33 | max_wait=8, # Maximum timeout for responses (optional) 34 | wait_consecutive=2, # Minimum time to wait for more/consecutive messages (optional) 35 | raise_no_response=True, # Raise `InvalidResponseError` when no response received (defaults to True) 36 | global_action_delay=2.5, # Choosing a rather high delay so we can follow along in realtime (optional) 37 | ) 38 | 39 | print("Clearing chat to start with a blank screen...") 40 | await controller.clear_chat() 41 | 42 | print("Sending /start and waiting for exactly 3 messages...") 43 | async with controller.collect(count=3) as response: # type: Response 44 | await controller.send_command("start") 45 | 46 | assert response.num_messages == 3 47 | print("Three messages received, bundled as a `Response`.") 48 | assert response.messages[0].sticker 49 | print("First message is a sticker.") 50 | 51 | print("Let's examine the buttons in the response...") 52 | inline_keyboard = response.inline_keyboards[0] 53 | assert len(inline_keyboard.rows[0]) == 3 54 | print("Yep, there are three buttons in the first row.") 55 | 56 | # We can also press the inline keyboard buttons, in this case based on a pattern: 57 | print("Clicking the button matching the regex r'.*Examples'") 58 | examples = await inline_keyboard.click(pattern=r".*Examples") 59 | 60 | assert "Examples for contributing to the BotList" in examples.full_text 61 | # As the bot edits the message, `.click()` automatically listens for "message edited" 62 | # updates and returns the new state as `Response`. 63 | 64 | print("So what happens when we send an invalid query or the peer fails to respond?") 65 | from tgintegration import InvalidResponseError 66 | 67 | try: 68 | # The following instruction will raise an `InvalidResponseError` after 69 | # `controller.max_wait` seconds. This is because we passed `raise_no_response=True` 70 | # during controller initialization. 71 | print("Expecting unhandled command to raise InvalidResponseError...") 72 | async with controller.collect(): 73 | await controller.send_command("ayylmao") 74 | except InvalidResponseError: 75 | print("Ok, raised as expected.") 76 | 77 | # If `raise_` is explicitly set to False, no exception is raised 78 | async with controller.collect(raise_=False) as response: # type: Response 79 | print("Sending a message but expecting no reply...") 80 | await client.send_message(controller.peer_id, "Henlo Fren") 81 | 82 | # In this case, tgintegration will simply emit a warning, but you can still assert 83 | # that no response has been received by using the `is_empty` property. 84 | assert response.is_empty 85 | 86 | print("Success!") 87 | 88 | 89 | if __name__ == "__main__": 90 | asyncio.get_event_loop().run_until_complete(run_example(create_client())) 91 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: tgintegration 2 | site_url: https://josxa.github.io/tgintegration 3 | repo_url: https://github.com/JosXa/tgintegration 4 | theme: 5 | name: material 6 | # logo: 'assets/tgintegration-logo.png' 7 | favicon: 'assets/favicon.png' 8 | icon: 9 | repo: fontawesome/brands/github 10 | palette: 11 | # https://squidfunk.github.io/mkdocs-material/setup/changing-the-colors/ 12 | primary: indigo 13 | accent: indigo 14 | features: 15 | - navigation.instant 16 | - navigation.expand 17 | - navigation.tabs 18 | 19 | extra: 20 | generator: false # Only for insiders 21 | tgi: '_tgintegration_' 22 | 23 | extra_css: 24 | - styles/mkapi-overrides.css 25 | - styles/mkdocstrings-overrides.css 26 | 27 | 28 | nav: 29 | - Docs: 30 | - Overview: 'index.md' 31 | - Setup: 'setup.md' 32 | - Getting Started: 'getting-started.md' 33 | - 'API Reference': mkapi/api/tgintegration 34 | # - Doc plugin comparison: 35 | # - Mkdocstrings reference: 'api-mkdocstrings.md' 36 | # - Mkapi reference: 'api-mkapi.md' 37 | 38 | markdown_extensions: 39 | - meta 40 | - markdown_include.include: 41 | base_path: docs 42 | - pymdownx.tasklist 43 | - pymdownx.inlinehilite 44 | - pymdownx.highlight 45 | - pymdownx.superfences 46 | - admonition 47 | - codehilite 48 | 49 | plugins: 50 | - search 51 | # TODO: Decision pending between mkapi and mkdocstrings 52 | - mkapi: 53 | src_dirs: [ "tgintegration" ] 54 | # filters: [ short ] 55 | filters: [] 56 | on_config: docs.api_overrides.headers.on_config_with_mkapi 57 | - mkdocstrings: 58 | default_handler: python 59 | watch: 60 | - tgintegration 61 | - markdownextradata: 62 | data: 'extra_templates.yml' 63 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "atomicwrites" 3 | version = "1.4.0" 4 | description = "Atomic file writes." 5 | category = "dev" 6 | optional = false 7 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 8 | 9 | [[package]] 10 | name = "attrs" 11 | version = "21.4.0" 12 | description = "Classes Without Boilerplate" 13 | category = "dev" 14 | optional = false 15 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 16 | 17 | [package.extras] 18 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] 19 | docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] 20 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] 21 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] 22 | 23 | [[package]] 24 | name = "autoflake" 25 | version = "1.4" 26 | description = "Removes unused imports and unused variables" 27 | category = "dev" 28 | optional = false 29 | python-versions = "*" 30 | 31 | [package.dependencies] 32 | pyflakes = ">=1.1.0" 33 | 34 | [[package]] 35 | name = "beautifulsoup4" 36 | version = "4.11.1" 37 | description = "Screen-scraping library" 38 | category = "dev" 39 | optional = false 40 | python-versions = ">=3.6.0" 41 | 42 | [package.dependencies] 43 | soupsieve = ">1.2" 44 | 45 | [package.extras] 46 | html5lib = ["html5lib"] 47 | lxml = ["lxml"] 48 | 49 | [[package]] 50 | name = "bumpversion" 51 | version = "0.5.3" 52 | description = "Version-bump your software with a single command!" 53 | category = "dev" 54 | optional = false 55 | python-versions = "*" 56 | 57 | [[package]] 58 | name = "click" 59 | version = "8.1.3" 60 | description = "Composable command line interface toolkit" 61 | category = "dev" 62 | optional = false 63 | python-versions = ">=3.7" 64 | 65 | [package.dependencies] 66 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 67 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 68 | 69 | [[package]] 70 | name = "colorama" 71 | version = "0.4.4" 72 | description = "Cross-platform colored terminal text." 73 | category = "dev" 74 | optional = false 75 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 76 | 77 | [[package]] 78 | name = "coverage" 79 | version = "6.4.1" 80 | description = "Code coverage measurement for Python" 81 | category = "dev" 82 | optional = false 83 | python-versions = ">=3.7" 84 | 85 | [package.extras] 86 | toml = ["tomli"] 87 | 88 | [[package]] 89 | name = "ghp-import" 90 | version = "2.1.0" 91 | description = "Copy your docs directly to the gh-pages branch." 92 | category = "dev" 93 | optional = false 94 | python-versions = "*" 95 | 96 | [package.dependencies] 97 | python-dateutil = ">=2.8.1" 98 | 99 | [package.extras] 100 | dev = ["twine", "markdown", "flake8", "wheel"] 101 | 102 | [[package]] 103 | name = "importlib-metadata" 104 | version = "4.11.4" 105 | description = "Read metadata from Python packages" 106 | category = "dev" 107 | optional = false 108 | python-versions = ">=3.7" 109 | 110 | [package.dependencies] 111 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 112 | zipp = ">=0.5" 113 | 114 | [package.extras] 115 | docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] 116 | perf = ["ipython"] 117 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] 118 | 119 | [[package]] 120 | name = "iniconfig" 121 | version = "1.1.1" 122 | description = "iniconfig: brain-dead simple config-ini parsing" 123 | category = "dev" 124 | optional = false 125 | python-versions = "*" 126 | 127 | [[package]] 128 | name = "jinja2" 129 | version = "3.1.2" 130 | description = "A very fast and expressive template engine." 131 | category = "dev" 132 | optional = false 133 | python-versions = ">=3.7" 134 | 135 | [package.dependencies] 136 | MarkupSafe = ">=2.0" 137 | 138 | [package.extras] 139 | i18n = ["Babel (>=2.7)"] 140 | 141 | [[package]] 142 | name = "markdown" 143 | version = "3.3.7" 144 | description = "Python implementation of Markdown." 145 | category = "dev" 146 | optional = false 147 | python-versions = ">=3.6" 148 | 149 | [package.dependencies] 150 | importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} 151 | 152 | [package.extras] 153 | testing = ["coverage", "pyyaml"] 154 | 155 | [[package]] 156 | name = "markdown-include" 157 | version = "0.6.0" 158 | description = "This is an extension to Python-Markdown which provides an \"include\" function, similar to that found in LaTeX (and also the C pre-processor and Fortran). I originally wrote it for my FORD Fortran auto-documentation generator." 159 | category = "dev" 160 | optional = false 161 | python-versions = "*" 162 | 163 | [package.dependencies] 164 | markdown = "*" 165 | 166 | [[package]] 167 | name = "markupsafe" 168 | version = "2.1.1" 169 | description = "Safely add untrusted strings to HTML/XML markup." 170 | category = "dev" 171 | optional = false 172 | python-versions = ">=3.7" 173 | 174 | [[package]] 175 | name = "mergedeep" 176 | version = "1.3.4" 177 | description = "A deep merge function for 🐍." 178 | category = "dev" 179 | optional = false 180 | python-versions = ">=3.6" 181 | 182 | [[package]] 183 | name = "mkapi" 184 | version = "1.0.14" 185 | description = "An Auto API Documentation tool." 186 | category = "dev" 187 | optional = false 188 | python-versions = ">=3.7" 189 | 190 | [package.dependencies] 191 | jinja2 = "*" 192 | markdown = "*" 193 | 194 | [[package]] 195 | name = "mkdocs" 196 | version = "1.3.0" 197 | description = "Project documentation with Markdown." 198 | category = "dev" 199 | optional = false 200 | python-versions = ">=3.6" 201 | 202 | [package.dependencies] 203 | click = ">=3.3" 204 | ghp-import = ">=1.0" 205 | importlib-metadata = ">=4.3" 206 | Jinja2 = ">=2.10.2" 207 | Markdown = ">=3.2.1" 208 | mergedeep = ">=1.3.4" 209 | packaging = ">=20.5" 210 | PyYAML = ">=3.10" 211 | pyyaml-env-tag = ">=0.1" 212 | watchdog = ">=2.0" 213 | 214 | [package.extras] 215 | i18n = ["babel (>=2.9.0)"] 216 | 217 | [[package]] 218 | name = "mkdocs-markdownextradata-plugin" 219 | version = "0.1.9" 220 | description = "A MkDocs plugin that injects the mkdocs.yml extra variables into the markdown template" 221 | category = "dev" 222 | optional = false 223 | python-versions = ">=2.7.9,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" 224 | 225 | [package.dependencies] 226 | mkdocs = "*" 227 | pyyaml = "*" 228 | 229 | [[package]] 230 | name = "mkdocs-material" 231 | version = "6.2.8" 232 | description = "A Material Design theme for MkDocs" 233 | category = "dev" 234 | optional = false 235 | python-versions = "*" 236 | 237 | [package.dependencies] 238 | markdown = ">=3.2" 239 | mkdocs = ">=1.1" 240 | mkdocs-material-extensions = ">=1.0" 241 | Pygments = ">=2.4" 242 | pymdown-extensions = ">=7.0" 243 | 244 | [[package]] 245 | name = "mkdocs-material-extensions" 246 | version = "1.0.3" 247 | description = "Extension pack for Python Markdown." 248 | category = "dev" 249 | optional = false 250 | python-versions = ">=3.6" 251 | 252 | [[package]] 253 | name = "mkdocs-toc-sidebar-plugin" 254 | version = "0.1.0" 255 | description = "An MkDocs plugin" 256 | category = "dev" 257 | optional = false 258 | python-versions = ">=2.7" 259 | 260 | [package.dependencies] 261 | beautifulsoup4 = ">=4.8.2" 262 | mkdocs = ">=1.0.4" 263 | mkdocs-material = ">=4.5.0" 264 | 265 | [[package]] 266 | name = "mkdocstrings" 267 | version = "0.13.6" 268 | description = "Automatic documentation from sources, for MkDocs." 269 | category = "dev" 270 | optional = false 271 | python-versions = ">=3.6,<4.0" 272 | 273 | [package.dependencies] 274 | beautifulsoup4 = ">=4.8.2,<5.0.0" 275 | mkdocs = ">=1.1,<2.0" 276 | pymdown-extensions = ">=6.3,<9.0" 277 | pytkdocs = ">=0.2.0,<0.10.0" 278 | 279 | [package.extras] 280 | tests = ["coverage (>=5.2.1,<6.0.0)", "invoke (>=1.4.1,<2.0.0)", "mkdocs-material (>=5.5.12,<6.0.0)", "mypy (>=0.782,<0.783)", "pytest (>=6.0.1,<7.0.0)", "pytest-cov (>=2.10.1,<3.0.0)", "pytest-randomly (>=3.4.1,<4.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=2.1.0,<3.0.0)"] 281 | 282 | [[package]] 283 | name = "packaging" 284 | version = "21.3" 285 | description = "Core utilities for Python packages" 286 | category = "dev" 287 | optional = false 288 | python-versions = ">=3.6" 289 | 290 | [package.dependencies] 291 | pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" 292 | 293 | [[package]] 294 | name = "panflute" 295 | version = "1.12.5" 296 | description = "Pythonic Pandoc filters" 297 | category = "dev" 298 | optional = false 299 | python-versions = "*" 300 | 301 | [package.dependencies] 302 | click = "*" 303 | pyyaml = "*" 304 | 305 | [package.extras] 306 | pypi = ["docutils", "pygments"] 307 | test = ["pandocfilters", "configparser", "pytest-cov"] 308 | 309 | [[package]] 310 | name = "pastel" 311 | version = "0.2.1" 312 | description = "Bring colors to your terminal." 313 | category = "dev" 314 | optional = false 315 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 316 | 317 | [[package]] 318 | name = "pluggy" 319 | version = "1.0.0" 320 | description = "plugin and hook calling mechanisms for python" 321 | category = "dev" 322 | optional = false 323 | python-versions = ">=3.6" 324 | 325 | [package.dependencies] 326 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 327 | 328 | [package.extras] 329 | dev = ["pre-commit", "tox"] 330 | testing = ["pytest", "pytest-benchmark"] 331 | 332 | [[package]] 333 | name = "poethepoet" 334 | version = "0.9.0" 335 | description = "A task runner that works well with poetry." 336 | category = "dev" 337 | optional = false 338 | python-versions = ">=3.6,<4.0" 339 | 340 | [package.dependencies] 341 | pastel = ">=0.2.0,<0.3.0" 342 | tomlkit = ">=0.6.0,<1.0.0" 343 | 344 | [[package]] 345 | name = "py" 346 | version = "1.11.0" 347 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 348 | category = "dev" 349 | optional = false 350 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 351 | 352 | [[package]] 353 | name = "pyaes" 354 | version = "1.6.1" 355 | description = "Pure-Python Implementation of the AES block-cipher and common modes of operation" 356 | category = "main" 357 | optional = false 358 | python-versions = "*" 359 | 360 | [[package]] 361 | name = "pyflakes" 362 | version = "2.4.0" 363 | description = "passive checker of Python programs" 364 | category = "dev" 365 | optional = false 366 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 367 | 368 | [[package]] 369 | name = "pygments" 370 | version = "2.12.0" 371 | description = "Pygments is a syntax highlighting package written in Python." 372 | category = "dev" 373 | optional = false 374 | python-versions = ">=3.6" 375 | 376 | [[package]] 377 | name = "pymdown-extensions" 378 | version = "8.2" 379 | description = "Extension pack for Python Markdown." 380 | category = "dev" 381 | optional = false 382 | python-versions = ">=3.6" 383 | 384 | [package.dependencies] 385 | Markdown = ">=3.2" 386 | 387 | [[package]] 388 | name = "pypandoc" 389 | version = "1.8.1" 390 | description = "Thin wrapper for pandoc." 391 | category = "dev" 392 | optional = false 393 | python-versions = ">=3.6" 394 | 395 | [[package]] 396 | name = "pyparsing" 397 | version = "3.0.9" 398 | description = "pyparsing module - Classes and methods to define and execute parsing grammars" 399 | category = "dev" 400 | optional = false 401 | python-versions = ">=3.6.8" 402 | 403 | [package.extras] 404 | diagrams = ["railroad-diagrams", "jinja2"] 405 | 406 | [[package]] 407 | name = "pyrogram" 408 | version = "2.0.26" 409 | description = "Elegant, modern and asynchronous Telegram MTProto API framework in Python for users and bots" 410 | category = "main" 411 | optional = false 412 | python-versions = "~=3.7" 413 | 414 | [package.dependencies] 415 | pyaes = "1.6.1" 416 | pysocks = "1.7.1" 417 | 418 | [[package]] 419 | name = "pysocks" 420 | version = "1.7.1" 421 | description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information." 422 | category = "main" 423 | optional = false 424 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 425 | 426 | [[package]] 427 | name = "pytest" 428 | version = "7.1.2" 429 | description = "pytest: simple powerful testing with Python" 430 | category = "dev" 431 | optional = false 432 | python-versions = ">=3.7" 433 | 434 | [package.dependencies] 435 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 436 | attrs = ">=19.2.0" 437 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 438 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 439 | iniconfig = "*" 440 | packaging = "*" 441 | pluggy = ">=0.12,<2.0" 442 | py = ">=1.8.2" 443 | tomli = ">=1.0.0" 444 | 445 | [package.extras] 446 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] 447 | 448 | [[package]] 449 | name = "pytest-asyncio" 450 | version = "0.18.3" 451 | description = "Pytest support for asyncio" 452 | category = "dev" 453 | optional = false 454 | python-versions = ">=3.7" 455 | 456 | [package.dependencies] 457 | pytest = ">=6.1.0" 458 | typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""} 459 | 460 | [package.extras] 461 | testing = ["coverage (==6.2)", "hypothesis (>=5.7.1)", "flaky (>=3.5.0)", "mypy (==0.931)", "pytest-trio (>=0.7.0)"] 462 | 463 | [[package]] 464 | name = "pytest-cov" 465 | version = "2.12.1" 466 | description = "Pytest plugin for measuring coverage." 467 | category = "dev" 468 | optional = false 469 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 470 | 471 | [package.dependencies] 472 | coverage = ">=5.2.1" 473 | pytest = ">=4.6" 474 | toml = "*" 475 | 476 | [package.extras] 477 | testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] 478 | 479 | [[package]] 480 | name = "pytest-runner" 481 | version = "6.0.0" 482 | description = "Invoke py.test as distutils command with dependency resolution" 483 | category = "dev" 484 | optional = false 485 | python-versions = ">=3.7" 486 | 487 | [package.extras] 488 | docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"] 489 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-virtualenv", "types-setuptools", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] 490 | 491 | [[package]] 492 | name = "python-dateutil" 493 | version = "2.8.2" 494 | description = "Extensions to the standard Python datetime module" 495 | category = "dev" 496 | optional = false 497 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 498 | 499 | [package.dependencies] 500 | six = ">=1.5" 501 | 502 | [[package]] 503 | name = "python-decouple" 504 | version = "3.6" 505 | description = "Strict separation of settings from code." 506 | category = "dev" 507 | optional = false 508 | python-versions = "*" 509 | 510 | [[package]] 511 | name = "pytkdocs" 512 | version = "0.9.0" 513 | description = "Load Python objects documentation." 514 | category = "dev" 515 | optional = false 516 | python-versions = ">=3.6,<4.0" 517 | 518 | [package.extras] 519 | tests = ["coverage (>=5.2.1,<6.0.0)", "invoke (>=1.4.1,<2.0.0)", "marshmallow (>=3.5.2,<4.0.0)", "mypy (>=0.782,<0.783)", "pydantic (>=1.5.1,<2.0.0)", "pytest (>=6.0.1,<7.0.0)", "pytest-cov (>=2.10.1,<3.0.0)", "pytest-randomly (>=3.4.1,<4.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=2.1.0,<3.0.0)"] 520 | 521 | [[package]] 522 | name = "pyyaml" 523 | version = "6.0" 524 | description = "YAML parser and emitter for Python" 525 | category = "dev" 526 | optional = false 527 | python-versions = ">=3.6" 528 | 529 | [[package]] 530 | name = "pyyaml-env-tag" 531 | version = "0.1" 532 | description = "A custom YAML tag for referencing environment variables in YAML files. " 533 | category = "dev" 534 | optional = false 535 | python-versions = ">=3.6" 536 | 537 | [package.dependencies] 538 | pyyaml = "*" 539 | 540 | [[package]] 541 | name = "six" 542 | version = "1.16.0" 543 | description = "Python 2 and 3 compatibility utilities" 544 | category = "dev" 545 | optional = false 546 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 547 | 548 | [[package]] 549 | name = "soupsieve" 550 | version = "2.3.2.post1" 551 | description = "A modern CSS selector implementation for Beautiful Soup." 552 | category = "dev" 553 | optional = false 554 | python-versions = ">=3.6" 555 | 556 | [[package]] 557 | name = "tgcrypto" 558 | version = "1.2.3" 559 | description = "Fast and Portable Cryptography Extension Library for Pyrogram" 560 | category = "dev" 561 | optional = false 562 | python-versions = "~=3.6" 563 | 564 | [[package]] 565 | name = "toml" 566 | version = "0.10.2" 567 | description = "Python Library for Tom's Obvious, Minimal Language" 568 | category = "dev" 569 | optional = false 570 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 571 | 572 | [[package]] 573 | name = "tomli" 574 | version = "2.0.1" 575 | description = "A lil' TOML parser" 576 | category = "dev" 577 | optional = false 578 | python-versions = ">=3.7" 579 | 580 | [[package]] 581 | name = "tomlkit" 582 | version = "0.11.0" 583 | description = "Style preserving TOML library" 584 | category = "dev" 585 | optional = false 586 | python-versions = ">=3.6,<4.0" 587 | 588 | [[package]] 589 | name = "typing-extensions" 590 | version = "3.10.0.2" 591 | description = "Backported and Experimental Type Hints for Python 3.5+" 592 | category = "main" 593 | optional = false 594 | python-versions = "*" 595 | 596 | [[package]] 597 | name = "watchdog" 598 | version = "2.1.8" 599 | description = "Filesystem events monitoring" 600 | category = "dev" 601 | optional = false 602 | python-versions = ">=3.6" 603 | 604 | [package.extras] 605 | watchmedo = ["PyYAML (>=3.10)"] 606 | 607 | [[package]] 608 | name = "zipp" 609 | version = "3.8.0" 610 | description = "Backport of pathlib-compatible object wrapper for zip files" 611 | category = "dev" 612 | optional = false 613 | python-versions = ">=3.7" 614 | 615 | [package.extras] 616 | docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] 617 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] 618 | 619 | [metadata] 620 | lock-version = "1.1" 621 | python-versions = "==3.*,>=3.7" 622 | content-hash = "38640b603ad481b522d2f57eb0590e5495eef7c83609256f02c5be9467d1ade6" 623 | 624 | [metadata.files] 625 | atomicwrites = [ 626 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 627 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 628 | ] 629 | attrs = [ 630 | {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, 631 | {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, 632 | ] 633 | autoflake = [ 634 | {file = "autoflake-1.4.tar.gz", hash = "sha256:61a353012cff6ab94ca062823d1fb2f692c4acda51c76ff83a8d77915fba51ea"}, 635 | ] 636 | beautifulsoup4 = [ 637 | {file = "beautifulsoup4-4.11.1-py3-none-any.whl", hash = "sha256:58d5c3d29f5a36ffeb94f02f0d786cd53014cf9b3b3951d42e0080d8a9498d30"}, 638 | {file = "beautifulsoup4-4.11.1.tar.gz", hash = "sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693"}, 639 | ] 640 | bumpversion = [ 641 | {file = "bumpversion-0.5.3-py2.py3-none-any.whl", hash = "sha256:6753d9ff3552013e2130f7bc03c1007e24473b4835952679653fb132367bdd57"}, 642 | {file = "bumpversion-0.5.3.tar.gz", hash = "sha256:6744c873dd7aafc24453d8b6a1a0d6d109faf63cd0cd19cb78fd46e74932c77e"}, 643 | ] 644 | click = [ 645 | {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, 646 | {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, 647 | ] 648 | colorama = [ 649 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 650 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 651 | ] 652 | coverage = [ 653 | {file = "coverage-6.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f1d5aa2703e1dab4ae6cf416eb0095304f49d004c39e9db1d86f57924f43006b"}, 654 | {file = "coverage-6.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4ce1b258493cbf8aec43e9b50d89982346b98e9ffdfaae8ae5793bc112fb0068"}, 655 | {file = "coverage-6.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83c4e737f60c6936460c5be330d296dd5b48b3963f48634c53b3f7deb0f34ec4"}, 656 | {file = "coverage-6.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84e65ef149028516c6d64461b95a8dbcfce95cfd5b9eb634320596173332ea84"}, 657 | {file = "coverage-6.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f69718750eaae75efe506406c490d6fc5a6161d047206cc63ce25527e8a3adad"}, 658 | {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e57816f8ffe46b1df8f12e1b348f06d164fd5219beba7d9433ba79608ef011cc"}, 659 | {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:01c5615d13f3dd3aa8543afc069e5319cfa0c7d712f6e04b920431e5c564a749"}, 660 | {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:75ab269400706fab15981fd4bd5080c56bd5cc07c3bccb86aab5e1d5a88dc8f4"}, 661 | {file = "coverage-6.4.1-cp310-cp310-win32.whl", hash = "sha256:a7f3049243783df2e6cc6deafc49ea123522b59f464831476d3d1448e30d72df"}, 662 | {file = "coverage-6.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:ee2ddcac99b2d2aec413e36d7a429ae9ebcadf912946b13ffa88e7d4c9b712d6"}, 663 | {file = "coverage-6.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb73e0011b8793c053bfa85e53129ba5f0250fdc0392c1591fd35d915ec75c46"}, 664 | {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106c16dfe494de3193ec55cac9640dd039b66e196e4641fa8ac396181578b982"}, 665 | {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87f4f3df85aa39da00fd3ec4b5abeb7407e82b68c7c5ad181308b0e2526da5d4"}, 666 | {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:961e2fb0680b4f5ad63234e0bf55dfb90d302740ae9c7ed0120677a94a1590cb"}, 667 | {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:cec3a0f75c8f1031825e19cd86ee787e87cf03e4fd2865c79c057092e69e3a3b"}, 668 | {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:129cd05ba6f0d08a766d942a9ed4b29283aff7b2cccf5b7ce279d50796860bb3"}, 669 | {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bf5601c33213d3cb19d17a796f8a14a9eaa5e87629a53979a5981e3e3ae166f6"}, 670 | {file = "coverage-6.4.1-cp37-cp37m-win32.whl", hash = "sha256:269eaa2c20a13a5bf17558d4dc91a8d078c4fa1872f25303dddcbba3a813085e"}, 671 | {file = "coverage-6.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f02cbbf8119db68455b9d763f2f8737bb7db7e43720afa07d8eb1604e5c5ae28"}, 672 | {file = "coverage-6.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ffa9297c3a453fba4717d06df579af42ab9a28022444cae7fa605af4df612d54"}, 673 | {file = "coverage-6.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:145f296d00441ca703a659e8f3eb48ae39fb083baba2d7ce4482fb2723e050d9"}, 674 | {file = "coverage-6.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d44996140af8b84284e5e7d398e589574b376fb4de8ccd28d82ad8e3bea13"}, 675 | {file = "coverage-6.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2bd9a6fc18aab8d2e18f89b7ff91c0f34ff4d5e0ba0b33e989b3cd4194c81fd9"}, 676 | {file = "coverage-6.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3384f2a3652cef289e38100f2d037956194a837221edd520a7ee5b42d00cc605"}, 677 | {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9b3e07152b4563722be523e8cd0b209e0d1a373022cfbde395ebb6575bf6790d"}, 678 | {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1480ff858b4113db2718848d7b2d1b75bc79895a9c22e76a221b9d8d62496428"}, 679 | {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:865d69ae811a392f4d06bde506d531f6a28a00af36f5c8649684a9e5e4a85c83"}, 680 | {file = "coverage-6.4.1-cp38-cp38-win32.whl", hash = "sha256:664a47ce62fe4bef9e2d2c430306e1428ecea207ffd68649e3b942fa8ea83b0b"}, 681 | {file = "coverage-6.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:26dff09fb0d82693ba9e6231248641d60ba606150d02ed45110f9ec26404ed1c"}, 682 | {file = "coverage-6.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d9c80df769f5ec05ad21ea34be7458d1dc51ff1fb4b2219e77fe24edf462d6df"}, 683 | {file = "coverage-6.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:39ee53946bf009788108b4dd2894bf1349b4e0ca18c2016ffa7d26ce46b8f10d"}, 684 | {file = "coverage-6.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5b66caa62922531059bc5ac04f836860412f7f88d38a476eda0a6f11d4724f4"}, 685 | {file = "coverage-6.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd180ed867e289964404051a958f7cccabdeed423f91a899829264bb7974d3d3"}, 686 | {file = "coverage-6.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84631e81dd053e8a0d4967cedab6db94345f1c36107c71698f746cb2636c63e3"}, 687 | {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8c08da0bd238f2970230c2a0d28ff0e99961598cb2e810245d7fc5afcf1254e8"}, 688 | {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d42c549a8f41dc103a8004b9f0c433e2086add8a719da00e246e17cbe4056f72"}, 689 | {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:309ce4a522ed5fca432af4ebe0f32b21d6d7ccbb0f5fcc99290e71feba67c264"}, 690 | {file = "coverage-6.4.1-cp39-cp39-win32.whl", hash = "sha256:fdb6f7bd51c2d1714cea40718f6149ad9be6a2ee7d93b19e9f00934c0f2a74d9"}, 691 | {file = "coverage-6.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:342d4aefd1c3e7f620a13f4fe563154d808b69cccef415415aece4c786665397"}, 692 | {file = "coverage-6.4.1-pp36.pp37.pp38-none-any.whl", hash = "sha256:4803e7ccf93230accb928f3a68f00ffa80a88213af98ed338a57ad021ef06815"}, 693 | {file = "coverage-6.4.1.tar.gz", hash = "sha256:4321f075095a096e70aff1d002030ee612b65a205a0a0f5b815280d5dc58100c"}, 694 | ] 695 | ghp-import = [ 696 | {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, 697 | {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, 698 | ] 699 | importlib-metadata = [ 700 | {file = "importlib_metadata-4.11.4-py3-none-any.whl", hash = "sha256:c58c8eb8a762858f49e18436ff552e83914778e50e9d2f1660535ffb364552ec"}, 701 | {file = "importlib_metadata-4.11.4.tar.gz", hash = "sha256:5d26852efe48c0a32b0509ffbc583fda1a2266545a78d104a6f4aff3db17d700"}, 702 | ] 703 | iniconfig = [ 704 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 705 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 706 | ] 707 | jinja2 = [ 708 | {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, 709 | {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, 710 | ] 711 | markdown = [ 712 | {file = "Markdown-3.3.7-py3-none-any.whl", hash = "sha256:f5da449a6e1c989a4cea2631aa8ee67caa5a2ef855d551c88f9e309f4634c621"}, 713 | {file = "Markdown-3.3.7.tar.gz", hash = "sha256:cbb516f16218e643d8e0a95b309f77eb118cb138d39a4f27851e6a63581db874"}, 714 | ] 715 | markdown-include = [ 716 | {file = "markdown-include-0.6.0.tar.gz", hash = "sha256:6f5d680e36f7780c7f0f61dca53ca581bd50d1b56137ddcd6353efafa0c3e4a2"}, 717 | ] 718 | markupsafe = [ 719 | {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, 720 | {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, 721 | {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, 722 | {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"}, 723 | {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"}, 724 | {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"}, 725 | {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"}, 726 | {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"}, 727 | {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"}, 728 | {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"}, 729 | {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"}, 730 | {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"}, 731 | {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"}, 732 | {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"}, 733 | {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"}, 734 | {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"}, 735 | {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"}, 736 | {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"}, 737 | {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"}, 738 | {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"}, 739 | {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"}, 740 | {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"}, 741 | {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"}, 742 | {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"}, 743 | {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"}, 744 | {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"}, 745 | {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"}, 746 | {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"}, 747 | {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"}, 748 | {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"}, 749 | {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"}, 750 | {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"}, 751 | {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"}, 752 | {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"}, 753 | {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"}, 754 | {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"}, 755 | {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"}, 756 | {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"}, 757 | {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, 758 | {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, 759 | ] 760 | mergedeep = [ 761 | {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, 762 | {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, 763 | ] 764 | mkapi = [ 765 | {file = "mkapi-1.0.14-py3-none-any.whl", hash = "sha256:88bff6a183f09a5c80acf3c9edfc32e0ff3e2585589a9b6a962aae0467a79a12"}, 766 | {file = "mkapi-1.0.14.tar.gz", hash = "sha256:b9b75ffeeeb6c29843ca703abf30464acac76c0867ca3ca66e3a825c0f258bb1"}, 767 | ] 768 | mkdocs = [ 769 | {file = "mkdocs-1.3.0-py3-none-any.whl", hash = "sha256:26bd2b03d739ac57a3e6eed0b7bcc86168703b719c27b99ad6ca91dc439aacde"}, 770 | {file = "mkdocs-1.3.0.tar.gz", hash = "sha256:b504405b04da38795fec9b2e5e28f6aa3a73bb0960cb6d5d27ead28952bd35ea"}, 771 | ] 772 | mkdocs-markdownextradata-plugin = [ 773 | {file = "mkdocs-markdownextradata-plugin-0.1.9.tar.gz", hash = "sha256:0147ce850b9c54ded307a6739f8027828eff83baf332d4b28eb7d3e664f71af0"}, 774 | {file = "mkdocs_markdownextradata_plugin-0.1.9-py3-none-any.whl", hash = "sha256:1e8f17d43ebef424af09992290507abb82eed84ff2b3ac28c342d57053843409"}, 775 | ] 776 | mkdocs-material = [ 777 | {file = "mkdocs-material-6.2.8.tar.gz", hash = "sha256:ce2f4a71e5db49540d71fd32f9afba7645765f7eca391e560d1d27f947eb344c"}, 778 | {file = "mkdocs_material-6.2.8-py2.py3-none-any.whl", hash = "sha256:c9b63d709d29778aa3dafc7178b6a8c655b00937be2594aab016d1423696c792"}, 779 | ] 780 | mkdocs-material-extensions = [ 781 | {file = "mkdocs-material-extensions-1.0.3.tar.gz", hash = "sha256:bfd24dfdef7b41c312ede42648f9eb83476ea168ec163b613f9abd12bbfddba2"}, 782 | {file = "mkdocs_material_extensions-1.0.3-py3-none-any.whl", hash = "sha256:a82b70e533ce060b2a5d9eb2bc2e1be201cf61f901f93704b4acf6e3d5983a44"}, 783 | ] 784 | mkdocs-toc-sidebar-plugin = [ 785 | {file = "mkdocs-toc-sidebar-plugin-0.1.0.tar.gz", hash = "sha256:34cb65a8bcb60b07bdf43f76ff03eee6eb0af542df3bab0b4d63118533a24c5e"}, 786 | {file = "mkdocs_toc_sidebar_plugin-0.1.0-py2-none-any.whl", hash = "sha256:871826470435401671d067ef9727f5fd9df6506bd27a3648ac6a02fd41745589"}, 787 | ] 788 | mkdocstrings = [ 789 | {file = "mkdocstrings-0.13.6-py3-none-any.whl", hash = "sha256:79d2a16b8c86a467bdc84846dfb90552551d2d9fd35578df9f92de13fb3b4537"}, 790 | {file = "mkdocstrings-0.13.6.tar.gz", hash = "sha256:79e5086c79f60d1ae1d4b222f658d348ebdd6302c970cc06ee8394f2839d7c4d"}, 791 | ] 792 | packaging = [ 793 | {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, 794 | {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, 795 | ] 796 | panflute = [ 797 | {file = "panflute-1.12.5.tar.gz", hash = "sha256:8b89507c02fde97650441d50169958b50cb13000edcc7f061390ea6fc313775c"}, 798 | ] 799 | pastel = [ 800 | {file = "pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364"}, 801 | {file = "pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d"}, 802 | ] 803 | pluggy = [ 804 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 805 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 806 | ] 807 | poethepoet = [ 808 | {file = "poethepoet-0.9.0-py3-none-any.whl", hash = "sha256:6b1df9a755c297d5b10749cd4713924055b41edfa62055770c8bd6b5da8e2c69"}, 809 | {file = "poethepoet-0.9.0.tar.gz", hash = "sha256:ab2263fd7be81d16d38a4b4fe42a055d992d04421e61cad36498b1e4bd8ee2a6"}, 810 | ] 811 | py = [ 812 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 813 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 814 | ] 815 | pyaes = [ 816 | {file = "pyaes-1.6.1.tar.gz", hash = "sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f"}, 817 | ] 818 | pyflakes = [ 819 | {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"}, 820 | {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"}, 821 | ] 822 | pygments = [ 823 | {file = "Pygments-2.12.0-py3-none-any.whl", hash = "sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519"}, 824 | {file = "Pygments-2.12.0.tar.gz", hash = "sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb"}, 825 | ] 826 | pymdown-extensions = [ 827 | {file = "pymdown-extensions-8.2.tar.gz", hash = "sha256:b6daa94aad9e1310f9c64c8b1f01e4ce82937ab7eb53bfc92876a97aca02a6f4"}, 828 | {file = "pymdown_extensions-8.2-py3-none-any.whl", hash = "sha256:141452d8ed61165518f2c923454bf054866b85cf466feedb0eb68f04acdc2560"}, 829 | ] 830 | pypandoc = [ 831 | {file = "pypandoc-1.8.1-py3-none-any.whl", hash = "sha256:3d7eda399f9169f16106362c55a8f12f30ab0575cfd2cdc6e1856b214cc4c38c"}, 832 | {file = "pypandoc-1.8.1.tar.gz", hash = "sha256:8c1b651d338e8441843b991835f59d561a8473cfe63f0126d330fdb3cb518809"}, 833 | ] 834 | pyparsing = [ 835 | {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, 836 | {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, 837 | ] 838 | pyrogram = [ 839 | {file = "Pyrogram-2.0.26-py3-none-any.whl", hash = "sha256:376df58ee06b1798df4e7aac84638cc4f4da82022a9902d1ed9baadafda37e99"}, 840 | {file = "Pyrogram-2.0.26.tar.gz", hash = "sha256:5dc715a5fef6d7deedacc44affdc0eb9c4f2c4d1dbe64a52cf7b7e5df060b2e6"}, 841 | ] 842 | pysocks = [ 843 | {file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"}, 844 | {file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"}, 845 | {file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"}, 846 | ] 847 | pytest = [ 848 | {file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"}, 849 | {file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"}, 850 | ] 851 | pytest-asyncio = [ 852 | {file = "pytest-asyncio-0.18.3.tar.gz", hash = "sha256:7659bdb0a9eb9c6e3ef992eef11a2b3e69697800ad02fb06374a210d85b29f91"}, 853 | {file = "pytest_asyncio-0.18.3-1-py3-none-any.whl", hash = "sha256:16cf40bdf2b4fb7fc8e4b82bd05ce3fbcd454cbf7b92afc445fe299dabb88213"}, 854 | {file = "pytest_asyncio-0.18.3-py3-none-any.whl", hash = "sha256:8fafa6c52161addfd41ee7ab35f11836c5a16ec208f93ee388f752bea3493a84"}, 855 | ] 856 | pytest-cov = [ 857 | {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, 858 | {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, 859 | ] 860 | pytest-runner = [ 861 | {file = "pytest-runner-6.0.0.tar.gz", hash = "sha256:b4d85362ed29b4c348678de797df438f0f0509497ddb8c647096c02a6d87b685"}, 862 | {file = "pytest_runner-6.0.0-py3-none-any.whl", hash = "sha256:4c059cf11cf4306e369c0f8f703d1eaf8f32fad370f41deb5f007044656aca6b"}, 863 | ] 864 | python-dateutil = [ 865 | {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, 866 | {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, 867 | ] 868 | python-decouple = [ 869 | {file = "python-decouple-3.6.tar.gz", hash = "sha256:2838cdf77a5cf127d7e8b339ce14c25bceb3af3e674e039d4901ba16359968c7"}, 870 | {file = "python_decouple-3.6-py3-none-any.whl", hash = "sha256:6cf502dc963a5c642ea5ead069847df3d916a6420cad5599185de6bab11d8c2e"}, 871 | ] 872 | pytkdocs = [ 873 | {file = "pytkdocs-0.9.0-py3-none-any.whl", hash = "sha256:12ed87d71b3518301c7b8c12c1a620e4b481a9d2fca1038aea665955000fad7f"}, 874 | {file = "pytkdocs-0.9.0.tar.gz", hash = "sha256:c8c39acb63824f69c3f6f58b3aed6ae55250c35804b76fd0cba09d5c11be13da"}, 875 | ] 876 | pyyaml = [ 877 | {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, 878 | {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, 879 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, 880 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, 881 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, 882 | {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, 883 | {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, 884 | {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, 885 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, 886 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, 887 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, 888 | {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, 889 | {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, 890 | {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, 891 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, 892 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, 893 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, 894 | {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, 895 | {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, 896 | {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, 897 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, 898 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, 899 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, 900 | {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, 901 | {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, 902 | {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, 903 | {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, 904 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, 905 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, 906 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, 907 | {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, 908 | {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, 909 | {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, 910 | ] 911 | pyyaml-env-tag = [ 912 | {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, 913 | {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, 914 | ] 915 | six = [ 916 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 917 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 918 | ] 919 | soupsieve = [ 920 | {file = "soupsieve-2.3.2.post1-py3-none-any.whl", hash = "sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759"}, 921 | {file = "soupsieve-2.3.2.post1.tar.gz", hash = "sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d"}, 922 | ] 923 | tgcrypto = [ 924 | {file = "TgCrypto-1.2.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:266cfc47dde87e23421b2ee50cbb066ec4b8c294553f79742e3d694af5c0d118"}, 925 | {file = "TgCrypto-1.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ab3f759180b17d252dd57f018dff9190dc0e96e5ff3aeb90b598e2ab2f81f57e"}, 926 | {file = "TgCrypto-1.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f69a8a73c37f99bc265df5e071f0506e2815d001362f117c6f7db13c9a1859e"}, 927 | {file = "TgCrypto-1.2.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51f0b1f75533b01c4109ee332e48d9f6734fb286cebe8385c8b1393ba183b693"}, 928 | {file = "TgCrypto-1.2.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fd9f9ba1ec042430539a895377f18260dd9833a8eacb52121457756bb26419d"}, 929 | {file = "TgCrypto-1.2.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:af738c072d721eb8a0d39b0d6140193c0e848f6cb34071ff573b4dd5c094b499"}, 930 | {file = "TgCrypto-1.2.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9f0a07a7112fcdc1c5c84a3b72de4c2d96ef45edc51e542bd7f998b2305b74c8"}, 931 | {file = "TgCrypto-1.2.3-cp310-cp310-win32.whl", hash = "sha256:d58bd811d1a533cbe0e10677152c75edee01b9c893edea6e455678dbf271313f"}, 932 | {file = "TgCrypto-1.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:8e29b62061bc97f1de5176f2c91891b0c614b0de6b1999a0a477faaa61159748"}, 933 | {file = "TgCrypto-1.2.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:7ccb42db80af53d109753be139bb7f6b9cf9e6281e847619860964a8a7edf019"}, 934 | {file = "TgCrypto-1.2.3-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a2d5cfdf06eabfdc3027257cc28ce3d42922644f33f523308e69644af5843c4"}, 935 | {file = "TgCrypto-1.2.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:adcc983f85812892f77fed9c96a9751b4c18e63cd5b8ea76836f778304d7a9db"}, 936 | {file = "TgCrypto-1.2.3-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:307035717383ea256d1c629675610f007d127d67ef786e0055a127b53f6fa589"}, 937 | {file = "TgCrypto-1.2.3-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:281672bfb51b3e30be1a0f29717007d0e815c323afe5c802f07e551099934e62"}, 938 | {file = "TgCrypto-1.2.3-cp36-cp36m-win32.whl", hash = "sha256:438316dc58ef75bb5f7f2e4521e942c3e797966b7262d0b5880bf84c59c2ba48"}, 939 | {file = "TgCrypto-1.2.3-cp36-cp36m-win_amd64.whl", hash = "sha256:c385a651f83f710fba150f8fba8774cfd0b2c234123de747c486cf46f8d182f6"}, 940 | {file = "TgCrypto-1.2.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:84932e93b6582a4abc8c64dd4b94a3b903d946f1d4ea4cbb7fe6bc1d49e7620a"}, 941 | {file = "TgCrypto-1.2.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97ced03b6fc66fd6074e07e90411ad2fb85d18f6110f5947ce29d5390f325b3e"}, 942 | {file = "TgCrypto-1.2.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c2dcd16acd9a25ab9e6f83eb93fdeb8c3d892c76cd8157a75a045a343f5c98d"}, 943 | {file = "TgCrypto-1.2.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:3b0628dea2e06f59315ec373902f7b1b6562abba0e82c60d6843648bf1311e4b"}, 944 | {file = "TgCrypto-1.2.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:90cf802e42fa187fbb3fb4de23b5c8b0a7bff946c2a6be31ff6b37254b00f254"}, 945 | {file = "TgCrypto-1.2.3-cp37-cp37m-win32.whl", hash = "sha256:ba1976374a7b72c12f51eb3efcf291c56cc316eebfaea9c5db0c98c5fc149c33"}, 946 | {file = "TgCrypto-1.2.3-cp37-cp37m-win_amd64.whl", hash = "sha256:bf553cc9088c7b6c502cbaa495d7c0b8ae035c56ff77e6c932efd2415ee13532"}, 947 | {file = "TgCrypto-1.2.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:c0efdf3d38320ddaafa145da1b2fce2abbdb9fc32d94538e2782e41da57ffe53"}, 948 | {file = "TgCrypto-1.2.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6e0ec261992ddb50081fbf2094ce6eaf97d880717b0e100caf74783cca317799"}, 949 | {file = "TgCrypto-1.2.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5c0b5d6d1e6133e4ce283228df22c83ff5f760a169075eac1c493194462038c7"}, 950 | {file = "TgCrypto-1.2.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a036ad285536723540f4071a3f23d312104dc728906dfafd9bba0ca5c8ebfb6"}, 951 | {file = "TgCrypto-1.2.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4d5f139da89e4bfaca3b7667fe889fb1c14c818f85e3ef3f029d16575ac10b83"}, 952 | {file = "TgCrypto-1.2.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f549f1164de67548327593362ae4f94435510b6d1c194e5579d3f0379f91bb71"}, 953 | {file = "TgCrypto-1.2.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8aef7d1d88fe051dae5a9a331e78e27f031a7290bed5d5f4f07a440e9d836bbb"}, 954 | {file = "TgCrypto-1.2.3-cp38-cp38-win32.whl", hash = "sha256:2a6ae87e32f5e94494eb37af29fd4e9d9560752f701922614b03fc6b2e6b9011"}, 955 | {file = "TgCrypto-1.2.3-cp38-cp38-win_amd64.whl", hash = "sha256:056845a5e368d6e4e94b2b773ab0529dd5d5d65b04dc1fa051f63be7ff7e7a3a"}, 956 | {file = "TgCrypto-1.2.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:17bd13f6c99a784f43fbcd410a80c18f078dadea71d049718caf255f6510895f"}, 957 | {file = "TgCrypto-1.2.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8536befb9bf952bf986e83d02fbebd65f6465b574d8ed3f3456c3e97ccd9122a"}, 958 | {file = "TgCrypto-1.2.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:268ad4b8a8e5b5e6b13435d0d57b91ccb4a2af9e1c893b014ea42f1b43b1966c"}, 959 | {file = "TgCrypto-1.2.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0106d780ca971f00376e28ccc3f4d45a29665f3cbe2b7b118141ecd85fe4c2d"}, 960 | {file = "TgCrypto-1.2.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:148fc9c40fdbd059655062c1eec612f8ace2d6d2a9158016472771ef04b06599"}, 961 | {file = "TgCrypto-1.2.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:42caf6e96742e8bce1a66bf7c6d48832ea1617f0a165e1c767ccde9d1889ba0f"}, 962 | {file = "TgCrypto-1.2.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8d44117d9e57fc15425517fb363bd8757c2340b1fce464b1bef3dd69d828ed60"}, 963 | {file = "TgCrypto-1.2.3-cp39-cp39-win32.whl", hash = "sha256:3199d2666f00bffd6c373190bc0f8153a7de1bf00da2cc585c877bb43a57b8d9"}, 964 | {file = "TgCrypto-1.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:fdba12358104453fef2b9f46ae7e364eadd8f857cd39ab6ab94a94bd4b246efc"}, 965 | {file = "TgCrypto-1.2.3-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d2ccda26a5f3377242f926a88b65d46c4b5b7d515304ceaefc39543b3ee5fdc8"}, 966 | {file = "TgCrypto-1.2.3-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bd38f118732c5ebc43fab7cd20ea4f53d0e2367a86169d22af98c2bca9bf8aa"}, 967 | {file = "TgCrypto-1.2.3-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:064caf3b1fdb7b923271130416fe6b2ab1ba695db83375011f39f49df3af202c"}, 968 | {file = "TgCrypto-1.2.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:76d038ae370bef184d7ece6505573857f1229cb8ef54b93def820734d4baa3d8"}, 969 | {file = "TgCrypto-1.2.3-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:dde446012dd5395efdce266a4c25267217586f0296981385e79774dd60c69df1"}, 970 | {file = "TgCrypto-1.2.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5857bdd18359fda4680f2557b12e32e911ae08a1651e241c76d37130bf7e8bef"}, 971 | {file = "TgCrypto-1.2.3-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8adabe5b55e78882a81d2a1aa8248ecb2767dc7305dde5f241e5e80208c8328c"}, 972 | {file = "TgCrypto-1.2.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:8515d2f54d02937f40bb169ad733a9b7e9ba360d1fdef0c3d396af433c1ce474"}, 973 | {file = "TgCrypto-1.2.3.tar.gz", hash = "sha256:87a7f4c722972645f20ec140499bf71672ba921d4ae85b33d3a7ab96c7c9d1b4"}, 974 | ] 975 | toml = [ 976 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 977 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 978 | ] 979 | tomli = [ 980 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 981 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 982 | ] 983 | tomlkit = [ 984 | {file = "tomlkit-0.11.0-py3-none-any.whl", hash = "sha256:0f4050db66fd445b885778900ce4dd9aea8c90c4721141fde0d6ade893820ef1"}, 985 | {file = "tomlkit-0.11.0.tar.gz", hash = "sha256:71ceb10c0eefd8b8f11fe34e8a51ad07812cb1dc3de23247425fbc9ddc47b9dd"}, 986 | ] 987 | typing-extensions = [ 988 | {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"}, 989 | {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"}, 990 | {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"}, 991 | ] 992 | watchdog = [ 993 | {file = "watchdog-2.1.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:676263bee67b165f16b05abc52acc7a94feac5b5ab2449b491f1a97638a79277"}, 994 | {file = "watchdog-2.1.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:aa68d2d9a89d686fae99d28a6edf3b18595e78f5adf4f5c18fbfda549ac0f20c"}, 995 | {file = "watchdog-2.1.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5e2e51c53666850c3ecffe9d265fc5d7351db644de17b15e9c685dd3cdcd6f97"}, 996 | {file = "watchdog-2.1.8-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:7721ac736170b191c50806f43357407138c6748e4eb3e69b071397f7f7aaeedd"}, 997 | {file = "watchdog-2.1.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:ce7376aed3da5fd777483fe5ebc8475a440c6d18f23998024f832134b2938e7b"}, 998 | {file = "watchdog-2.1.8-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:f9ee4c6bf3a1b2ed6be90a2d78f3f4bbd8105b6390c04a86eb48ed67bbfa0b0b"}, 999 | {file = "watchdog-2.1.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:68dbe75e0fa1ba4d73ab3f8e67b21770fbed0651d32ce515cd38919a26873266"}, 1000 | {file = "watchdog-2.1.8-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0c520009b8cce79099237d810aaa19bc920941c268578436b62013b2f0102320"}, 1001 | {file = "watchdog-2.1.8-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:efcc8cbc1b43902571b3dce7ef53003f5b97fe4f275fe0489565fc6e2ebe3314"}, 1002 | {file = "watchdog-2.1.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:746e4c197ec1083581bb1f64d07d1136accf03437badb5ff8fcb862565c193b2"}, 1003 | {file = "watchdog-2.1.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1ae17b6be788fb8e4d8753d8d599de948f0275a232416e16436363c682c6f850"}, 1004 | {file = "watchdog-2.1.8-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ddde157dc1447d8130cb5b8df102fad845916fe4335e3d3c3f44c16565becbb7"}, 1005 | {file = "watchdog-2.1.8-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4978db33fc0934c92013ee163a9db158ec216099b69fce5aec790aba704da412"}, 1006 | {file = "watchdog-2.1.8-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b962de4d7d92ff78fb2dbc6a0cb292a679dea879a0eb5568911484d56545b153"}, 1007 | {file = "watchdog-2.1.8-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1e5d0fdfaa265c29dc12621913a76ae99656cf7587d03950dfeb3595e5a26102"}, 1008 | {file = "watchdog-2.1.8-py3-none-manylinux2014_armv7l.whl", hash = "sha256:036ed15f7cd656351bf4e17244447be0a09a61aaa92014332d50719fc5973bc0"}, 1009 | {file = "watchdog-2.1.8-py3-none-manylinux2014_i686.whl", hash = "sha256:2962628a8777650703e8f6f2593065884c602df7bae95759b2df267bd89b2ef5"}, 1010 | {file = "watchdog-2.1.8-py3-none-manylinux2014_ppc64.whl", hash = "sha256:156ec3a94695ea68cfb83454b98754af6e276031ba1ae7ae724dc6bf8973b92a"}, 1011 | {file = "watchdog-2.1.8-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:47598fe6713fc1fee86b1ca85c9cbe77e9b72d002d6adeab9c3b608f8a5ead10"}, 1012 | {file = "watchdog-2.1.8-py3-none-manylinux2014_s390x.whl", hash = "sha256:fed4de6e45a4f16e4046ea00917b4fe1700b97244e5d114f594b4a1b9de6bed8"}, 1013 | {file = "watchdog-2.1.8-py3-none-manylinux2014_x86_64.whl", hash = "sha256:24dedcc3ce75e150f2a1d704661f6879764461a481ba15a57dc80543de46021c"}, 1014 | {file = "watchdog-2.1.8-py3-none-win32.whl", hash = "sha256:6ddf67bc9f413791072e3afb466e46cc72c6799ba73dea18439b412e8f2e3257"}, 1015 | {file = "watchdog-2.1.8-py3-none-win_amd64.whl", hash = "sha256:88ef3e8640ef0a64b7ad7394b0f23384f58ac19dd759da7eaa9bc04b2898943f"}, 1016 | {file = "watchdog-2.1.8-py3-none-win_ia64.whl", hash = "sha256:0fb60c7d31474b21acba54079ce9ff0136411183e9a591369417cddb1d7d00d7"}, 1017 | {file = "watchdog-2.1.8.tar.gz", hash = "sha256:6d03149126864abd32715d4e9267d2754cede25a69052901399356ad3bc5ecff"}, 1018 | ] 1019 | zipp = [ 1020 | {file = "zipp-3.8.0-py3-none-any.whl", hash = "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099"}, 1021 | {file = "zipp-3.8.0.tar.gz", hash = "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad"}, 1022 | ] 1023 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "tgintegration" 3 | packages = [ 4 | { include = "tgintegration" } 5 | ] 6 | include = ["LICENSE"] 7 | version = "2.0.0" 8 | description = "Integration test and automation library for Telegram Messenger Bots based on Pyrogram." 9 | authors = ["JosXa "] 10 | license = "MIT" 11 | readme = "README.md" 12 | repository = "https://github.com/JosXa/tgintegration" 13 | keywords = ["telegram-bots", "testing", "integration-tests", "library", "python"] 14 | classifiers = [ 15 | "Typing :: Typed", 16 | "Topic :: Software Development :: Libraries :: Python Modules", 17 | "Intended Audience :: Developers", 18 | "Programming Language :: Python :: 3.8", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: Implementation :: CPython" 21 | ] 22 | 23 | [tool.poetry.urls] 24 | "Bug Tracker" = "https://github.com/JosXa/tgintegration/issues" 25 | "Support Chat" = "https://t.me/TgIntegration" 26 | "Contact Author" = "https:/t.me/JosXa" 27 | 28 | [tool.poetry.dependencies] 29 | python = "==3.*,>=3.7" 30 | typing-extensions = "^3.7.4" 31 | pyrogram = "^2.0.0" 32 | 33 | [tool.poetry.dev-dependencies] 34 | bumpversion = "==0.5.3" 35 | coverage = "*" 36 | tgcrypto = "==1.*,>=1.2.0" 37 | python-decouple = "^3.3" 38 | pytest = "*" 39 | pytest-runner = "*" 40 | pytest-asyncio = "*" 41 | pytest-cov = "^2.10.1" 42 | mkdocs-material = { version = "^6.0.2", python = "^3.7" } 43 | mkapi = { version = "^1.0.13", python = "^3.7" } 44 | poethepoet = "^0.9.0" 45 | mkdocstrings = "^0.13.6" 46 | mkdocs-toc-sidebar-plugin = "^0.1.0" 47 | pypandoc = "^1.5" 48 | panflute = "^1.12.5" 49 | markdown-include = "^0.6.0" 50 | mkdocs-markdownextradata-plugin = "^0.1.7" 51 | autoflake = "^1.4" 52 | 53 | [build-system] 54 | requires = ["poetry>=1.0.5"] 55 | build-backend = "poetry.masonry.api" 56 | 57 | [tool.poe.tasks] 58 | # Usage: `pip install poe`, then `poe [task]` 59 | test = "pytest" 60 | pre-stage-config = "git stage .pre-commit-config.yaml" 61 | pre-install = "pre-commit install" 62 | pre-commit = "pre-commit run --all-files" 63 | pre = ["pre-stage-config", "pre-commit"] 64 | pre-with-install = ["pre-stage-config", "pre-install", "pre-commit"] 65 | docs-build = "mkdocs build" 66 | docs-serve = "mkdocs serve" 67 | docs-open = { script = "scripts.open_browser:open_docs" } 68 | docs = ["docs-serve"] 69 | -------------------------------------------------------------------------------- /scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JosXa/tgintegration/39dfc82eb5c80bb6845c12edf0c112e2fa7de26f/scripts/__init__.py -------------------------------------------------------------------------------- /scripts/copy_readme.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Callable 3 | 4 | root_path = Path(__file__).parent.parent 5 | docs_folder = root_path / "docs" 6 | 7 | METADATA_HEADER = ( 8 | """\n\n""" 9 | ) 10 | 11 | readme_source = root_path / "README.md" 12 | 13 | doc = readme_source.read_text(encoding="utf-8") 14 | 15 | 16 | def copy_readme_sections_to_docs(): 17 | copy_section( 18 | "Quick Start Guide\n---", 19 | "Integrating with Test Frameworks\n---", 20 | new_header="# Getting Started", 21 | out_file=docs_folder / "getting-started.md", 22 | ) 23 | copy_section( 24 | "Prerequisites\n---", 25 | "Installation\n---", 26 | new_header="## Prerequisites", 27 | out_file=docs_folder / "prerequisites.md", 28 | ) 29 | copy_section( 30 | "Installation\n---", 31 | "Quick Start Guide\n---", 32 | new_header="## Installation", 33 | out_file=docs_folder / "installation.md", 34 | formatter=lambda doc: doc.replace("
", "").replace( 35 | "$ `pip install git+https://github.com/JosXa/tgintegration.git`", 36 | "
pip install git+https://github.com/JosXa/tgintegration.git
", 37 | ), 38 | ) 39 | 40 | 41 | def copy_section( 42 | section_part: str, 43 | end: str, 44 | new_header: str, 45 | out_file: Path, 46 | formatter: Callable = None, 47 | ) -> None: 48 | content = _get_md_doc_section(section_part, end, new_header) 49 | 50 | if formatter: 51 | content = formatter(content) 52 | 53 | out_file.write_text(content, encoding="utf-8") 54 | 55 | 56 | def _get_md_doc_section(section_part: str, end: str, new_header: str) -> str: 57 | _, after = doc.split(section_part) 58 | result, _ = after.split(end) 59 | result = result.lstrip("-\n").rstrip("-\n") 60 | return f"{METADATA_HEADER}{new_header}\n\n{result}" 61 | 62 | 63 | if __name__ == "__main__": 64 | copy_readme_sections_to_docs() 65 | -------------------------------------------------------------------------------- /scripts/create_session_strings.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import re 3 | from typing import List 4 | from unittest.mock import Mock 5 | 6 | from decouple import config 7 | from pyrogram import Client 8 | from pyrogram.types import Message 9 | from pyrogram.types import SentCode 10 | 11 | from tgintegration import BotController 12 | from tgintegration.collector import collect 13 | from tgintegration.expectation import Expectation 14 | from tgintegration.timeout_settings import TimeoutSettings 15 | 16 | clients: List[Client] = [] 17 | 18 | 19 | async def create_session_string(test_mode: bool = False): 20 | memory_client = Client( 21 | name=":memory:", 22 | in_memory=True, 23 | api_id=config("API_ID"), 24 | api_hash=config("API_HASH"), 25 | test_mode=test_mode, 26 | phone_number=config("TEST_AGENT_PHONE"), 27 | ) 28 | 29 | if len(clients) == 0: 30 | await memory_client.start() 31 | result = await memory_client.export_session_string() 32 | else: 33 | intercept_client = clients[0] 34 | 35 | async def tg_service_notifications_filter(_, __, m: Message): 36 | print(m) 37 | return bool(m.from_user and m.from_user.first_name == "Telegram") 38 | 39 | # TODO: Use standalone `collect` (https://github.com/JosXa/tgintegration/issues/19) 40 | controller = Mock(BotController) 41 | controller.configure_mock(client=intercept_client) 42 | 43 | # noinspection PydanticTypeChecker 44 | async with collect( 45 | controller, 46 | tg_service_notifications_filter, 47 | expectation=Expectation(min_messages=1, max_messages=1), 48 | timeouts=TimeoutSettings(max_wait=120), 49 | ) as res: 50 | sent_code = await initiate_send_code(memory_client) 51 | 52 | code = re.findall(r".*([0-9]{5}).*", res.full_text, re.DOTALL)[0] 53 | 54 | await memory_client.sign_in( 55 | memory_client.phone_number, sent_code.phone_code_hash, code 56 | ) 57 | result = await memory_client.export_session_string() 58 | 59 | if len(clients) >= 1: 60 | await memory_client.stop() 61 | 62 | clients.append(memory_client) 63 | return result 64 | 65 | 66 | async def initiate_send_code(client: Client) -> SentCode: 67 | is_authorized = await client.connect() 68 | assert not is_authorized 69 | 70 | await client.authorize() 71 | sent_code = await client.send_code(client.phone_number) 72 | assert sent_code.type == "app" 73 | 74 | return sent_code 75 | 76 | 77 | if __name__ == "__main__": 78 | N = 2 79 | TEST_MODE = False 80 | 81 | strings = [] 82 | 83 | try: 84 | for _ in range(N): 85 | strings.append(asyncio.run(create_session_string(test_mode=TEST_MODE))) 86 | finally: 87 | print( 88 | f"\n============ {len(strings)}/{N} session strings created: ============" 89 | ) 90 | for s in strings: 91 | print(s) 92 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths = 3 | tests 4 | examples/pytest 5 | filterwarnings = 6 | ignore::DeprecationWarning:distutils 7 | 8 | [flake8] 9 | max-line-length = 120 10 | max-complexity = 14 11 | inline-quotes = single 12 | multiline-quotes = double 13 | ignore = E203, W503 14 | 15 | [coverage:run] 16 | source = tgintegration 17 | branch = True 18 | 19 | [coverage:report] 20 | precision = 2 21 | exclude_lines = 22 | pragma: no cover 23 | raise NotImplementedError 24 | raise NotImplemented 25 | if TYPE_CHECKING: 26 | @overload 27 | 28 | [isort] 29 | line_length=120 30 | known_first_party=pydantic 31 | multi_line_output=3 32 | include_trailing_comma=True 33 | 34 | [mypy] 35 | show_error_codes = True 36 | follow_imports = silent 37 | strict_optional = True 38 | warn_redundant_casts = True 39 | warn_unused_ignores = True 40 | disallow_any_generics = True 41 | check_untyped_defs = True 42 | no_implicit_reexport = True 43 | warn_unused_configs = True 44 | disallow_subclassing_any = True 45 | disallow_incomplete_defs = True 46 | disallow_untyped_decorators = True 47 | disallow_untyped_calls = True 48 | ;disallow_untyped_defs = True 49 | 50 | [mypy-pyrogram] 51 | ignore_missing_imports = true 52 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Unit test package for tgintegration.""" 3 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import pytest 4 | 5 | 6 | @pytest.yield_fixture(scope="module") 7 | def event_loop(request): 8 | """Create an instance of the default event loop for each test case.""" 9 | loop = asyncio.get_event_loop_policy().new_event_loop() 10 | yield loop 11 | loop.close() 12 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JosXa/tgintegration/39dfc82eb5c80bb6845c12edf0c112e2fa7de26f/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from decouple import config 5 | 6 | examples_dir = Path(__file__).parent.parent.parent / "examples" 7 | 8 | 9 | # noinspection PyCallingNonCallable 10 | @pytest.fixture(scope="session", autouse=True) 11 | def generate_config_ini(): 12 | lines = [ 13 | "[pyrogram]", 14 | f'api_id={config("API_ID")}', 15 | f'api_hash={config("API_HASH")}', 16 | ] 17 | 18 | with open(examples_dir / "config.ini", "w+") as text_file: 19 | text_file.write("\n".join(lines)) 20 | 21 | 22 | @pytest.fixture(scope="session") 23 | def session_name(): 24 | # noinspection PyCallingNonCallable 25 | return config("SESSION_STRING") 26 | -------------------------------------------------------------------------------- /tests/integration/test_examples.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | pytestmark = pytest.mark.asyncio 4 | 5 | 6 | # TODO: Bot is offline. Does anyone have a nice alternative to automate? 7 | # async def test_dinopark_example(session_name): 8 | # # Late import so that the autouse fixtures run first 9 | # from examples.automation import dinoparkbot 10 | # 11 | # client = dinoparkbot.create_client(session_name) 12 | # game = dinoparkbot.create_game_controller(client) 13 | # await game.perform_full_run() 14 | 15 | 16 | async def test_idletown_example(session_name): 17 | # Late import so that the autouse fixtures run first 18 | from examples.automation import idletown 19 | 20 | idletown.MAX_RUNS = 1 21 | client = idletown.create_client(session_name) 22 | controller = idletown.create_game_controller(client) 23 | await idletown.perform_full_run(controller) 24 | 25 | 26 | async def test_readme_example(session_name): 27 | # Late import so that the autouse fixtures run first 28 | from examples.readme_example import readmeexample 29 | 30 | client = readmeexample.create_client(session_name) 31 | await readmeexample.run_example(client) 32 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JosXa/tgintegration/39dfc82eb5c80bb6845c12edf0c112e2fa7de26f/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/containers/test_inline_keyboard.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from unittest.mock import Mock 3 | 4 | import pytest 5 | from pyrogram import Client 6 | from pyrogram.types import InlineKeyboardButton 7 | 8 | from tgintegration import BotController 9 | from tgintegration.containers import InlineKeyboard 10 | from tgintegration.containers import NoButtonFound 11 | 12 | BTN_A = InlineKeyboardButton("a", callback_data="a") 13 | BTN_B = InlineKeyboardButton("b", callback_data="b") 14 | BTN_C = InlineKeyboardButton("c", callback_data="c") 15 | 16 | 17 | @pytest.mark.parametrize( 18 | "rows,idx,expected", 19 | [ 20 | pytest.param( 21 | [ 22 | [ 23 | BTN_A, 24 | BTN_B, 25 | ] 26 | ], 27 | 0, 28 | BTN_A, 29 | ), 30 | pytest.param( 31 | [ 32 | [ 33 | BTN_A, 34 | BTN_B, 35 | ] 36 | ], 37 | 1, 38 | BTN_B, 39 | ), 40 | pytest.param( 41 | [ 42 | [ 43 | BTN_A, 44 | BTN_B, 45 | ] 46 | ], 47 | -1, 48 | BTN_B, 49 | ), 50 | pytest.param( 51 | [ 52 | [ 53 | BTN_A, 54 | BTN_B, 55 | ] 56 | ], 57 | -2, 58 | BTN_A, 59 | ), 60 | pytest.param( 61 | [ 62 | [ 63 | BTN_A, 64 | BTN_B, 65 | ] 66 | ], 67 | -3, 68 | NoButtonFound, 69 | ), 70 | pytest.param( 71 | [ 72 | [ 73 | BTN_A, 74 | BTN_B, 75 | ], 76 | [BTN_C], 77 | ], 78 | 0, 79 | BTN_A, 80 | ), 81 | pytest.param( 82 | [ 83 | [ 84 | BTN_A, 85 | BTN_B, 86 | ], 87 | [BTN_C], 88 | ], 89 | 1, 90 | BTN_B, 91 | ), 92 | pytest.param( 93 | [ 94 | [ 95 | BTN_A, 96 | BTN_B, 97 | ], 98 | [BTN_C], 99 | ], 100 | 2, 101 | BTN_C, 102 | ), 103 | pytest.param( 104 | [ 105 | [ 106 | BTN_A, 107 | BTN_B, 108 | ], 109 | [BTN_C], 110 | ], 111 | 3, 112 | NoButtonFound, 113 | ), 114 | pytest.param( 115 | [ 116 | [ 117 | BTN_A, 118 | BTN_B, 119 | ], 120 | [BTN_C], 121 | ], 122 | -1, 123 | BTN_C, 124 | ), 125 | pytest.param( 126 | [ 127 | [ 128 | BTN_A, 129 | BTN_B, 130 | ], 131 | [BTN_C], 132 | ], 133 | -2, 134 | BTN_B, 135 | ), 136 | pytest.param( 137 | [ 138 | [ 139 | BTN_A, 140 | BTN_B, 141 | ], 142 | [BTN_C], 143 | ], 144 | -3, 145 | BTN_A, 146 | ), 147 | pytest.param( 148 | [ 149 | [ 150 | BTN_A, 151 | BTN_B, 152 | ], 153 | [BTN_C], 154 | ], 155 | -4, 156 | NoButtonFound, 157 | ), 158 | ], 159 | ) 160 | def test_click_inline_keyboard_button_by_index(rows, idx, expected): 161 | """https://github.com/JosXa/tgintegration/issues/2""" 162 | 163 | CHAT_ID = 12345 164 | MESSAGE_ID = 123 165 | client = Mock(Client) 166 | controller = Mock(BotController) 167 | controller.configure_mock(client=client) 168 | kb = InlineKeyboard( 169 | controller, chat_id=CHAT_ID, message_id=MESSAGE_ID, button_rows=rows 170 | ) 171 | 172 | if inspect.isclass(expected) and issubclass(expected, Exception): 173 | with pytest.raises(expected): 174 | kb.find_button(index=idx) 175 | else: 176 | res = kb.find_button(index=idx) 177 | assert res == expected 178 | -------------------------------------------------------------------------------- /tests/unit/test_expectation.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import pytest 4 | from pyrogram.types import Message 5 | 6 | from tgintegration.expectation import Expectation 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "min_n,max_n,num_msgs,is_sufficient,is_match", 11 | [ 12 | # TODO: (0,0,0) ? 13 | (1, 1, 0, False, False), 14 | (1, 1, 1, True, True), 15 | (1, 1, 2, True, False), 16 | ], 17 | ) 18 | def test_expectation( 19 | min_n: int, max_n: int, num_msgs: int, is_sufficient: bool, is_match: bool 20 | ): 21 | obj = Expectation(min_messages=min_n, max_messages=max_n) 22 | msgs = [Mock(Message)] * num_msgs 23 | assert obj.is_sufficient(msgs) == is_sufficient 24 | assert obj._is_match(msgs) == is_match 25 | -------------------------------------------------------------------------------- /tests/unit/test_frame_utils.py: -------------------------------------------------------------------------------- 1 | from tgintegration.utils.frame_utils import get_caller_function_name 2 | 3 | 4 | def test_get_caller_function_name(): 5 | def foo(): 6 | return bar() 7 | 8 | def bar(): 9 | return get_caller_function_name() 10 | 11 | assert foo() == "foo" 12 | 13 | 14 | def test_get_caller_function_name_lambda(): 15 | def foo(): 16 | f = lambda: get_caller_function_name() # noqa 17 | return f() 18 | 19 | assert foo() == "foo" 20 | -------------------------------------------------------------------------------- /tests/unit/test_handler_utils.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from unittest.mock import Mock 3 | 4 | import pytest 5 | from pyrogram.dispatcher import Dispatcher 6 | 7 | from tgintegration.handler_utils import find_free_group 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "group_indices,expected", 12 | [ 13 | ([1, 2, 3], -1000), 14 | ([-1000], -1001), 15 | ([-999], -1000), 16 | ([-999, -1000, -1001], -1002), 17 | ([-1000, -1001, -1003], -1002), 18 | ], 19 | ) 20 | def test_find_free_group(group_indices, expected): 21 | dp = Mock(Dispatcher) 22 | groups = OrderedDict({k: None for k in group_indices}) 23 | dp.configure_mock(groups=groups) 24 | assert find_free_group(dp, -1000) == expected 25 | -------------------------------------------------------------------------------- /tgintegration/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | The root package of {{tgi}}. 4 | """ 5 | from tgintegration.botcontroller import BotController 6 | from tgintegration.containers import InlineKeyboard 7 | from tgintegration.containers import InlineResult 8 | from tgintegration.containers import InlineResultContainer 9 | from tgintegration.containers import InvalidResponseError 10 | from tgintegration.containers import ReplyKeyboard 11 | from tgintegration.containers import Response 12 | 13 | __all__ = [ 14 | "Response", 15 | "BotController", 16 | "InlineResult", 17 | "InlineResultContainer", 18 | "InvalidResponseError", 19 | "InlineKeyboard", 20 | "ReplyKeyboard", 21 | ] 22 | -------------------------------------------------------------------------------- /tgintegration/botcontroller.py: -------------------------------------------------------------------------------- 1 | """ 2 | Entry point to {{tgi}} features. 3 | """ 4 | import asyncio 5 | import logging 6 | from contextlib import asynccontextmanager 7 | from time import time 8 | from typing import AsyncGenerator 9 | from typing import cast 10 | from typing import List 11 | from typing import Optional 12 | from typing import Union 13 | 14 | from pyrogram import Client 15 | from pyrogram import filters 16 | from pyrogram.errors import FloodWait 17 | from pyrogram.filters import Filter 18 | from pyrogram.handlers.handler import Handler 19 | from pyrogram.raw.base import BotCommand 20 | from pyrogram.raw.functions.messages import DeleteHistory 21 | from pyrogram.raw.functions.users import GetFullUser 22 | from pyrogram.raw.types import BotInfo 23 | from pyrogram.raw.types import InputPeerUser 24 | from pyrogram.raw.types.messages import BotResults 25 | from pyrogram.types import Message 26 | from pyrogram.types import User 27 | from typing_extensions import AsyncContextManager 28 | 29 | from tgintegration.collector import collect 30 | from tgintegration.containers.inlineresults import InlineResult 31 | from tgintegration.containers.inlineresults import InlineResultContainer 32 | from tgintegration.containers.responses import Response 33 | from tgintegration.expectation import Expectation 34 | from tgintegration.handler_utils import add_handlers_transient 35 | from tgintegration.timeout_settings import TimeoutSettings 36 | from tgintegration.utils.frame_utils import get_caller_function_name 37 | from tgintegration.utils.sentinel import NotSet 38 | 39 | 40 | class BotController: 41 | """ 42 | This class is the entry point for all interactions with either regular bots or userbots in `TgIntegration`. 43 | It expects a Pyrogram `Client` (typically a **user client**) that serves as the controll**ing** account for a 44 | specific `peer` - which can be seen as the "bot under test" or "conversation partner". 45 | In addition, the controller holds a number of settings to control the timeouts for all these interactions. 46 | """ 47 | 48 | def __init__( 49 | self, 50 | client: Client, 51 | peer: Union[int, str], 52 | *, 53 | max_wait: Union[int, float] = 20.0, 54 | wait_consecutive: Optional[Union[int, float]] = 2.0, 55 | raise_no_response: bool = True, 56 | global_action_delay: Union[int, float] = 0.8, 57 | ): 58 | """ 59 | Creates a new `BotController`. 60 | 61 | Args: 62 | client: A Pyrogram user client that acts as the controll*ing* account. 63 | peer: The bot under test or conversation partner. 64 | max_wait: Maximum time in seconds for the `peer` to produce the expected response. 65 | wait_consecutive: Additional time in seconds to wait for _additional_ messages upon receiving a response 66 | (even when `max_wait` is exceeded). 67 | raise_no_response: Whether to raise an exception on timeout/invalid response or to log silently. 68 | global_action_delay: The time to wait in between `collect` calls. 69 | """ 70 | self.client = client 71 | self.peer = peer 72 | self.max_wait_response = max_wait 73 | self.min_wait_consecutive = wait_consecutive 74 | self.raise_no_response = raise_no_response 75 | self.global_action_delay = global_action_delay 76 | 77 | self._input_peer: Optional[InputPeerUser] = None 78 | self.peer_user: Optional[User] = None 79 | self.peer_id: Optional[int] = None 80 | self.command_list: List[BotCommand] = [] 81 | 82 | self._last_response_ts: Optional[time] = None 83 | self.logger = logging.getLogger(self.__class__.__name__) 84 | 85 | async def initialize(self, start_client: bool = True) -> None: 86 | # noinspection PyUnresolvedReferences 87 | """ 88 | Fetches and caches information about the given `peer` and optionally starts the assigned `client`. 89 | This method will automatically be called when coroutines of this class are invoked, but you can call it 90 | manually to override defaults (namely whether to `start_client`). 91 | 92 | Args: 93 | start_client: Set to `False` if the client should not be started as part of initialization. 94 | 95 | !!! note 96 | It is unlikely that you will need to call this manually. 97 | """ 98 | if start_client and not self.client.is_connected: 99 | await self.client.start() 100 | 101 | self._input_peer = await self.client.resolve_peer(self.peer) 102 | self.peer_user = await self.client.get_users(self.peer) 103 | self.peer_id = self.peer_user.id 104 | 105 | if self.peer_user.is_bot: 106 | self.command_list = await self._get_command_list() 107 | 108 | async def _ensure_preconditions(self, *, bots_only: bool = False): 109 | if not self.peer_id: 110 | await self.initialize() 111 | 112 | if bots_only and not self.peer_user.is_bot: 113 | caller = get_caller_function_name() 114 | raise ValueError( 115 | f"This controller is assigned to a user peer, but '{caller}' can only be used with a bot." 116 | ) 117 | 118 | def _merge_default_filters( 119 | self, user_filters: Filter = None, override_peer: Union[int, str] = None 120 | ) -> Filter: 121 | chat_filter = filters.chat(override_peer or self.peer_id) & filters.incoming 122 | return None if user_filters is None else user_filters & chat_filter 123 | 124 | async def _get_command_list(self) -> List[BotCommand]: 125 | return list( 126 | cast( 127 | BotInfo, 128 | ( 129 | await self.client.invoke( 130 | GetFullUser(id=await self.client.resolve_peer(self.peer_id)) 131 | ) 132 | ).full_user.bot_info, 133 | ).commands 134 | ) 135 | 136 | async def clear_chat(self) -> None: 137 | """ 138 | Deletes all messages in the conversation with the assigned `peer`. 139 | 140 | !!! warning 141 | Be careful as this will completely drop your mutual message history. 142 | """ 143 | await self._ensure_preconditions() 144 | await self.client.invoke( 145 | DeleteHistory(peer=self._input_peer, max_id=0, just_clear=False) 146 | ) 147 | 148 | async def _wait_global(self): 149 | if self.global_action_delay and self._last_response_ts: 150 | # Sleep for as long as the global delay prescribes 151 | sleep = self.global_action_delay - (time() - self._last_response_ts.started) 152 | if sleep > 0: 153 | await asyncio.sleep(sleep) 154 | 155 | @asynccontextmanager 156 | async def add_handler_transient( 157 | self, handler: Handler 158 | ) -> AsyncContextManager[None]: 159 | """ 160 | Registers a one-time/ad-hoc Pyrogram `Handler` that is only valid during the context manager body. 161 | 162 | Args: 163 | handler: A Pyrogram `Handler` (typically a `MessageHandler`). 164 | 165 | Yields: 166 | `None` 167 | 168 | Examples: 169 | ``` python 170 | async def some_callback(client, message): 171 | print(message) 172 | 173 | async def main(): 174 | async with controller.add_handler_transient(MessageHandler(some_callback, filters.text)): 175 | await controller.send_command("start") 176 | await asyncio.sleep(3) # Wait 3 seconds for a reply 177 | ``` 178 | """ 179 | async with add_handlers_transient(self.client, handler): 180 | yield 181 | 182 | @asynccontextmanager 183 | async def collect( 184 | self, 185 | filters: Filter = None, 186 | count: int = None, 187 | *, 188 | peer: Union[int, str] = None, 189 | max_wait: Union[int, float] = 15, 190 | wait_consecutive: Optional[Union[int, float]] = None, 191 | raise_: Optional[bool] = None, 192 | ) -> AsyncContextManager[Response]: 193 | """ 194 | 195 | Args: 196 | filters (): 197 | count (): 198 | peer (): 199 | max_wait (): 200 | wait_consecutive (): 201 | raise_ (): 202 | 203 | Returns: 204 | 205 | """ 206 | await self._ensure_preconditions() 207 | await self._wait_if_necessary() 208 | 209 | async with collect( 210 | self, 211 | self._merge_default_filters(filters, peer), 212 | expectation=Expectation( 213 | min_messages=count or NotSet, max_messages=count or NotSet 214 | ), 215 | timeouts=TimeoutSettings( 216 | max_wait=max_wait, 217 | wait_consecutive=wait_consecutive, 218 | raise_on_timeout=raise_ 219 | if raise_ is not None 220 | else self.raise_no_response, 221 | ), 222 | ) as response: 223 | yield response 224 | 225 | self._last_response_ts = response.last_message_timestamp 226 | 227 | async def _wait_if_necessary(self): 228 | if not self.global_action_delay or not self._last_response_ts: 229 | return 230 | 231 | wait_for = (self.global_action_delay + self._last_response_ts) - time() 232 | if wait_for > 0: 233 | # noinspection PyUnboundLocalVariable 234 | self.logger.debug( 235 | f"Waiting {wait_for} seconds to respect global action delay..." 236 | ) 237 | await asyncio.sleep(wait_for) 238 | 239 | async def ping_bot( 240 | self, 241 | override_messages: List[str] = None, 242 | override_filters: Filter = None, 243 | *, 244 | peer: Union[int, str] = None, 245 | max_wait: Union[int, float] = 15, 246 | wait_consecutive: Optional[Union[int, float]] = None, 247 | ) -> Response: 248 | await self._ensure_preconditions() 249 | peer = peer or self.peer_id 250 | 251 | messages = ["/start"] 252 | if override_messages: 253 | messages = override_messages 254 | 255 | async def send_pings(): 256 | for n, m in enumerate(messages): 257 | try: 258 | if n >= 1: 259 | await asyncio.sleep(1) 260 | await self.send_command(m, peer=peer) 261 | except FloodWait as e: 262 | if e.x > 5: 263 | self.logger.warning( 264 | "send_message flood: waiting {} seconds".format(e.x) 265 | ) 266 | await asyncio.sleep(e.x) 267 | continue 268 | 269 | async with collect( 270 | self, 271 | self._merge_default_filters(override_filters, peer), 272 | expectation=Expectation(min_messages=1), 273 | timeouts=TimeoutSettings( 274 | max_wait=max_wait, wait_consecutive=wait_consecutive 275 | ), 276 | ) as response: 277 | await send_pings() 278 | 279 | return response 280 | 281 | async def send_command( 282 | self, 283 | command: str, 284 | args: List[str] = None, 285 | peer: Union[int, str] = None, 286 | add_bot_name: bool = True, 287 | ) -> Message: 288 | """ 289 | Send a slash-command with corresponding parameters. 290 | """ 291 | text = "/" + command.lstrip("/") 292 | 293 | if add_bot_name and self.peer_user.username: 294 | text += f"@{self.peer_user.username}" 295 | 296 | if args: 297 | text += " " 298 | text += " ".join(args) 299 | 300 | return await self.client.send_message(peer or self.peer_id, text) 301 | 302 | async def _iter_bot_results( 303 | self, 304 | bot_results: BotResults, 305 | query: str, 306 | latitude: float = None, 307 | longitude: float = None, 308 | limit: int = 200, 309 | current_offset: str = "", 310 | ) -> AsyncGenerator[InlineResult, None]: 311 | num_returned: int = 0 312 | while num_returned <= limit: 313 | 314 | for result in bot_results.results: 315 | yield InlineResult(self, result, bot_results.query_id) 316 | num_returned += 1 317 | 318 | if not bot_results.next_offset or current_offset == bot_results.next_offset: 319 | break # no more results 320 | 321 | bot_results: BotResults = await self.client.get_inline_bot_results( 322 | self.peer_id, 323 | query, 324 | offset=current_offset, 325 | latitude=latitude, 326 | longitude=longitude, 327 | ) 328 | current_offset = bot_results.next_offset 329 | 330 | async def query_inline( 331 | self, 332 | query: str, 333 | latitude: float = None, 334 | longitude: float = None, 335 | limit: int = 200, 336 | ) -> InlineResultContainer: 337 | """ 338 | Requests inline results from the `peer` (which needs to be a bot). 339 | 340 | Args: 341 | query: The query text. 342 | latitude: Latitude of a geo point. 343 | longitude: Longitude of a geo point. 344 | limit: When result pages get iterated automatically, specifies the maximum number of results to return 345 | from the bot. 346 | 347 | Returns: 348 | A container for convenient access to the inline results. 349 | """ 350 | await self._ensure_preconditions(bots_only=True) 351 | 352 | if limit <= 0: 353 | raise ValueError("Cannot get 0 or less results.") 354 | 355 | start_offset = "" 356 | first_batch: BotResults = await self.client.get_inline_bot_results( 357 | self.peer_id, 358 | query, 359 | offset=start_offset, 360 | latitude=latitude, 361 | longitude=longitude, 362 | ) 363 | 364 | gallery = first_batch.gallery 365 | switch_pm = first_batch.switch_pm 366 | users = first_batch.users 367 | 368 | results = [ 369 | x 370 | async for x in self._iter_bot_results( 371 | first_batch, 372 | query, 373 | latitude=latitude, 374 | longitude=longitude, 375 | limit=limit, 376 | current_offset=start_offset, 377 | ) 378 | ] 379 | 380 | return InlineResultContainer( 381 | self, 382 | query, 383 | latitude=latitude, 384 | longitude=longitude, 385 | results=results, 386 | gallery=gallery, 387 | switch_pm=switch_pm, 388 | users=users, 389 | ) 390 | -------------------------------------------------------------------------------- /tgintegration/collector.py: -------------------------------------------------------------------------------- 1 | """ 2 | Standalone `collector` utilities. 3 | """ 4 | import asyncio 5 | import logging 6 | from contextlib import asynccontextmanager 7 | from datetime import datetime 8 | from datetime import timedelta 9 | from typing import AsyncContextManager 10 | from typing import TYPE_CHECKING 11 | 12 | from pyrogram.errors import InternalServerError 13 | from pyrogram.filters import Filter 14 | from pyrogram.handlers import EditedMessageHandler 15 | from pyrogram.handlers import MessageHandler 16 | 17 | from tgintegration.expectation import Expectation 18 | from tgintegration.handler_utils import add_handlers_transient 19 | from tgintegration.timeout_settings import TimeoutSettings 20 | 21 | if TYPE_CHECKING: 22 | from tgintegration.botcontroller import BotController 23 | 24 | from tgintegration.containers.responses import InvalidResponseError, Response 25 | from tgintegration.update_recorder import MessageRecorder 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | 30 | @asynccontextmanager 31 | async def collect( 32 | controller: "BotController", 33 | filters: Filter = None, 34 | expectation: Expectation = None, 35 | timeouts: TimeoutSettings = None, 36 | ) -> AsyncContextManager[Response]: 37 | expectation = expectation or Expectation() 38 | timeouts = timeouts or TimeoutSettings() 39 | 40 | recorder = MessageRecorder() 41 | message_handler = MessageHandler(recorder.record_message, filters=filters) 42 | edited_message_handler = EditedMessageHandler( 43 | recorder.record_message, filters=filters 44 | ) 45 | 46 | assert controller.client.is_connected 47 | 48 | async with add_handlers_transient( 49 | controller.client, [message_handler, edited_message_handler] 50 | ): 51 | response = Response(controller, recorder) 52 | 53 | logger.debug("Collector set up. Executing user-defined interaction...") 54 | yield response # Start user-defined interaction 55 | logger.debug("interaction complete.") 56 | 57 | num_received = 0 58 | # last_received_timestamp = ( 59 | # None # TODO: work with the message's timestamp instead of utcnow() 60 | # ) 61 | timeout_end = datetime.utcnow() + timedelta(seconds=timeouts.max_wait) 62 | 63 | try: 64 | seconds_remaining = (timeout_end - datetime.utcnow()).total_seconds() 65 | 66 | while True: 67 | if seconds_remaining > 0: 68 | # Wait until we receive any message or time out 69 | logger.debug(f"Waiting for message #{num_received + 1}") 70 | await asyncio.wait_for( 71 | recorder.wait_until( 72 | lambda msgs: expectation.is_sufficient(msgs) 73 | or len(msgs) > num_received 74 | ), 75 | timeout=seconds_remaining, 76 | ) 77 | 78 | num_received = len(recorder.messages) # TODO: this is ugly 79 | 80 | if timeouts.wait_consecutive: 81 | # Always wait for at least `wait_consecutive` seconds for another message 82 | try: 83 | logger.debug( 84 | f"Checking for consecutive message to #{num_received}..." 85 | ) 86 | await asyncio.wait_for( 87 | recorder.wait_until(lambda msgs: len(msgs) > num_received), 88 | # The consecutive end may go over the max wait timeout, 89 | # which is a design decision. 90 | timeout=timeouts.wait_consecutive, 91 | ) 92 | logger.debug("received 1.") 93 | except TimeoutError: 94 | logger.debug("none received.") 95 | 96 | num_received = len(recorder.messages) # TODO: this is ugly 97 | 98 | if expectation.is_sufficient(recorder.messages): 99 | expectation.verify(recorder.messages, timeouts) 100 | return 101 | 102 | seconds_remaining = (timeout_end - datetime.utcnow()).total_seconds() 103 | 104 | assert seconds_remaining is not None 105 | 106 | if seconds_remaining <= 0: 107 | expectation.verify(recorder.messages, timeouts) 108 | return 109 | 110 | except InternalServerError as e: 111 | logger.warning(e) 112 | await asyncio.sleep(60) # Internal Telegram error 113 | except asyncio.exceptions.TimeoutError as te: 114 | if timeouts.raise_on_timeout: 115 | raise InvalidResponseError() from te 116 | else: 117 | # TODO: better warning message 118 | logger.warning("Peer did not reply.") 119 | finally: 120 | recorder.stop() 121 | -------------------------------------------------------------------------------- /tgintegration/containers/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Containers are abstractions that group together Pyrogram types for more convenient access. 3 | """ 4 | from .exceptions import NoButtonFound 5 | from .inline_keyboard import InlineKeyboard 6 | from .inlineresults import InlineResult 7 | from .inlineresults import InlineResultContainer 8 | from .reply_keyboard import ReplyKeyboard 9 | from .responses import InvalidResponseError 10 | from .responses import Response 11 | 12 | __all__ = [ 13 | "InlineResultContainer", 14 | "InlineResult", 15 | "Response", 16 | "InvalidResponseError", 17 | "NoButtonFound", 18 | "InlineKeyboard", 19 | "ReplyKeyboard", 20 | ] 21 | -------------------------------------------------------------------------------- /tgintegration/containers/exceptions.py: -------------------------------------------------------------------------------- 1 | class NoButtonFound(Exception): 2 | """ 3 | Raised when attempting to find a button inside a menu failed. 4 | """ 5 | -------------------------------------------------------------------------------- /tgintegration/containers/inline_keyboard.py: -------------------------------------------------------------------------------- 1 | """ 2 | ​ 3 | """ 4 | import itertools 5 | import logging 6 | import re 7 | from typing import List 8 | from typing import Optional 9 | from typing import Pattern 10 | from typing import TYPE_CHECKING 11 | from typing import Union 12 | 13 | from pyrogram import filters as f 14 | from pyrogram.types import InlineKeyboardButton 15 | 16 | from tgintegration.containers.exceptions import NoButtonFound 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | if TYPE_CHECKING: 21 | from tgintegration.botcontroller import BotController 22 | from tgintegration.containers.responses import Response 23 | 24 | 25 | class InlineKeyboard: 26 | """ 27 | Represents an inline keyboard attached to a message in the Telegram UI and allows to click those buttons. 28 | """ 29 | 30 | def __init__( 31 | self, 32 | controller: "BotController", 33 | chat_id: Union[int, str], 34 | message_id: int, 35 | button_rows: List[List[InlineKeyboardButton]], 36 | ): 37 | self._controller = controller 38 | self._message_id = message_id 39 | self._peer_id = chat_id 40 | self.rows = button_rows 41 | 42 | def find_button( 43 | self, pattern: Pattern = None, index: int = None 44 | ) -> Optional[InlineKeyboardButton]: 45 | """ 46 | Attempts to retrieve a clickable button anywhere in the underlying `rows` by matching the button captions with 47 | the given `pattern` or its global `index`. If no button could be found, **this method raises** `NoButtonFound`. 48 | 49 | The `pattern` and `index` arguments are mutually exclusive. 50 | 51 | Args: 52 | pattern: The button caption to look for (by `re.match`). 53 | index: The index of the button, couting from top left to bottom right and starting at 0. 54 | 55 | Returns: 56 | The `InlineKeyboardButton` if found. 57 | """ 58 | index_set = index or index == 0 59 | if not any((pattern, index_set)) or all((pattern, index_set)): 60 | raise ValueError( 61 | "Exactly one of the `pattern` or `index` arguments must be provided." 62 | ) 63 | 64 | if pattern: 65 | compiled = re.compile(pattern) 66 | for row in self.rows: 67 | for button in row: 68 | if compiled.match(button.text): 69 | return button 70 | raise NoButtonFound 71 | elif index_set: 72 | try: 73 | buttons_flattened = list(itertools.chain.from_iterable(self.rows)) 74 | 75 | if index < 0: 76 | index += len(buttons_flattened) 77 | 78 | return next(itertools.islice(buttons_flattened, index, index + 1)) 79 | except (StopIteration, ValueError): 80 | raise NoButtonFound 81 | 82 | async def click( 83 | self, 84 | pattern: Union[Pattern, str] = None, 85 | index: Optional[int] = None, 86 | ) -> "Response": 87 | """ 88 | Uses `find_button` with the given `pattern` or `index`, clicks the button if found, and waits for the bot 89 | to react in the same chat. 90 | 91 | If not button could be found, `NoButtonFound` will be raised. 92 | 93 | Args: 94 | pattern: The button caption to look for (by `re.match`). 95 | index: The index of the button, couting from top left to bottom right and starting at 0. 96 | 97 | Returns: 98 | The bot's `Response`. 99 | """ 100 | button = self.find_button(pattern, index) 101 | 102 | async with self._controller.collect( 103 | filters=f.chat(self._peer_id) 104 | ) as res: # type: Response 105 | logger.debug(f"Clicking button with caption '{button.text}'...") 106 | await self._controller.client.request_callback_answer( 107 | chat_id=self._peer_id, 108 | message_id=self._message_id, 109 | callback_data=button.callback_data, 110 | timeout=30, 111 | ) 112 | 113 | return res 114 | 115 | def __eq__(self, other): 116 | if not isinstance(other, InlineKeyboard): 117 | return False 118 | try: 119 | for r_n, row in enumerate(self.rows): 120 | other_row = other.rows[r_n] 121 | for b_n, btn in enumerate(row): 122 | other_btn = other_row[b_n] 123 | if ( 124 | btn.text != other_btn.text 125 | or btn.switch_inline_query_current_chat 126 | != other_btn.switch_inline_query_current_chat 127 | or btn.switch_inline_query != other_btn.switch_inline_query 128 | or btn.callback_data != other_btn.callback_data 129 | or btn.url != other_btn.url 130 | ): 131 | return False 132 | except KeyError: 133 | return False 134 | 135 | return True 136 | 137 | @property 138 | def num_buttons(self): 139 | return sum(len(row) for row in self.rows) 140 | -------------------------------------------------------------------------------- /tgintegration/containers/inlineresults.py: -------------------------------------------------------------------------------- 1 | import re 2 | from operator import attrgetter 3 | from typing import Any 4 | from typing import List 5 | from typing import Optional 6 | from typing import Pattern 7 | from typing import Set 8 | from typing import TYPE_CHECKING 9 | from typing import Union 10 | 11 | from pyrogram.raw.types import BotInlineResult 12 | from pyrogram.raw.types import InlineBotSwitchPM 13 | from pyrogram.raw.types import WebDocument 14 | from pyrogram.types import Message 15 | 16 | from tgintegration.utils.iter_utils import flatten 17 | 18 | if TYPE_CHECKING: 19 | from tgintegration.botcontroller import BotController 20 | 21 | QueryId = str 22 | 23 | 24 | class InlineResult: 25 | def __init__( 26 | self, controller: "BotController", result: BotInlineResult, query_id: int 27 | ): 28 | self._controller = controller 29 | self.result = result 30 | self._query_id = query_id 31 | 32 | def send( 33 | self, 34 | chat_id: Union[int, str], 35 | disable_notification: Optional[bool] = None, 36 | reply_to_message_id: Optional[int] = None, 37 | ): 38 | return self._controller.client.send_inline_bot_result( 39 | chat_id, 40 | self._query_id, 41 | self.result.id, 42 | disable_notification=disable_notification, 43 | reply_to_message_id=reply_to_message_id, 44 | ) 45 | 46 | @property 47 | def id(self) -> Any: 48 | return self.result.id 49 | 50 | @property 51 | def full_text(self) -> str: 52 | return "{}\n{}".format(self.result.title, self.result.description) 53 | 54 | @property 55 | def title(self): 56 | return self.result.title 57 | 58 | @property 59 | def description(self): 60 | return self.result.description 61 | 62 | @property 63 | def url(self): 64 | return self.result.url 65 | 66 | @property 67 | def thumb(self) -> Optional[WebDocument]: 68 | return self.result.thumb 69 | 70 | @property 71 | def content(self) -> Optional[WebDocument]: 72 | return self.result.content 73 | 74 | def __str__(self) -> str: 75 | return str(self.result) 76 | 77 | def __hash__(self): 78 | return hash(self.result.id) 79 | 80 | def __eq__(self, other) -> bool: 81 | return self.id == other.id 82 | 83 | 84 | class InlineResultContainer: 85 | def __init__( 86 | self, 87 | controller: "BotController", 88 | query: str, 89 | latitude: Optional[float], 90 | longitude: Optional[float], 91 | results: List[InlineResult], 92 | gallery: bool, 93 | switch_pm: InlineBotSwitchPM, 94 | users, 95 | ): 96 | self._controller = controller 97 | self.query = query 98 | self.latitude = latitude 99 | self.longitude = longitude 100 | self.results = results 101 | self.is_gallery = gallery 102 | self._switch_pm = switch_pm 103 | self._users = users 104 | 105 | @property 106 | def can_switch_pm(self) -> bool: 107 | return bool(self._switch_pm) 108 | 109 | async def switch_pm(self) -> Message: 110 | if not self.can_switch_pm: 111 | raise AttributeError("This inline query does not allow switching to PM.") 112 | text = "/start {}".format(self._switch_pm.start_param or "").strip() 113 | return await self._controller.client.send_message( 114 | self._controller.peer_id, text 115 | ) 116 | 117 | def _match(self, pattern: Pattern, getter: attrgetter) -> List[InlineResult]: 118 | results = [] 119 | if pattern: 120 | compiled = re.compile(pattern) 121 | for result in self.results: 122 | compare = getter(result) 123 | if compiled.match(compare): 124 | results.append(result) 125 | return results 126 | 127 | def find_results( 128 | self, 129 | title_pattern=None, 130 | description_pattern=None, 131 | message_pattern=None, 132 | url_pattern=None, 133 | ) -> Set[InlineResult]: 134 | 135 | # TODO: 136 | # article_types: List[str] = None, 137 | 138 | d = { 139 | title_pattern: attrgetter("title"), 140 | description_pattern: attrgetter("description"), 141 | message_pattern: attrgetter("send_message.message"), 142 | url_pattern: attrgetter("url"), 143 | } 144 | 145 | return set(flatten((self._match(*it) for it in d.items()))) 146 | -------------------------------------------------------------------------------- /tgintegration/containers/reply_keyboard.py: -------------------------------------------------------------------------------- 1 | """ 2 | ​ 3 | """ 4 | import re 5 | from typing import List 6 | from typing import Pattern 7 | from typing import TYPE_CHECKING 8 | from typing import Union 9 | 10 | from pyrogram import filters as f 11 | from pyrogram.filters import Filter 12 | from pyrogram.types import KeyboardButton 13 | from pyrogram.types import Message 14 | 15 | from tgintegration.containers import NoButtonFound 16 | 17 | if TYPE_CHECKING: 18 | from tgintegration.botcontroller import BotController 19 | from tgintegration.containers.responses import Response 20 | 21 | 22 | class ReplyKeyboard: 23 | """ 24 | Represents a regular keyboard in the Telegram UI and allows to click buttons in the menu. 25 | 26 | See Also: 27 | [InlineKeyboard](tgintegration.InlineKeyboard) 28 | """ 29 | 30 | def __init__( 31 | self, 32 | controller: "BotController", 33 | chat_id: Union[int, str], 34 | message_id: int, 35 | button_rows: List[List[KeyboardButton]], 36 | ): 37 | self._controller: BotController = controller 38 | self._message_id = message_id 39 | self._peer_id = chat_id 40 | self.rows = button_rows 41 | 42 | def find_button(self, pattern: Pattern) -> KeyboardButton: 43 | """ 44 | Attempts to retrieve a clickable button anywhere in the underlying `rows` by matching the button captions with 45 | the given `pattern`. If no button could be found, **this method raises** `NoButtonFound`. 46 | 47 | Args: 48 | pattern: The button caption to look for (by `re.match`). 49 | 50 | Returns: 51 | The `KeyboardButton` if found. 52 | """ 53 | compiled = re.compile(pattern) 54 | for row in self.rows: 55 | for button in row: 56 | # TODO: Investigate why sometimes it's a button and other times a string 57 | if compiled.match(button.text if hasattr(button, "text") else button): 58 | return button 59 | raise NoButtonFound(f"No clickable entity found for pattern r'{pattern}'") 60 | 61 | async def _click_nowait(self, pattern, quote=False) -> Message: 62 | button = self.find_button(pattern) 63 | 64 | return await self._controller.client.send_message( 65 | self._peer_id, 66 | button.text, 67 | reply_to_message_id=self._message_id if quote else None, 68 | ) 69 | 70 | @property 71 | def num_buttons(self) -> int: 72 | """ 73 | Returns the total number of buttons in all underlying rows. 74 | """ 75 | return sum(len(row) for row in self.rows) 76 | 77 | async def click( 78 | self, pattern: Pattern, filters: Filter = None, quote: bool = False 79 | ) -> "Response": 80 | """ 81 | Uses `find_button` with the given `pattern`, clicks the button if found, and waits for the bot to react. For 82 | a `ReplyKeyboard`, this means that a message with the button's caption will be sent to the same chat. 83 | 84 | If not button could be found, `NoButtonFound` will be raised. 85 | 86 | Args: 87 | pattern: The button caption to look for (by `re.match`). 88 | filters: Additional filters to be given to `collect`. Will be merged with a "same chat" filter and 89 | `filters.text | filters.edited`. 90 | quote: Whether to reply to the message containing the buttons. 91 | 92 | Returns: 93 | The bot's `Response`. 94 | """ 95 | button = self.find_button(pattern) 96 | 97 | filters = ( 98 | filters & f.chat(self._peer_id) if filters else f.chat(self._peer_id) 99 | ) & (f.text | f.edited) 100 | 101 | async with self._controller.collect(filters=filters) as res: # type: Response 102 | await self._controller.client.send_message( 103 | self._controller.peer, 104 | button.text if hasattr(button, "text") else button, 105 | reply_to_message_id=self._message_id if quote else None, 106 | ) 107 | 108 | return res 109 | -------------------------------------------------------------------------------- /tgintegration/containers/responses.py: -------------------------------------------------------------------------------- 1 | """ 2 | ​ 3 | """ 4 | from datetime import datetime 5 | from typing import Any 6 | from typing import List 7 | from typing import Optional 8 | from typing import Set 9 | from typing import TYPE_CHECKING 10 | 11 | from pyrogram.types import InlineKeyboardMarkup 12 | from pyrogram.types import Message 13 | from pyrogram.types import ReplyKeyboardMarkup 14 | 15 | from tgintegration.containers import InlineKeyboard 16 | from tgintegration.containers import ReplyKeyboard 17 | from tgintegration.update_recorder import MessageRecorder 18 | 19 | if TYPE_CHECKING: 20 | from tgintegration.botcontroller import BotController 21 | 22 | 23 | class Response: 24 | def __init__(self, controller: "BotController", recorder: MessageRecorder): 25 | self._controller = controller 26 | self._recorder = recorder 27 | 28 | self.started: Optional[float] = None 29 | self.action_result: Any = None 30 | 31 | # cached properties 32 | self.__reply_keyboard: Optional[ReplyKeyboard] = None 33 | self.__inline_keyboards: List[InlineKeyboard] = [] 34 | 35 | @property 36 | def messages(self) -> List[Message]: 37 | return self._recorder.messages 38 | 39 | @property 40 | def is_empty(self) -> bool: 41 | return not self.messages 42 | 43 | @property 44 | def num_messages(self) -> int: 45 | return len(self.messages) 46 | 47 | @property 48 | def full_text(self) -> str: 49 | return "\n".join(x.text for x in self.messages if x.text) or "" 50 | 51 | @property 52 | def reply_keyboard(self) -> Optional[ReplyKeyboard]: 53 | if self.__reply_keyboard: 54 | return self.__reply_keyboard 55 | if self.is_empty: 56 | return None 57 | 58 | # Contingent upon the way Telegram works, 59 | # only the *last* message with buttons in a response object matters 60 | messages = reversed(self.messages) 61 | for m in messages: 62 | if isinstance(m.reply_markup, ReplyKeyboardMarkup): 63 | last_kb_msg = m 64 | break 65 | else: 66 | return None # No message with a keyboard found 67 | 68 | reply_keyboard = ReplyKeyboard( 69 | controller=self._controller, 70 | chat_id=last_kb_msg.chat.id, 71 | message_id=last_kb_msg.id, 72 | button_rows=last_kb_msg.reply_markup.keyboard, 73 | ) 74 | self.__reply_keyboard = reply_keyboard 75 | return reply_keyboard 76 | 77 | @property 78 | def inline_keyboards(self) -> Optional[List[InlineKeyboard]]: 79 | if self.__inline_keyboards: 80 | return self.__inline_keyboards 81 | if self.is_empty: 82 | return None 83 | 84 | inline_keyboards = [ 85 | InlineKeyboard( 86 | controller=self._controller, 87 | chat_id=message.chat.id, 88 | message_id=message.id, 89 | button_rows=message.reply_markup.inline_keyboard, 90 | ) 91 | for message in self.messages 92 | if isinstance(message.reply_markup, InlineKeyboardMarkup) 93 | ] 94 | 95 | self.__inline_keyboards = inline_keyboards 96 | return inline_keyboards 97 | 98 | @property 99 | def keyboard_buttons(self) -> Set[str]: 100 | all_buttons = set() 101 | for m in self.messages: 102 | markup = m.reply_markup 103 | if markup and hasattr(markup, "keyboard"): 104 | for row in markup.keyboard: 105 | for button in row: 106 | all_buttons.add(button) 107 | return all_buttons 108 | 109 | @property 110 | def last_message_datetime(self) -> Optional[datetime]: 111 | return None if self.is_empty else self.messages[-1].date 112 | 113 | @property 114 | def last_message_timestamp(self) -> Optional[float]: 115 | return None if self.is_empty else self.messages[-1].date.timestamp() 116 | 117 | @property 118 | def commands(self) -> Set[str]: 119 | all_commands = set() 120 | for m in self.messages: 121 | entity_commands = [x for x in m.entities if x.type == "bot_command"] 122 | for e in entity_commands: 123 | all_commands.add(m.text[e.offset, len(m.text) - e.length]) 124 | caption_entity_commands = [x for x in m.entities if x.type == "bot_command"] 125 | for e in caption_entity_commands: 126 | all_commands.add(m.caption[e.offset, len(m.caption) - e.length]) 127 | return all_commands 128 | 129 | async def delete_all_messages(self, revoke: bool = True): 130 | peer_id = self.messages[0].chat.id 131 | await self._controller.client.delete_messages( 132 | peer_id, [x.id for x in self.messages], revoke=revoke 133 | ) 134 | 135 | def __eq__(self, other): 136 | if not isinstance(other, Response): 137 | return False 138 | 139 | return ( 140 | self.full_text == other.full_text 141 | and self.inline_keyboards == other.inline_keyboards 142 | # TODO: self.keyboard == other.keyboard 143 | ) 144 | 145 | def __getitem__(self, item): 146 | return self.messages[item] 147 | 148 | def __str__(self): 149 | if self.is_empty: 150 | return "Empty response" 151 | return "\nthen\n".join(['"{}"'.format(m.text) for m in self.messages]) 152 | 153 | 154 | class InvalidResponseError(Exception): 155 | """ 156 | Raised when peer's response did not match the [expectation](tgintegration.expectation.Expectation). 157 | """ 158 | -------------------------------------------------------------------------------- /tgintegration/expectation.py: -------------------------------------------------------------------------------- 1 | """ 2 | ​ 3 | """ 4 | import logging 5 | from dataclasses import dataclass 6 | from typing import List 7 | from typing import Union 8 | 9 | from pyrogram.types import Message 10 | 11 | from tgintegration.containers.responses import InvalidResponseError 12 | from tgintegration.timeout_settings import TimeoutSettings 13 | from tgintegration.utils.sentinel import NotSet 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | @dataclass 19 | class Expectation: 20 | """ 21 | Defines the expected reaction of a peer. 22 | """ 23 | 24 | min_messages: Union[int, NotSet] = NotSet 25 | """ 26 | Minimum number of expected messages. 27 | """ 28 | 29 | max_messages: Union[int, NotSet] = NotSet 30 | """ 31 | Maximum number of expected messages. 32 | """ 33 | 34 | def is_sufficient(self, messages: List[Message]) -> bool: 35 | n = len(messages) 36 | if self.min_messages is NotSet: 37 | return n >= 1 38 | return n >= self.min_messages 39 | 40 | def _is_match(self, messages: List[Message]) -> bool: 41 | n = len(messages) 42 | return (self.min_messages is NotSet or n >= self.min_messages) and ( 43 | self.max_messages is NotSet or n <= self.max_messages 44 | ) 45 | 46 | def verify(self, messages: List[Message], timeouts: TimeoutSettings) -> None: 47 | if self._is_match(messages): 48 | return 49 | 50 | n = len(messages) 51 | 52 | if n < self.min_messages: 53 | _raise_or_log( 54 | timeouts, 55 | "Expected {} messages but only received {} after waiting {} seconds.", 56 | self.min_messages, 57 | n, 58 | timeouts.max_wait, 59 | ) 60 | return 61 | 62 | if n > self.max_messages: 63 | _raise_or_log( 64 | timeouts, 65 | "Expected only {} messages but received {}.", 66 | self.max_messages, 67 | n, 68 | ) 69 | return 70 | 71 | 72 | def _raise_or_log(timeouts: TimeoutSettings, msg: str, *fmt) -> None: 73 | if timeouts.raise_on_timeout: 74 | if fmt: 75 | raise InvalidResponseError(msg.format(*fmt)) 76 | else: 77 | raise InvalidResponseError(msg) 78 | logger.debug(msg, *fmt) 79 | -------------------------------------------------------------------------------- /tgintegration/handler_utils.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from contextlib import asynccontextmanager 3 | from typing import List 4 | 5 | from pyrogram import Client 6 | from pyrogram.dispatcher import Dispatcher 7 | from pyrogram.handlers.handler import Handler 8 | 9 | 10 | def find_free_group(dispatcher: Dispatcher, max_index: int = -1000) -> int: 11 | """ 12 | Finds the next free group index in the given `dispatcher`'s groups that is lower than `max_index`. 13 | """ 14 | groups = dispatcher.groups 15 | i = max_index 16 | while True: 17 | if i in groups: 18 | i -= 1 19 | continue 20 | return i 21 | 22 | 23 | @asynccontextmanager 24 | async def add_handlers_transient(client: Client, handlers: List[Handler]): 25 | dispatcher = client.dispatcher 26 | 27 | # TODO: Add a comment why it's necessary to circumvent pyro's builtin 28 | for lock in dispatcher.locks_list: 29 | await lock.acquire() 30 | 31 | group = find_free_group(dispatcher) 32 | 33 | try: 34 | dispatcher.groups[group] = [] 35 | dispatcher.groups = OrderedDict(sorted(dispatcher.groups.items())) 36 | for handler in handlers: 37 | dispatcher.groups[group].append(handler) 38 | finally: 39 | for lock in dispatcher.locks_list: 40 | lock.release() 41 | 42 | yield 43 | 44 | for lock in dispatcher.locks_list: 45 | await lock.acquire() 46 | 47 | try: 48 | if group not in dispatcher.groups: 49 | raise ValueError(f"Group {group} does not exist. Handler was not removed.") 50 | 51 | for handler in handlers: 52 | dispatcher.groups[group].remove(handler) 53 | finally: 54 | for lock in dispatcher.locks_list: 55 | lock.release() 56 | -------------------------------------------------------------------------------- /tgintegration/timeout_settings.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional 3 | 4 | 5 | @dataclass 6 | class TimeoutSettings: 7 | max_wait: float = 10 8 | """ 9 | The maximum duration in seconds to wait for a response from the peer. 10 | """ 11 | 12 | wait_consecutive: Optional[float] = None 13 | """ 14 | The minimum duration in seconds to wait for another consecutive message from the peer after 15 | receiving a message. This can cause the total duration to exceed the `max_wait` time. 16 | """ 17 | 18 | raise_on_timeout: bool = False 19 | """ 20 | Whether to raise an exception when a timeout occurs or to fail with a log message. 21 | """ 22 | -------------------------------------------------------------------------------- /tgintegration/update_recorder.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import time 4 | from typing import Callable 5 | from typing import List 6 | from typing import Tuple 7 | 8 | from pyrogram.types import Message 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class MessageRecorder: 14 | def __init__(self): 15 | self.messages: List[Message] = [] 16 | self._lock = asyncio.Lock() 17 | 18 | self.any_received = asyncio.Event() 19 | self._event_conditions: List[ 20 | Tuple[Callable[[List[Message]], bool], asyncio.Event] 21 | ] = [(bool, self.any_received)] 22 | 23 | self._is_completed = False 24 | 25 | async def record_message(self, _, message: Message): 26 | if self._is_completed: 27 | return 28 | 29 | async with self._lock: 30 | message.exact_timestamp = time.time() 31 | self.messages.append(message) 32 | for (pred, ev) in self._event_conditions: 33 | if pred(self.messages): 34 | ev.set() 35 | 36 | async def wait_until(self, predicate: Callable[[List[Message]], bool]): 37 | 38 | async with self._lock: 39 | if predicate(self.messages): 40 | return 41 | 42 | ev = asyncio.Event() 43 | self._event_conditions.append((predicate, ev)) 44 | 45 | await ev.wait() 46 | 47 | def stop(self): 48 | self._is_completed = True 49 | -------------------------------------------------------------------------------- /tgintegration/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JosXa/tgintegration/39dfc82eb5c80bb6845c12edf0c112e2fa7de26f/tgintegration/utils/__init__.py -------------------------------------------------------------------------------- /tgintegration/utils/frame_utils.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | 4 | def get_caller_function_name() -> str: 5 | return inspect.stack()[2].function 6 | -------------------------------------------------------------------------------- /tgintegration/utils/iter_utils.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | 4 | def flatten(listOfLists): 5 | """Return an iterator flattening one level of nesting in a list of lists. 6 | 7 | >>> list(flatten([[0, 1], [2, 3]])) 8 | [0, 1, 2, 3] 9 | 10 | See also :func:`collapse`, which can flatten multiple levels of nesting. 11 | 12 | """ 13 | return chain.from_iterable(listOfLists) 14 | -------------------------------------------------------------------------------- /tgintegration/utils/sentinel.py: -------------------------------------------------------------------------------- 1 | class NotSet(object): 2 | pass # Sentinel 3 | --------------------------------------------------------------------------------