├── .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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
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 | [](https://pypi.org/project/tgintegration/)
10 | [](https://pypi.org/project/tgintegration/)
11 | [](https://pypi.org/project/tgintegration/)
12 | 
13 | [](https://github.com/JosXa/tgintegration/actions?query=workflow%3ABuild)
14 | [](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 | 
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 | 
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 | # 
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 | 
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 | 
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 |
11 |
12 | Your browser does not support the video tag.
13 |
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 |
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 |
--------------------------------------------------------------------------------