diff --git a/.github/CONTRIBUTING.rst b/.github/CONTRIBUTING.rst index 4f65904c1..3e7dc6d12 100644 --- a/.github/CONTRIBUTING.rst +++ b/.github/CONTRIBUTING.rst @@ -26,7 +26,7 @@ Setting things up .. code-block:: bash - $ pip install -r requirements.txt -r requirements-dev.txt + $ pip install -r requirements-all.txt 5. Install pre-commit hooks: diff --git a/.github/workflows/docs-linkcheck.yml b/.github/workflows/docs-linkcheck.yml index 2e9abe096..c69c58b3e 100644 --- a/.github/workflows/docs-linkcheck.yml +++ b/.github/workflows/docs-linkcheck.yml @@ -22,8 +22,6 @@ jobs: - name: Install dependencies run: | python -W ignore -m pip install --upgrade pip - python -W ignore -m pip install -r requirements.txt - python -W ignore -m pip install -r requirements-dev.txt - python -W ignore -m pip install -r docs/requirements-docs.txt + python -W ignore -m pip install -r requirements-all.txt - name: Check Links run: sphinx-build docs/source docs/build/html -W --keep-going -j auto -b linkcheck diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 23168cf23..c2163d4b4 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -27,8 +27,6 @@ jobs: - name: Install dependencies run: | python -W ignore -m pip install --upgrade pip - python -W ignore -m pip install -r requirements.txt - python -W ignore -m pip install -r requirements-dev.txt - python -W ignore -m pip install -r docs/requirements-docs.txt + python -W ignore -m pip install -r requirements-all.txt - name: Build docs run: sphinx-build docs/source docs/build/html -W --keep-going -j auto diff --git a/.github/workflows/pre-commit_dependencies_notifier.yml b/.github/workflows/pre-commit_dependencies_notifier.yml index e4076b7d6..6f6428faf 100644 --- a/.github/workflows/pre-commit_dependencies_notifier.yml +++ b/.github/workflows/pre-commit_dependencies_notifier.yml @@ -3,6 +3,7 @@ on: pull_request_target: paths: - requirements.txt + - requirements-opts.txt - .pre-commit-config.yaml permissions: pull-requests: write @@ -14,5 +15,5 @@ jobs: - name: running the check uses: Poolitzer/notifier-action@master with: - notify-message: Hey! Looks like you edited the requirements or the pre-commit hooks. I'm just a friendly reminder to keep the additional dependencies for the hooks in sync with the requirements :) + notify-message: Hey! Looks like you edited the (optional) requirements or the pre-commit hooks. I'm just a friendly reminder to keep the additional dependencies for the hooks in sync with the requirements :) repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a8ab4a825..39d414ab5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,32 +42,41 @@ jobs: python -W ignore -m pip install --upgrade pip python -W ignore -m pip install -U codecov pytest-cov python -W ignore -m pip install -r requirements.txt + python -W ignore -m pip install -r requirements-opts.txt python -W ignore -m pip install -r requirements-dev.txt - name: Test with pytest - # We run 3 different suites here + # We run 4 different suites here # 1. Test just utils.datetime.py without pytz being installed # 2. Test just test_no_passport.py without passport dependencies being installed - # 3. Test everything else + # 3. Test just test_rate_limiter.py without passport dependencies being installed + # 4. Test everything else # The first & second one are achieved by mocking the corresponding import # See test_helpers.py & test_no_passport.py for details run: | pytest -v --cov -k test_no_passport.py no_passport_exit=$? - export TEST_NO_PASSPORT='false' + export TEST_PASSPORT='true' pytest -v --cov --cov-append -k test_helpers.py no_pytz_exit=$? - export TEST_NO_PYTZ='false' + export TEST_PYTZ='true' + pip uninstall aiolimiter -y + pytest -v --cov --cov-append -k test_ratelimiter.py + no_rate_limiter_exit=$? + export TEST_RATE_LIMITER='true' + pip install -r requirements-opts.txt pytest -v --cov --cov-append full_exit=$? special_exit=$(( no_pytz_exit > no_passport_exit ? no_pytz_exit : no_passport_exit )) + special_exit=$(( special_exit > no_rate_limiter_exit ? special_exit : no_rate_limiter_exit )) global_exit=$(( special_exit > full_exit ? special_exit : full_exit )) exit ${global_exit} env: JOB_INDEX: ${{ strategy.job-index }} BOTS: W3sidG9rZW4iOiAiNjk2MTg4NzMyOkFBR1Z3RUtmSEhsTmpzY3hFRE5LQXdraEdzdFpfa28xbUMwIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6WldGaU1UUmxNbVF5TnpNeSIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gVHJhdmlzIHVzaW5nIENQeXRob24gMi43IiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxMzkwOTgzOTk3IiwgImJvdF91c2VybmFtZSI6ICJAcHRiX3RyYXZpc19jcHl0aG9uXzI3X2JvdCJ9LCB7InRva2VuIjogIjY3MTQ2ODg4NjpBQUdQR2ZjaVJJQlVORmU4MjR1SVZkcTdKZTNfWW5BVE5HdyIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOlpHWXdPVGxrTXpNeE4yWTIiLCAiYm90X25hbWUiOiAiUFRCIHRlc3RzIG9uIFRyYXZpcyB1c2luZyBDUHl0aG9uIDMuNCIsICJzdXBlcl9ncm91cF9pZCI6ICItMTAwMTQ0NjAyMjUyMiIsICJib3RfdXNlcm5hbWUiOiAiQHB0Yl90cmF2aXNfY3B5dGhvbl8zNF9ib3QifSwgeyJ0b2tlbiI6ICI2MjkzMjY1Mzg6QUFGUnJaSnJCN29CM211ekdzR0pYVXZHRTVDUXpNNUNVNG8iLCAicGF5bWVudF9wcm92aWRlcl90b2tlbiI6ICIyODQ2ODUwNjM6VEVTVDpNbU01WVdKaFl6a3hNMlUxIiwgImJvdF9uYW1lIjogIlBUQiB0ZXN0cyBvbiBUcmF2aXMgdXNpbmcgQ1B5dGhvbiAzLjUiLCAic3VwZXJfZ3JvdXBfaWQiOiAiLTEwMDE0OTY5MTc3NTAiLCAiYm90X3VzZXJuYW1lIjogIkBwdGJfdHJhdmlzX2NweXRob25fMzVfYm90In0sIHsidG9rZW4iOiAiNjQwMjA4OTQzOkFBRmhCalFwOXFtM1JUeFN6VXBZekJRakNsZS1Kano1aGNrIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6WXpoa1pUZzFOamMxWXpWbCIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gVHJhdmlzIHVzaW5nIENQeXRob24gMy42IiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxMzMzODcxNDYxIiwgImJvdF91c2VybmFtZSI6ICJAcHRiX3RyYXZpc19jcHl0aG9uXzM2X2JvdCJ9LCB7InRva2VuIjogIjY5NTEwNDA4ODpBQUhmenlsSU9qU0lJUy1lT25JMjB5MkUyMEhvZEhzZnotMCIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOk9HUTFNRGd3WmpJd1pqRmwiLCAiYm90X25hbWUiOiAiUFRCIHRlc3RzIG9uIFRyYXZpcyB1c2luZyBDUHl0aG9uIDMuNyIsICJzdXBlcl9ncm91cF9pZCI6ICItMTAwMTQ3ODI5MzcxNCIsICJib3RfdXNlcm5hbWUiOiAiQHB0Yl90cmF2aXNfY3B5dGhvbl8zN19ib3QifSwgeyJ0b2tlbiI6ICI2OTE0MjM1NTQ6QUFGOFdrakNaYm5IcVBfaTZHaFRZaXJGRWxackdhWU9oWDAiLCAicGF5bWVudF9wcm92aWRlcl90b2tlbiI6ICIyODQ2ODUwNjM6VEVTVDpZamM1TlRoaU1tUXlNV1ZoIiwgImJvdF9uYW1lIjogIlBUQiB0ZXN0cyBvbiBUcmF2aXMgdXNpbmcgUHlQeSAyLjciLCAic3VwZXJfZ3JvdXBfaWQiOiAiLTEwMDEzNjM5MzI1NzMiLCAiYm90X3VzZXJuYW1lIjogIkBwdGJfdHJhdmlzX3B5cHlfMjdfYm90In0sIHsidG9rZW4iOiAiNjg0MzM5OTg0OkFBRk1nRUVqcDAxcjVyQjAwN3lDZFZOc2c4QWxOc2FVLWNjIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6TVRBek1UWTNNR1V5TmpnMCIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gVHJhdmlzIHVzaW5nIFB5UHkgMy41IiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxNDA3ODM2NjA1IiwgImJvdF91c2VybmFtZSI6ICJAcHRiX3RyYXZpc19weXB5XzM1X2JvdCJ9LCB7InRva2VuIjogIjY5MDA5MTM0NzpBQUZMbVI1cEFCNVljcGVfbU9oN3pNNEpGQk9oMHozVDBUbyIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOlpEaGxOekU1TURrd1lXSmkiLCAiYm90X25hbWUiOiAiUFRCIHRlc3RzIG9uIEFwcFZleW9yIHVzaW5nIENQeXRob24gMy40IiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxMjc5NjAwMDI2IiwgImJvdF91c2VybmFtZSI6ICJAcHRiX2FwcHZleW9yX2NweXRob25fMzRfYm90In0sIHsidG9rZW4iOiAiNjk0MzA4MDUyOkFBRUIyX3NvbkNrNTVMWTlCRzlBTy1IOGp4aVBTNTVvb0JBIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6WW1aaVlXWm1NakpoWkdNeSIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gQXBwVmV5b3IgdXNpbmcgQ1B5dGhvbiAyLjciLCAic3VwZXJfZ3JvdXBfaWQiOiAiLTEwMDEyOTMwNzkxNjUiLCAiYm90X3VzZXJuYW1lIjogIkBwdGJfYXBwdmV5b3JfY3B5dGhvbl8yN19ib3QifSwgeyJ0b2tlbiI6ICIxMDU1Mzk3NDcxOkFBRzE4bkJfUzJXQXd1SjNnN29oS0JWZ1hYY2VNbklPeVNjIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6TmpBd056QXpZalZpTkdOayIsICJuYW1lIjogIlBUQiB0ZXN0cyBbMF0iLCAic3VwZXJfZ3JvdXBfaWQiOiAiLTEwMDExODU1MDk2MzYiLCAidXNlcm5hbWUiOiAicHRiXzBfYm90In0sIHsidG9rZW4iOiAiMTA0NzMyNjc3MTpBQUY4bk90ODFGcFg4bGJidno4VWV3UVF2UmZUYkZmQnZ1SSIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOllUVTFOVEk0WkdSallqbGkiLCAibmFtZSI6ICJQVEIgdGVzdHMgWzFdIiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxNDg0Nzk3NjEyIiwgInVzZXJuYW1lIjogInB0Yl8xX2JvdCJ9LCB7InRva2VuIjogIjk3MTk5Mjc0NTpBQUdPa09hVzBOSGpnSXY1LTlqUWJPajR2R3FkaFNGLVV1cyIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOk5XWmtNV1ZoWWpsallqVTUiLCAibmFtZSI6ICJQVEIgdGVzdHMgWzJdIiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxNDAyMjU1MDcwIiwgInVzZXJuYW1lIjogInB0Yl8yX2JvdCJ9XQ== - TEST_NO_PYTZ : "true" - TEST_NO_PASSPORT: "true" + TEST_PYTZ : "false" + TEST_PASSPORT: "false" + TEST_RATE_LIMITER: "false" TEST_BUILD: "true" shell: bash --noprofile --norc {0} @@ -95,6 +104,7 @@ jobs: run: | python -W ignore -m pip install --upgrade pip python -W ignore -m pip install -r requirements.txt + python -W ignore -m pip install -r requirements-opts.txt python -W ignore -m pip install -r requirements-dev.txt - name: Compare to official api run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b9ec39d65..199f72a5a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,6 +35,7 @@ repos: - tornado~=6.2 - APScheduler~=3.9.1 - cachetools~=5.2.0 + - aiolimiter~=1.0.0 - . # this basically does `pip install -e .` - repo: https://github.com/pre-commit/mirrors-mypy rev: v0.971 @@ -43,7 +44,6 @@ repos: name: mypy-ptb files: ^telegram/.*\.py$ additional_dependencies: - - types-ujson - types-pytz - types-cryptography - types-cachetools @@ -51,6 +51,7 @@ repos: - tornado~=6.2 - APScheduler~=3.9.1 - cachetools~=5.2.0 + - aiolimiter~=1.0.0 - . # this basically does `pip install -e .` - id: mypy name: mypy-examples diff --git a/MANIFEST.in b/MANIFEST.in index a0169b273..1c6c1cfa6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1 @@ -include LICENSE LICENSE.lesser Makefile requirements.txt README_RAW.rst telegram/py.typed +include LICENSE LICENSE.lesser Makefile requirements.txt requirements-opts.txt README_RAW.rst telegram/py.typed diff --git a/README.rst b/README.rst index 1d0a13b8f..480efe065 100644 --- a/README.rst +++ b/README.rst @@ -138,6 +138,7 @@ PTB can be installed with optional dependencies: * ``pip install python-telegram-bot[passport]`` installs the `cryptography>=3.0 `_ library. Use this, if you want to use Telegram Passport related functionality. * ``pip install python-telegram-bot[socks]`` installs ``httpx[socks]``. Use this, if you want to work behind a Socks5 server. +* ``pip install python-telegram-bot[rate-limiter]`` installs ``aiolimiter~=1.0.0``. Use this, if you want to use ``telegram.ext.AIORateLimiter``. Quick Start =========== diff --git a/docs/source/conf.py b/docs/source/conf.py index 1f96883e7..d9365d1db 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -490,7 +490,7 @@ def autodoc_process_bases(app, name, obj, option, bases: list): # Now convert `telegram._message.Message` to `telegram.Message` etc match = re.search(pattern=r"(telegram(\.ext|))\.[_\w\.]+", string=base) if not match or "_utils" in base: - return + continue parts = match.group(0).split(".") diff --git a/docs/source/telegram.ext.aioratelimiter.rst b/docs/source/telegram.ext.aioratelimiter.rst new file mode 100644 index 000000000..b43c0350e --- /dev/null +++ b/docs/source/telegram.ext.aioratelimiter.rst @@ -0,0 +1,6 @@ +telegram.ext.AIORateLimiter +============================ + +.. autoclass:: telegram.ext.AIORateLimiter + :members: + :show-inheritance: diff --git a/docs/source/telegram.ext.baseratelimiter.rst b/docs/source/telegram.ext.baseratelimiter.rst new file mode 100644 index 000000000..c4820549a --- /dev/null +++ b/docs/source/telegram.ext.baseratelimiter.rst @@ -0,0 +1,6 @@ +telegram.ext.BaseRateLimiter +============================ + +.. autoclass:: telegram.ext.BaseRateLimiter + :members: + :show-inheritance: diff --git a/docs/source/telegram.ext.extbot.rst b/docs/source/telegram.ext.extbot.rst index d0e85027c..5388df5e9 100644 --- a/docs/source/telegram.ext.extbot.rst +++ b/docs/source/telegram.ext.extbot.rst @@ -3,5 +3,4 @@ telegram.ext.ExtBot .. autoclass:: telegram.ext.ExtBot :show-inheritance: - - .. autofunction:: telegram.ext.ExtBot.insert_callback_data + :members: insert_callback_data, defaults, rate_limiter, initialize, shutdown diff --git a/docs/source/telegram.ext.rst b/docs/source/telegram.ext.rst index 1b8dc8ea5..24313a28b 100644 --- a/docs/source/telegram.ext.rst +++ b/docs/source/telegram.ext.rst @@ -55,3 +55,11 @@ Arbitrary Callback Data telegram.ext.callbackdatacache telegram.ext.invalidcallbackdata + +Rate Limiting +------------- + +.. toctree:: + + telegram.ext.baseratelimiter + telegram.ext.aioratelimiter \ No newline at end of file diff --git a/requirements-all.txt b/requirements-all.txt new file mode 100644 index 000000000..d38ad6691 --- /dev/null +++ b/requirements-all.txt @@ -0,0 +1,4 @@ +-r requirements.txt +-r requirements-dev.txt +-r requirements-opts.txt +-r docs/requirements-docs.txt \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index 74dce71b9..9791b6afb 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,3 @@ -# cryptography is an optional dependency, but running the tests properly requires it -cryptography!=3.4,!=3.4.1,!=3.4.2,!=3.4.3 - pre-commit pytest==7.1.2 diff --git a/requirements-opts.txt b/requirements-opts.txt new file mode 100644 index 000000000..f4f5a0d7e --- /dev/null +++ b/requirements-opts.txt @@ -0,0 +1,7 @@ +# Format: +# package_name==version # req-1, req-2, req-3!ext +# `pip install ptb-raw[req-1/2]` will install `package_name` +# `pip install ptb[req-1/2/3]` will also install `package_name` +httpx[socks] # socks +cryptography!=3.4,!=3.4.1,!=3.4.2,!=3.4.3,>=3.0 # passport +aiolimiter~=1.0.0 # rate-limiter!ext \ No newline at end of file diff --git a/setup.py b/setup.py index 48f74596c..21e5134d0 100644 --- a/setup.py +++ b/setup.py @@ -2,6 +2,7 @@ """The setup and build script for the python-telegram-bot library.""" import subprocess import sys +from collections import defaultdict from pathlib import Path from setuptools import find_packages, setup @@ -35,6 +36,25 @@ def get_packages_requirements(raw=False): return packs, reqs +def get_optional_requirements(raw=False): + """Build the optional dependencies""" + requirements = defaultdict(list) + + with Path("requirements-opts.txt").open() as reqs: + for line in reqs: + if line.startswith("#"): + continue + dependency, names = line.split("#") + dependency = dependency.strip() + for name in names.split(","): + name = name.strip() + if name.endswith("!ext") and raw: + continue + requirements[name].append(dependency) + + return requirements + + def get_setup_kwargs(raw=False): """Builds a dictionary of kwargs for the setup function""" packages, requirements = get_packages_requirements(raw=raw) @@ -69,11 +89,7 @@ def get_setup_kwargs(raw=False): long_description_content_type="text/x-rst", packages=packages, install_requires=requirements, - extras_require={ - "socks": "httpx[socks]", - # 3.4-3.4.3 contained some cyclical import bugs - "passport": "cryptography!=3.4,!=3.4.1,!=3.4.2,!=3.4.3,>=3.0", - }, + extras_require=get_optional_requirements(raw=raw), include_package_data=True, classifiers=[ "Development Status :: 5 - Production/Stable", diff --git a/telegram/_bot.py b/telegram/_bot.py index fcfeeaaa0..ffdcb5a21 100644 --- a/telegram/_bot.py +++ b/telegram/_bot.py @@ -296,6 +296,25 @@ class Bot(TelegramObject, AbstractAsyncContextManager): # Drop any None values because Telegram doesn't handle them well data = {key: value for key, value in data.items() if value is not None} + return await self._do_post( + endpoint=endpoint, + data=data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + ) + + async def _do_post( + self, + endpoint: str, + data: JSONDict, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + ) -> Union[bool, JSONDict, None]: # This also converts datetimes into timestamps. # We don't do this earlier so that _insert_defaults (see above) has a chance to convert # to the default timezone in case this is called by ExtBot @@ -2902,7 +2921,7 @@ class Bot(TelegramObject, AbstractAsyncContextManager): api_kwargs=api_kwargs, ) - return UserProfilePhotos.de_json(result, self) # type: ignore[return-value, arg-type] + return UserProfilePhotos.de_json(result, self) # type: ignore[arg-type,return-value] @_log async def get_file( diff --git a/telegram/ext/__init__.py b/telegram/ext/__init__.py index 12151e764..9910a7365 100644 --- a/telegram/ext/__init__.py +++ b/telegram/ext/__init__.py @@ -19,10 +19,13 @@ """Extensions over the Telegram Bot API to facilitate bot making""" __all__ = ( + "AIORateLimiter", "Application", "ApplicationBuilder", "ApplicationHandlerStop", + "BaseHandler", "BasePersistence", + "BaseRateLimiter", "CallbackContext", "CallbackDataCache", "CallbackQueryHandler", @@ -36,7 +39,6 @@ __all__ = ( "DictPersistence", "ExtBot", "filters", - "BaseHandler", "InlineQueryHandler", "InvalidCallbackData", "Job", @@ -56,9 +58,11 @@ __all__ = ( ) from . import filters +from ._aioratelimiter import AIORateLimiter from ._application import Application, ApplicationHandlerStop from ._applicationbuilder import ApplicationBuilder from ._basepersistence import BasePersistence, PersistenceInput +from ._baseratelimiter import BaseRateLimiter from ._callbackcontext import CallbackContext from ._callbackdatacache import CallbackDataCache, InvalidCallbackData from ._callbackqueryhandler import CallbackQueryHandler diff --git a/telegram/ext/_aioratelimiter.py b/telegram/ext/_aioratelimiter.py new file mode 100644 index 000000000..d17204885 --- /dev/null +++ b/telegram/ext/_aioratelimiter.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2022 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an implementation of the BaseRateLimiter class based on the aiolimiter +library. +""" +import asyncio +import contextlib +import logging +import sys +from typing import Any, AsyncIterator, Callable, Coroutine, Dict, Optional, Union + +try: + from aiolimiter import AsyncLimiter + + AIO_LIMITER_AVAILABLE = True +except ImportError: + AIO_LIMITER_AVAILABLE = False + +from telegram._utils.types import JSONDict +from telegram.error import RetryAfter +from telegram.ext._baseratelimiter import BaseRateLimiter + +# Useful for something like: +# async with group_limiter if group else null_context(): +# so we don't have to differentiate between "I'm using a context manager" and "I'm not" +if sys.version_info >= (3, 10): + null_context = contextlib.nullcontext # pylint: disable=invalid-name +else: + + @contextlib.asynccontextmanager + async def null_context() -> AsyncIterator[None]: + yield None + + +class AIORateLimiter(BaseRateLimiter[int]): + """ + Implementation of :class:`~telegram.ext.BaseRateLimiter` using the library + `aiolimiter `_. + + Important: + If you want to use this class, you must install PTB with the optional requirement + ``rate-limiter``, i.e. + + .. code-block:: bash + + pip install python-telegram-bot[rate-limiter] + + The rate limiting is applied by combining two levels of throttling and :meth:`process_request` + roughly boils down to:: + + async with group_limiter(group_id): + async with overall_limiter: + await callback(*args, **kwargs) + + Here, ``group_id`` is determined by checking if there is a ``chat_id`` parameter in the + :paramref:`~telegram.ext.BaseRateLimiter.process_request.data`. + The ``overall_limiter`` is applied only if a ``chat_id`` argument is present at all. + + Attention: + * Some bot methods accept a ``chat_id`` parameter in form of a ``@username`` for + supergroups and channels. As we can't know which ``@username`` corresponds to which + integer ``chat_id``, these will be treated as different groups, which may lead to + exceeding the rate limit. + * As channels can't be differentiated from supergroups by the ``@username`` or integer + ``chat_id``, this also applies the group related rate limits to channels. + * A :exc:`~telegram.error.RetryAfter` exception will halt *all* requests for + :attr:`~telegram.error.RetryAfter.retry_after` + 0.1 seconds. This may be stricter than + necessary in some cases, e.g. the bot may hit a rate limit in one group but might still + be allowed to send messages in another group. + + Note: + This class is to be understood as minimal effort reference implementation. + If you would like to handle rate limiting in a more sophisticated, fine-tuned way, we + welcome you to implement your own subclass of :class:`~telegram.ext.BaseRateLimiter`. + Feel free to check out the source code of this class for inspiration. + + .. versionadded:: 20.0 + + Args: + overall_max_rate (:obj:`float`): The maximum number of requests allowed for the entire bot + per :paramref:`overall_time_period`. When set to 0, no rate limiting will be applied. + Defaults to ``30``. + overall_time_period (:obj:`float`): The time period (in seconds) during which the + :paramref:`overall_max_rate` is enforced. When set to 0, no rate limiting will be + applied. Defaults to 1. + group_max_rate (:obj:`float`): The maximum number of requests allowed for requests related + to groups and channels per :paramref:`group_time_period`. When set to 0, no rate + limiting will be applied. Defaults to 20. + group_time_period (:obj:`float`): The time period (in seconds) during which the + :paramref:`group_time_period` is enforced. When set to 0, no rate limiting will be + applied. Defaults to 60. + max_retries (:obj:`int`): The maximum number of retries to be made in case of a + :exc:`~telegram.error.RetryAfter` exception. + If set to 0, no retries will be made. Defaults to ``0``. + + """ + + __slots__ = ( + "_base_limiter", + "_group_limiters", + "_group_max_rate", + "_group_time_period", + "_logger", + "_max_retries", + "_retry_after_event", + ) + + def __init__( + self, + overall_max_rate: float = 30, + overall_time_period: float = 1, + group_max_rate: float = 20, + group_time_period: float = 60, + max_retries: int = 0, + ) -> None: + if not AIO_LIMITER_AVAILABLE: + raise RuntimeError( + "To use `AIORateLimiter`, PTB must be installed via `pip install " + "python-telegram-bot[rate-limiter]`." + ) + if overall_max_rate and overall_time_period: + self._base_limiter: Optional[AsyncLimiter] = AsyncLimiter( + max_rate=overall_max_rate, time_period=overall_time_period + ) + else: + self._base_limiter = None + + if group_max_rate and group_time_period: + self._group_max_rate = group_max_rate + self._group_time_period = group_time_period + else: + self._group_max_rate = 0 + self._group_time_period = 0 + + self._group_limiters: Dict[Union[str, int], AsyncLimiter] = {} + self._max_retries = max_retries + self._logger = logging.getLogger(__name__) + self._retry_after_event = asyncio.Event() + self._retry_after_event.set() + + async def initialize(self) -> None: + """Does nothing.""" + + async def shutdown(self) -> None: + """Does nothing.""" + + def _get_group_limiter(self, group_id: Union[str, int, bool]) -> "AsyncLimiter": + # Remove limiters that haven't been used for so long that all their capacity is unused + # We only do that if we have a lot of limiters lying around to avoid looping on every call + # This is a minimal effort approach - a full-fledged cache could use a TTL approach + # or at least adapt the threshold dynamically depending on the number of active limiters + if len(self._group_limiters) > 512: + # We copy to avoid modifying the dict while we iterate over it + for key, limiter in self._group_limiters.copy().items(): + if key == group_id: + continue + if limiter.has_capacity(limiter.max_rate): + del self._group_limiters[key] + + if group_id not in self._group_limiters: + self._group_limiters[group_id] = AsyncLimiter( + max_rate=self._group_max_rate, + time_period=self._group_time_period, + ) + return self._group_limiters[group_id] + + async def _run_request( + self, + chat: bool, + group: Union[str, int, bool], + callback: Callable[..., Coroutine[Any, Any, Union[bool, JSONDict, None]]], + args: Any, + kwargs: Dict[str, Any], + ) -> Union[bool, JSONDict, None]: + base_context = self._base_limiter if (chat and self._base_limiter) else null_context() + group_context = ( + self._get_group_limiter(group) if group and self._group_max_rate else null_context() + ) + + async with group_context: # skipcq: PTC-W0062 + async with base_context: + # In case a retry_after was hit, we wait with processing the request + await self._retry_after_event.wait() + + return await callback(*args, **kwargs) + + # mypy doesn't understand that the last run of the for loop raises an exception + async def process_request( # type: ignore[return] + self, + callback: Callable[..., Coroutine[Any, Any, Union[bool, JSONDict, None]]], + args: Any, + kwargs: Dict[str, Any], + endpoint: str, # skipcq: PYL-W0613 + data: Dict[str, Any], + rate_limit_args: Optional[int], + ) -> Union[bool, JSONDict, None]: + """ + Processes a request by applying rate limiting. + + See :meth:`telegram.ext.BaseRateLimiter.process_request` for detailed information on the + arguments. + + Args: + rate_limit_args (:obj:`None` | :obj:`int`): If set, specifies the maximum number of + retries to be made in case of a :exc:`~telegram.error.RetryAfter` exception. + Defaults to :paramref:`AIORateLimiter.max_retries`. + """ + max_retries = rate_limit_args or self._max_retries + + group: Union[int, str, bool] = False + chat: bool = False + chat_id = data.get("chat_id") + if chat_id is not None: + chat = True + + # In case user passes integer chat id as string + try: + chat_id = int(chat_id) + except (ValueError, TypeError): + pass + + if (isinstance(chat_id, int) and chat_id < 0) or isinstance(chat_id, str): + # string chat_id only works for channels and supergroups + # We can't really tell channels from groups though ... + group = chat_id + + for i in range(max_retries + 1): + try: + return await self._run_request( + chat=chat, group=group, callback=callback, args=args, kwargs=kwargs + ) + except RetryAfter as exc: + if i == max_retries: + self._logger.exception( + "Rate limit hit after maximum of %d retries", max_retries, exc_info=exc + ) + raise exc + + sleep = exc.retry_after + 0.1 + self._logger.info("Rate limit hit. Retrying after %f seconds", sleep) + # Make sure we don't allow other requests to be processed + self._retry_after_event.clear() + await asyncio.sleep(sleep) + finally: + # Allow other requests to be processed + self._retry_after_event.set() diff --git a/telegram/ext/_applicationbuilder.py b/telegram/ext/_applicationbuilder.py index 0a35c6149..403e53cdd 100644 --- a/telegram/ext/_applicationbuilder.py +++ b/telegram/ext/_applicationbuilder.py @@ -45,7 +45,8 @@ from telegram.request import BaseRequest from telegram.request._httpxrequest import HTTPXRequest if TYPE_CHECKING: - from telegram.ext import BasePersistence, CallbackContext, Defaults + from telegram.ext import BasePersistence, BaseRateLimiter, CallbackContext, Defaults + from telegram.ext._utils.types import RLARGS # Type hinting is a bit complicated here because we try to get to a sane level of # leveraging generics and therefore need a number of type variables. @@ -81,6 +82,7 @@ _BOT_CHECKS = [ ("defaults", "defaults"), ("arbitrary_callback_data", "arbitrary_callback_data"), ("private_key", "private_key"), + ("rate_limiter", "rate_limiter instance"), ] _TWO_ARGS_REQ = "The parameter `{}` may only be set, if no {} was set." @@ -139,6 +141,7 @@ class ApplicationBuilder(Generic[BT, CCT, UD, CD, BD, JQ]): "_private_key", "_private_key_password", "_proxy_url", + "_rate_limiter", "_read_timeout", "_request", "_token", @@ -180,6 +183,7 @@ class ApplicationBuilder(Generic[BT, CCT, UD, CD, BD, JQ]): self._updater: ODVInput[Updater] = DEFAULT_NONE self._post_init: Optional[Callable[[Application], Coroutine[Any, Any, None]]] = None self._post_shutdown: Optional[Callable[[Application], Coroutine[Any, Any, None]]] = None + self._rate_limiter: ODVInput["BaseRateLimiter"] = DEFAULT_NONE def _build_request(self, get_updates: bool) -> BaseRequest: prefix = "_get_updates_" if get_updates else "_" @@ -227,6 +231,7 @@ class ApplicationBuilder(Generic[BT, CCT, UD, CD, BD, JQ]): arbitrary_callback_data=DefaultValue.get_value(self._arbitrary_callback_data), request=self._build_request(get_updates=False), get_updates_request=self._build_request(get_updates=True), + rate_limiter=DefaultValue.get_value(self._rate_limiter), ) def build( @@ -973,10 +978,31 @@ class ApplicationBuilder(Generic[BT, CCT, UD, CD, BD, JQ]): self._post_shutdown = post_shutdown return self + def rate_limiter( + self: "ApplicationBuilder[BT, CCT, UD, CD, BD, JQ]", + rate_limiter: "BaseRateLimiter[RLARGS]", + ) -> "ApplicationBuilder[ExtBot[RLARGS], CCT, UD, CD, BD, JQ]": + """Sets a :class:`telegram.ext.BaseRateLimiter` instance for the + :paramref:`telegram.ext.ExtBot.rate_limiter` parameter of + :attr:`telegram.ext.Application.bot`. + + Args: + rate_limiter (:class:`telegram.ext.BaseRateLimiter`): The rate limiter. + + Returns: + :class:`ApplicationBuilder`: The same builder with the updated argument. + """ + if self._bot is not DEFAULT_NONE: + raise RuntimeError(_TWO_ARGS_REQ.format("rate_limiter", "bot instance")) + if self._updater not in (DEFAULT_NONE, None): + raise RuntimeError(_TWO_ARGS_REQ.format("rate_limiter", "updater")) + self._rate_limiter = rate_limiter + return self # type: ignore[return-value] + InitApplicationBuilder = ( # This is defined all the way down here so that its type is inferred ApplicationBuilder[ # by Pylance correctly. - ExtBot, + ExtBot[None], ContextTypes.DEFAULT_TYPE, Dict, Dict, diff --git a/telegram/ext/_baseratelimiter.py b/telegram/ext/_baseratelimiter.py new file mode 100644 index 000000000..06223b906 --- /dev/null +++ b/telegram/ext/_baseratelimiter.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2022 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains a class that allows to rate limit requests to the Bot API.""" +from abc import ABC, abstractmethod +from typing import Any, Callable, Coroutine, Dict, Generic, Optional, Union + +from telegram._utils.types import JSONDict +from telegram.ext._utils.types import RLARGS + + +class BaseRateLimiter(ABC, Generic[RLARGS]): + """ + Abstract interface class that allows to rate limit the requests that python-telegram-bot + sends to the Telegram Bot API. An implementation of this class + must implement all abstract methods and properties. + + This class is a :class:`~typing.Generic` class and accepts one type variable that specifies + the type of the argument :paramref:`~process_request.rate_limit_args` of + :meth:`process_request` and the methods of :class:`~telegram.ext.ExtBot`. + + Hint: + Requests to :meth:`~telegram.Bot.get_updates` are never rate limited. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + @abstractmethod + async def initialize(self) -> None: + """Initialize resources used by this class. Must be implemented by a subclass.""" + + @abstractmethod + async def shutdown(self) -> None: + """Stop & clear resources used by this class. Must be implemented by a subclass.""" + + @abstractmethod + async def process_request( + self, + callback: Callable[..., Coroutine[Any, Any, Union[bool, JSONDict, None]]], + args: Any, + kwargs: Dict[str, Any], + endpoint: str, + data: Dict[str, Any], + rate_limit_args: Optional[RLARGS], + ) -> Union[bool, JSONDict, None]: + """ + Process a request. Must be implemented by a subclass. + + This method must call :paramref:`callback` and return the result of the call. + `When` the callback is called is up to the implementation. + + Important: + This method must only return once the result of :paramref:`callback` is known! + + If a :exc:`~telegram.error.RetryAfter` error is raised, this method may try to make + a new request by calling the callback again. + + Warning: + This method *should not* handle any other exception raised by :paramref:`callback`! + + There are basically two different approaches how a rate limiter can be implemented: + + 1. React only if necessary. In this case, the :paramref:`callback` is called without any + precautions. If a :exc:`~telegram.error.RetryAfter` error is raised, processing requests + is halted for the :attr:`~telegram.error.RetryAfter.retry_after` and finally the + :paramref:`callback` is called again. This approach is often amendable for bots that + don't have a large user base and/or don't send more messages than they get updates. + 2. Throttle all outgoing requests. In this case the implementation makes sure that the + requests are spread out over a longer time interval in order to stay below the rate + limits. This approach is often amendable for bots that have a large user base and/or + send more messages than they get updates. + + An implementation can use the information provided by :paramref:`data`, + :paramref:`endpoint` and :paramref:`rate_limit_args` to handle each request differently. + + Examples: + * It is usually desirable to call :meth:`telegram.Bot.answer_inline_query` + as quickly as possible, while delaying :meth:`telegram.Bot.send_message` + is acceptable. + * There are `different `_ rate limits for group chats and + private chats. + * When sending broadcast messages to a large number of users, these requests can + typically be delayed for a longer time than messages that are direct replies to a + user input. + + Args: + callback (Callable[..., :term:`coroutine`]): The coroutine function that must be called + to make the request. + args (Tuple[:obj:`object`]): The positional arguments for the :paramref:`callback` + function. + kwargs (Dict[:obj:`str`, :obj:`object`]): The keyword arguments for the + :paramref:`callback` function. + endpoint (:obj:`str`): The endpoint that the request is made for, e.g. + ``"sendMessage"``. + data (Dict[:obj:`str`, :obj:`object`]): The parameters that were passed to the method + of :class:`~telegram.ext.ExtBot`. Any ``api_kwargs`` are included in this and + any :paramref:`~telegram.ext.ExtBot.defaults` are already applied. + + Example: + + When calling:: + + await ext_bot.send_message( + chat_id=1, + text="Hello world!", + api_kwargs={"custom": "arg"} + ) + + then :paramref:`data` will be:: + + {"chat_id": 1, "text": "Hello world!", "custom": "arg"} + + rate_limit_args (:obj:`None` | :class:`object`): Custom arguments passed to the methods + of :class:`~telegram.ext.ExtBot`. Can e.g. be used to specify the priority of + the request. + + Returns: + :obj:`bool` | Dict[:obj:`str`, :obj:`object`] | :obj:`None`: The result of the + callback function. + """ diff --git a/telegram/ext/_extbot.py b/telegram/ext/_extbot.py index 7e2e2065a..7c85b2ae0 100644 --- a/telegram/ext/_extbot.py +++ b/telegram/ext/_extbot.py @@ -24,6 +24,7 @@ from typing import ( TYPE_CHECKING, Callable, Dict, + Generic, List, Optional, Sequence, @@ -32,33 +33,73 @@ from typing import ( Union, cast, no_type_check, + overload, ) +from uuid import uuid4 from telegram import ( + Animation, + Audio, Bot, + BotCommand, + BotCommandScope, CallbackQuery, Chat, + ChatAdministratorRights, + ChatInviteLink, + ChatMember, + ChatPermissions, + ChatPhoto, + Contact, + Document, + File, + GameHighScore, InlineKeyboardMarkup, InputMedia, + Location, + MaskPosition, + MenuButton, Message, MessageId, + PassportElementError, + PhotoSize, Poll, + SentWebAppMessage, + ShippingOption, + Sticker, + StickerSet, Update, + User, + UserProfilePhotos, + Venue, + Video, + VideoNote, + Voice, + WebhookInfo, ) from telegram._utils.datetime import to_timestamp from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue -from telegram._utils.types import DVInput, JSONDict, ODVInput, ReplyMarkup +from telegram._utils.types import DVInput, FileInput, JSONDict, ODVInput, ReplyMarkup from telegram.ext._callbackdatacache import CallbackDataCache +from telegram.ext._utils.types import RLARGS from telegram.request import BaseRequest if TYPE_CHECKING: - from telegram import InlineQueryResult, MessageEntity - from telegram.ext import Defaults + from telegram import ( + InlineQueryResult, + InputMediaAudio, + InputMediaDocument, + InputMediaPhoto, + InputMediaVideo, + LabeledPrice, + MessageEntity, + ) + from telegram.ext import BaseRateLimiter, Defaults HandledTypes = TypeVar("HandledTypes", bound=Union[Message, CallbackQuery, Chat]) -class ExtBot(Bot): +class ExtBot(Bot, Generic[RLARGS]): """This object represents a Telegram Bot with convenience extensions. Warning: @@ -67,6 +108,16 @@ class ExtBot(Bot): For the documentation of the arguments, methods and attributes, please see :class:`telegram.Bot`. + All API methods of this class have an additional keyword argument ``rate_limit_args``. + This can be used to pass additional information to the rate limiter, specifically to + :paramref:`telegram.ext.BaseRateLimiter.process_request.rate_limit_args`. + + Warning: + * The keyword argument ``rate_limit_args`` can `not` be used, if :attr:`rate_limiter` + is :obj:`None`. + * The method :meth:`~telegram.Bot.get_updates` is the only method that does not have the + additional argument, as this method will never be rate limited. + .. versionadded:: 13.6 Args: @@ -77,6 +128,10 @@ class ExtBot(Bot): Pass an integer to specify the maximum number of objects cached in memory. For more details, please see our `wiki `_. Defaults to :obj:`False`. + rate_limiter (:class:`telegram.ext.BaseRateLimiter`, optional): A rate limiter to use for + limiting the number of requests made by the bot per time interval. + + .. versionadded:: 20.0 Attributes: arbitrary_callback_data (:obj:`bool` | :obj:`int`): Whether this bot instance @@ -87,7 +142,41 @@ class ExtBot(Bot): """ - __slots__ = ("arbitrary_callback_data", "callback_data_cache", "_defaults") + __slots__ = ("arbitrary_callback_data", "callback_data_cache", "_defaults", "_rate_limiter") + + # using object() would be a tiny bit safer, but a string plays better with the typing setup + __RL_KEY = uuid4().hex + + @overload + def __init__( + self: "ExtBot[None]", + token: str, + base_url: str = "https://api.telegram.org/bot", + base_file_url: str = "https://api.telegram.org/file/bot", + request: BaseRequest = None, + get_updates_request: BaseRequest = None, + private_key: bytes = None, + private_key_password: bytes = None, + defaults: "Defaults" = None, + arbitrary_callback_data: Union[bool, int] = False, + ): + ... + + @overload + def __init__( + self: "ExtBot[RLARGS]", + token: str, + base_url: str = "https://api.telegram.org/bot", + base_file_url: str = "https://api.telegram.org/file/bot", + request: BaseRequest = None, + get_updates_request: BaseRequest = None, + private_key: bytes = None, + private_key_password: bytes = None, + defaults: "Defaults" = None, + arbitrary_callback_data: Union[bool, int] = False, + rate_limiter: "BaseRateLimiter[RLARGS]" = None, + ): + ... def __init__( self, @@ -100,6 +189,7 @@ class ExtBot(Bot): private_key_password: bytes = None, defaults: "Defaults" = None, arbitrary_callback_data: Union[bool, int] = False, + rate_limiter: "BaseRateLimiter" = None, ): super().__init__( token=token, @@ -111,6 +201,7 @@ class ExtBot(Bot): private_key_password=private_key_password, ) self._defaults = defaults + self._rate_limiter = rate_limiter # set up callback_data if not isinstance(arbitrary_callback_data, bool): @@ -121,12 +212,112 @@ class ExtBot(Bot): self.arbitrary_callback_data = arbitrary_callback_data self.callback_data_cache: CallbackDataCache = CallbackDataCache(bot=self, maxsize=maxsize) + async def initialize(self) -> None: + """See :meth:`telegram.Bot.initialize`. Also initializes the + :paramref:`ExtBot.rate_limiter` (if set) + by calling :meth:`telegram.ext.BaseRateLimiter.initialize`. + """ + # Initialize before calling super, because super calls get_me + if self.rate_limiter: + await self.rate_limiter.initialize() + await super().initialize() + + async def shutdown(self) -> None: + """See :meth:`telegram.Bot.shutdown`. Also shuts down the + :paramref:`ExtBot.rate_limiter` (if set) by + calling :meth:`telegram.ext.BaseRateLimiter.shutdown`. + """ + # Shut down the rate limiter before shutting down the request objects! + if self.rate_limiter: + await self.rate_limiter.shutdown() + await super().shutdown() + + @classmethod + def _merge_api_rl_kwargs( + cls, api_kwargs: Optional[JSONDict], rate_limit_args: Optional[RLARGS] + ) -> Optional[JSONDict]: + """Inserts the `rate_limit_args` into `api_kwargs` with the special key `__RL_KEY` so + that we can extract them later without having to modify the `telegram.Bot` class. + """ + if not rate_limit_args: + return api_kwargs + if api_kwargs is None: + api_kwargs = {} + api_kwargs[cls.__RL_KEY] = rate_limit_args + return api_kwargs + + @classmethod + def _extract_rl_kwargs(cls, data: Optional[JSONDict]) -> Optional[RLARGS]: + """Extracts the `rate_limit_args` from `data` if it exists.""" + if not data: + return None + return data.pop(cls.__RL_KEY, None) + + async def _do_post( + self, + endpoint: str, + data: JSONDict, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + ) -> Union[bool, JSONDict, None]: + """Order of method calls is: Bot.some_method -> Bot._post -> Bot._do_post. + So we can override Bot._do_post to add rate limiting. + """ + rate_limit_args = self._extract_rl_kwargs(data) + if not self.rate_limiter and rate_limit_args is not None: + raise ValueError( + "`rate_limit_args` can only be used if a `ExtBot.rate_limiter` is set." + ) + + # getting updates should not be rate limited! + if endpoint == "getUpdates" or not self.rate_limiter: + return await super()._do_post( + endpoint=endpoint, + data=data, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + read_timeout=read_timeout, + ) + + kwargs = { + "read_timeout": read_timeout, + "write_timeout": write_timeout, + "connect_timeout": connect_timeout, + "pool_timeout": pool_timeout, + } + self._logger.debug( + "Passing request through rate limiter of type %s with rate_limit_args %s", + type(self.rate_limiter), + rate_limit_args, + ) + return await self.rate_limiter.process_request( + callback=super()._do_post, + args=(endpoint, data), + kwargs=kwargs, + endpoint=endpoint, + data=data, + rate_limit_args=rate_limit_args, + ) + @property def defaults(self) -> Optional["Defaults"]: """The :class:`telegram.ext.Defaults` used by this bot, if any.""" # This is a property because defaults shouldn't be changed at runtime return self._defaults + @property + def rate_limiter(self) -> Optional["BaseRateLimiter"]: + """The :class:`telegram.ext.BaseRateLimiter` used by this bot, if any. + + .. versionadded:: 20.0 + """ + # This is a property because the rate limiter shouldn't be changed at runtime + return self._rate_limiter + def _insert_defaults(self, data: Dict[str, object]) -> None: """Inserts the defaults values for optional kwargs for which tg.ext.Defaults provides convenience functionality, i.e. the kwargs with a tg.utils.helpers.DefaultValue default @@ -368,6 +559,7 @@ class ExtBot(Bot): connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, ) -> Poll: # We override this method to call self._replace_keyboard return await super().stop_poll( @@ -378,7 +570,7 @@ class ExtBot(Bot): write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, - api_kwargs=api_kwargs, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) async def copy_message( @@ -400,6 +592,7 @@ class ExtBot(Bot): connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, ) -> MessageId: # We override this method to call self._replace_keyboard return await super().copy_message( @@ -418,7 +611,7 @@ class ExtBot(Bot): write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, - api_kwargs=api_kwargs, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) async def get_chat( @@ -430,6 +623,7 @@ class ExtBot(Bot): connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, ) -> Chat: # We override this method to call self._insert_callback_data result = await super().get_chat( @@ -438,16 +632,2510 @@ class ExtBot(Bot): write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, - api_kwargs=api_kwargs, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) return self._insert_callback_data(result) + async def add_sticker_to_set( + self, + user_id: Union[str, int], + name: str, + emojis: str, + png_sticker: FileInput = None, + mask_position: MaskPosition = None, + tgs_sticker: FileInput = None, + webm_sticker: FileInput = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = 20, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().add_sticker_to_set( + user_id=user_id, + name=name, + emojis=emojis, + png_sticker=png_sticker, + mask_position=mask_position, + tgs_sticker=tgs_sticker, + webm_sticker=webm_sticker, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def answer_callback_query( + self, + callback_query_id: str, + text: str = None, + show_alert: bool = None, + url: str = None, + cache_time: int = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().answer_callback_query( + callback_query_id=callback_query_id, + text=text, + show_alert=show_alert, + url=url, + cache_time=cache_time, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def answer_inline_query( + self, + inline_query_id: str, + results: Union[ + Sequence["InlineQueryResult"], Callable[[int], Optional[Sequence["InlineQueryResult"]]] + ], + cache_time: int = None, + is_personal: bool = None, + next_offset: str = None, + switch_pm_text: str = None, + switch_pm_parameter: str = None, + *, + current_offset: str = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().answer_inline_query( + inline_query_id=inline_query_id, + results=results, + cache_time=cache_time, + is_personal=is_personal, + next_offset=next_offset, + switch_pm_text=switch_pm_text, + switch_pm_parameter=switch_pm_parameter, + current_offset=current_offset, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def answer_pre_checkout_query( + self, + pre_checkout_query_id: str, + ok: bool, + error_message: str = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().answer_pre_checkout_query( + pre_checkout_query_id=pre_checkout_query_id, + ok=ok, + error_message=error_message, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def answer_shipping_query( + self, + shipping_query_id: str, + ok: bool, + shipping_options: List[ShippingOption] = None, + error_message: str = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().answer_shipping_query( + shipping_query_id=shipping_query_id, + ok=ok, + shipping_options=shipping_options, + error_message=error_message, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def answer_web_app_query( + self, + web_app_query_id: str, + result: "InlineQueryResult", + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> SentWebAppMessage: + return await super().answer_web_app_query( + web_app_query_id=web_app_query_id, + result=result, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def approve_chat_join_request( + self, + chat_id: Union[str, int], + user_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().approve_chat_join_request( + chat_id=chat_id, + user_id=user_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def ban_chat_member( + self, + chat_id: Union[str, int], + user_id: Union[str, int], + until_date: Union[int, datetime] = None, + revoke_messages: bool = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().ban_chat_member( + chat_id=chat_id, + user_id=user_id, + until_date=until_date, + revoke_messages=revoke_messages, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def ban_chat_sender_chat( + self, + chat_id: Union[str, int], + sender_chat_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().ban_chat_sender_chat( + chat_id=chat_id, + sender_chat_id=sender_chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def create_chat_invite_link( + self, + chat_id: Union[str, int], + expire_date: Union[int, datetime] = None, + member_limit: int = None, + name: str = None, + creates_join_request: bool = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> ChatInviteLink: + return await super().create_chat_invite_link( + chat_id=chat_id, + expire_date=expire_date, + member_limit=member_limit, + name=name, + creates_join_request=creates_join_request, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def create_invoice_link( + self, + title: str, + description: str, + payload: str, + provider_token: str, + currency: str, + prices: List["LabeledPrice"], + max_tip_amount: int = None, + suggested_tip_amounts: List[int] = None, + provider_data: Union[str, object] = None, + photo_url: str = None, + photo_size: int = None, + photo_width: int = None, + photo_height: int = None, + need_name: bool = None, + need_phone_number: bool = None, + need_email: bool = None, + need_shipping_address: bool = None, + send_phone_number_to_provider: bool = None, + send_email_to_provider: bool = None, + is_flexible: bool = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> str: + return await super().create_invoice_link( + title=title, + description=description, + payload=payload, + provider_token=provider_token, + currency=currency, + prices=prices, + max_tip_amount=max_tip_amount, + suggested_tip_amounts=suggested_tip_amounts, + provider_data=provider_data, + photo_url=photo_url, + photo_size=photo_size, + photo_width=photo_width, + photo_height=photo_height, + need_name=need_name, + need_phone_number=need_phone_number, + need_email=need_email, + need_shipping_address=need_shipping_address, + send_phone_number_to_provider=send_phone_number_to_provider, + send_email_to_provider=send_email_to_provider, + is_flexible=is_flexible, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def create_new_sticker_set( + self, + user_id: Union[str, int], + name: str, + title: str, + emojis: str, + png_sticker: FileInput = None, + mask_position: MaskPosition = None, + tgs_sticker: FileInput = None, + webm_sticker: FileInput = None, + sticker_type: str = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = 20, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().create_new_sticker_set( + user_id=user_id, + name=name, + title=title, + emojis=emojis, + png_sticker=png_sticker, + mask_position=mask_position, + tgs_sticker=tgs_sticker, + webm_sticker=webm_sticker, + sticker_type=sticker_type, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def decline_chat_join_request( + self, + chat_id: Union[str, int], + user_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().decline_chat_join_request( + chat_id=chat_id, + user_id=user_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def delete_chat_photo( + self, + chat_id: Union[str, int], + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().delete_chat_photo( + chat_id=chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def delete_chat_sticker_set( + self, + chat_id: Union[str, int], + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().delete_chat_sticker_set( + chat_id=chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def delete_message( + self, + chat_id: Union[str, int], + message_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().delete_message( + chat_id=chat_id, + message_id=message_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def delete_my_commands( + self, + scope: BotCommandScope = None, + language_code: str = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().delete_my_commands( + scope=scope, + language_code=language_code, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def delete_sticker_from_set( + self, + sticker: str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().delete_sticker_from_set( + sticker=sticker, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def delete_webhook( + self, + drop_pending_updates: bool = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().delete_webhook( + drop_pending_updates=drop_pending_updates, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def edit_chat_invite_link( + self, + chat_id: Union[str, int], + invite_link: Union[str, "ChatInviteLink"], + expire_date: Union[int, datetime] = None, + member_limit: int = None, + name: str = None, + creates_join_request: bool = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> ChatInviteLink: + return await super().edit_chat_invite_link( + chat_id=chat_id, + invite_link=invite_link, + expire_date=expire_date, + member_limit=member_limit, + name=name, + creates_join_request=creates_join_request, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def edit_message_caption( + self, + chat_id: Union[str, int] = None, + message_id: int = None, + inline_message_id: str = None, + caption: str = None, + reply_markup: InlineKeyboardMarkup = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Union[List["MessageEntity"], Tuple["MessageEntity", ...]] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Union[Message, bool]: + return await super().edit_message_caption( + chat_id=chat_id, + message_id=message_id, + inline_message_id=inline_message_id, + caption=caption, + reply_markup=reply_markup, + parse_mode=parse_mode, + caption_entities=caption_entities, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def edit_message_live_location( + self, + chat_id: Union[str, int] = None, + message_id: int = None, + inline_message_id: int = None, + latitude: float = None, + longitude: float = None, + reply_markup: InlineKeyboardMarkup = None, + horizontal_accuracy: float = None, + heading: int = None, + proximity_alert_radius: int = None, + *, + location: Location = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Union[Message, bool]: + return await super().edit_message_live_location( + chat_id=chat_id, + message_id=message_id, + inline_message_id=inline_message_id, + latitude=latitude, + longitude=longitude, + reply_markup=reply_markup, + horizontal_accuracy=horizontal_accuracy, + heading=heading, + proximity_alert_radius=proximity_alert_radius, + location=location, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def edit_message_media( + self, + media: "InputMedia", + chat_id: Union[str, int] = None, + message_id: int = None, + inline_message_id: int = None, + reply_markup: InlineKeyboardMarkup = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Union[Message, bool]: + return await super().edit_message_media( + media=media, + chat_id=chat_id, + message_id=message_id, + inline_message_id=inline_message_id, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def edit_message_reply_markup( + self, + chat_id: Union[str, int] = None, + message_id: int = None, + inline_message_id: int = None, + reply_markup: Optional["InlineKeyboardMarkup"] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Union[Message, bool]: + return await super().edit_message_reply_markup( + chat_id=chat_id, + message_id=message_id, + inline_message_id=inline_message_id, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def edit_message_text( + self, + text: str, + chat_id: Union[str, int] = None, + message_id: int = None, + inline_message_id: int = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, + reply_markup: InlineKeyboardMarkup = None, + entities: Union[List["MessageEntity"], Tuple["MessageEntity", ...]] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Union[Message, bool]: + return await super().edit_message_text( + text=text, + chat_id=chat_id, + message_id=message_id, + inline_message_id=inline_message_id, + parse_mode=parse_mode, + disable_web_page_preview=disable_web_page_preview, + reply_markup=reply_markup, + entities=entities, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def export_chat_invite_link( + self, + chat_id: Union[str, int], + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> str: + return await super().export_chat_invite_link( + chat_id=chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def forward_message( + self, + chat_id: Union[int, str], + from_chat_id: Union[str, int], + message_id: int, + disable_notification: DVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Message: + return await super().forward_message( + chat_id=chat_id, + from_chat_id=from_chat_id, + message_id=message_id, + disable_notification=disable_notification, + protect_content=protect_content, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_chat_administrators( + self, + chat_id: Union[str, int], + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> List[ChatMember]: + return await super().get_chat_administrators( + chat_id=chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_chat_member( + self, + chat_id: Union[str, int], + user_id: Union[str, int], + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> ChatMember: + return await super().get_chat_member( + chat_id=chat_id, + user_id=user_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_chat_member_count( + self, + chat_id: Union[str, int], + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> int: + return await super().get_chat_member_count( + chat_id=chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_chat_menu_button( + self, + chat_id: int = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> MenuButton: + return await super().get_chat_menu_button( + chat_id=chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_file( + self, + file_id: Union[ + str, Animation, Audio, ChatPhoto, Document, PhotoSize, Sticker, Video, VideoNote, Voice + ], + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> File: + return await super().get_file( + file_id=file_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_game_high_scores( + self, + user_id: Union[int, str], + chat_id: Union[str, int] = None, + message_id: int = None, + inline_message_id: int = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> List[GameHighScore]: + return await super().get_game_high_scores( + user_id=user_id, + chat_id=chat_id, + message_id=message_id, + inline_message_id=inline_message_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_me( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> User: + return await super().get_me( + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_my_commands( + self, + scope: BotCommandScope = None, + language_code: str = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> List[BotCommand]: + return await super().get_my_commands( + scope=scope, + language_code=language_code, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_my_default_administrator_rights( + self, + for_channels: bool = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> ChatAdministratorRights: + return await super().get_my_default_administrator_rights( + for_channels=for_channels, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_sticker_set( + self, + name: str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> StickerSet: + return await super().get_sticker_set( + name=name, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_custom_emoji_stickers( + self, + custom_emoji_ids: List[str], + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> List[Sticker]: + return await super().get_custom_emoji_stickers( + custom_emoji_ids=custom_emoji_ids, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_user_profile_photos( + self, + user_id: Union[str, int], + offset: int = None, + limit: int = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> UserProfilePhotos: + return await super().get_user_profile_photos( + user_id=user_id, + offset=offset, + limit=limit, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_webhook_info( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> WebhookInfo: + return await super().get_webhook_info( + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def leave_chat( + self, + chat_id: Union[str, int], + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().leave_chat( + chat_id=chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def log_out( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().log_out( + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def close( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().close( + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def pin_chat_message( + self, + chat_id: Union[str, int], + message_id: int, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().pin_chat_message( + chat_id=chat_id, + message_id=message_id, + disable_notification=disable_notification, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def promote_chat_member( + self, + chat_id: Union[str, int], + user_id: Union[str, int], + can_change_info: bool = None, + can_post_messages: bool = None, + can_edit_messages: bool = None, + can_delete_messages: bool = None, + can_invite_users: bool = None, + can_restrict_members: bool = None, + can_pin_messages: bool = None, + can_promote_members: bool = None, + is_anonymous: bool = None, + can_manage_chat: bool = None, + can_manage_video_chats: bool = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().promote_chat_member( + chat_id=chat_id, + user_id=user_id, + can_change_info=can_change_info, + can_post_messages=can_post_messages, + can_edit_messages=can_edit_messages, + can_delete_messages=can_delete_messages, + can_invite_users=can_invite_users, + can_restrict_members=can_restrict_members, + can_pin_messages=can_pin_messages, + can_promote_members=can_promote_members, + is_anonymous=is_anonymous, + can_manage_chat=can_manage_chat, + can_manage_video_chats=can_manage_video_chats, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def restrict_chat_member( + self, + chat_id: Union[str, int], + user_id: Union[str, int], + permissions: ChatPermissions, + until_date: Union[int, datetime] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().restrict_chat_member( + chat_id=chat_id, + user_id=user_id, + permissions=permissions, + until_date=until_date, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def revoke_chat_invite_link( + self, + chat_id: Union[str, int], + invite_link: Union[str, "ChatInviteLink"], + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> ChatInviteLink: + return await super().revoke_chat_invite_link( + chat_id=chat_id, + invite_link=invite_link, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def send_animation( + self, + chat_id: Union[int, str], + animation: Union[FileInput, "Animation"], + duration: int = None, + width: int = None, + height: int = None, + thumb: FileInput = None, + caption: str = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + disable_notification: DVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int = None, + reply_markup: ReplyMarkup = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + caption_entities: Union[List["MessageEntity"], Tuple["MessageEntity", ...]] = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + filename: str = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = 20, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Message: + return await super().send_animation( + chat_id=chat_id, + animation=animation, + duration=duration, + width=width, + height=height, + thumb=thumb, + caption=caption, + parse_mode=parse_mode, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_markup=reply_markup, + allow_sending_without_reply=allow_sending_without_reply, + caption_entities=caption_entities, + protect_content=protect_content, + filename=filename, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def send_audio( + self, + chat_id: Union[int, str], + audio: Union[FileInput, "Audio"], + duration: int = None, + performer: str = None, + title: str = None, + caption: str = None, + disable_notification: DVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int = None, + reply_markup: ReplyMarkup = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + thumb: FileInput = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + caption_entities: Union[List["MessageEntity"], Tuple["MessageEntity", ...]] = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + filename: str = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = 20, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Message: + return await super().send_audio( + chat_id=chat_id, + audio=audio, + duration=duration, + performer=performer, + title=title, + caption=caption, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_markup=reply_markup, + parse_mode=parse_mode, + thumb=thumb, + allow_sending_without_reply=allow_sending_without_reply, + caption_entities=caption_entities, + protect_content=protect_content, + filename=filename, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def send_chat_action( + self, + chat_id: Union[str, int], + action: str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().send_chat_action( + chat_id=chat_id, + action=action, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def send_contact( + self, + chat_id: Union[int, str], + phone_number: str = None, + first_name: str = None, + last_name: str = None, + disable_notification: DVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int = None, + reply_markup: ReplyMarkup = None, + vcard: str = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + contact: Contact = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Message: + return await super().send_contact( + chat_id=chat_id, + phone_number=phone_number, + first_name=first_name, + last_name=last_name, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_markup=reply_markup, + vcard=vcard, + allow_sending_without_reply=allow_sending_without_reply, + protect_content=protect_content, + contact=contact, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def send_dice( + self, + chat_id: Union[int, str], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int = None, + reply_markup: ReplyMarkup = None, + emoji: str = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Message: + return await super().send_dice( + chat_id=chat_id, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_markup=reply_markup, + emoji=emoji, + allow_sending_without_reply=allow_sending_without_reply, + protect_content=protect_content, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def send_document( + self, + chat_id: Union[int, str], + document: Union[FileInput, "Document"], + caption: str = None, + disable_notification: DVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int = None, + reply_markup: ReplyMarkup = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + thumb: FileInput = None, + disable_content_type_detection: bool = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + caption_entities: Union[List["MessageEntity"], Tuple["MessageEntity", ...]] = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + filename: str = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = 20, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Message: + return await super().send_document( + chat_id=chat_id, + document=document, + caption=caption, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_markup=reply_markup, + parse_mode=parse_mode, + thumb=thumb, + disable_content_type_detection=disable_content_type_detection, + allow_sending_without_reply=allow_sending_without_reply, + caption_entities=caption_entities, + protect_content=protect_content, + filename=filename, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def send_game( + self, + chat_id: Union[int, str], + game_short_name: str, + disable_notification: DVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int = None, + reply_markup: InlineKeyboardMarkup = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Message: + return await super().send_game( + chat_id=chat_id, + game_short_name=game_short_name, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_markup=reply_markup, + allow_sending_without_reply=allow_sending_without_reply, + protect_content=protect_content, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def send_invoice( + self, + chat_id: Union[int, str], + title: str, + description: str, + payload: str, + provider_token: str, + currency: str, + prices: List["LabeledPrice"], + start_parameter: str = None, + photo_url: str = None, + photo_size: int = None, + photo_width: int = None, + photo_height: int = None, + need_name: bool = None, + need_phone_number: bool = None, + need_email: bool = None, + need_shipping_address: bool = None, + is_flexible: bool = None, + disable_notification: DVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int = None, + reply_markup: InlineKeyboardMarkup = None, + provider_data: Union[str, object] = None, + send_phone_number_to_provider: bool = None, + send_email_to_provider: bool = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + max_tip_amount: int = None, + suggested_tip_amounts: List[int] = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Message: + return await super().send_invoice( + chat_id=chat_id, + title=title, + description=description, + payload=payload, + provider_token=provider_token, + currency=currency, + prices=prices, + start_parameter=start_parameter, + photo_url=photo_url, + photo_size=photo_size, + photo_width=photo_width, + photo_height=photo_height, + need_name=need_name, + need_phone_number=need_phone_number, + need_email=need_email, + need_shipping_address=need_shipping_address, + is_flexible=is_flexible, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_markup=reply_markup, + provider_data=provider_data, + send_phone_number_to_provider=send_phone_number_to_provider, + send_email_to_provider=send_email_to_provider, + allow_sending_without_reply=allow_sending_without_reply, + max_tip_amount=max_tip_amount, + suggested_tip_amounts=suggested_tip_amounts, + protect_content=protect_content, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def send_location( + self, + chat_id: Union[int, str], + latitude: float = None, + longitude: float = None, + disable_notification: DVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int = None, + reply_markup: ReplyMarkup = None, + live_period: int = None, + horizontal_accuracy: float = None, + heading: int = None, + proximity_alert_radius: int = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + location: Location = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Message: + return await super().send_location( + chat_id=chat_id, + latitude=latitude, + longitude=longitude, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_markup=reply_markup, + live_period=live_period, + horizontal_accuracy=horizontal_accuracy, + heading=heading, + proximity_alert_radius=proximity_alert_radius, + allow_sending_without_reply=allow_sending_without_reply, + protect_content=protect_content, + location=location, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def send_media_group( + self, + chat_id: Union[int, str], + media: List[ + Union["InputMediaAudio", "InputMediaDocument", "InputMediaPhoto", "InputMediaVideo"] + ], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = 20, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> List[Message]: + return await super().send_media_group( + chat_id=chat_id, + media=media, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + allow_sending_without_reply=allow_sending_without_reply, + protect_content=protect_content, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def send_message( + self, + chat_id: Union[int, str], + text: str, + parse_mode: ODVInput[str] = DEFAULT_NONE, + entities: Union[List["MessageEntity"], Tuple["MessageEntity", ...]] = None, + disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, + disable_notification: DVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_markup: ReplyMarkup = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Message: + return await super().send_message( + chat_id=chat_id, + text=text, + parse_mode=parse_mode, + entities=entities, + disable_web_page_preview=disable_web_page_preview, + disable_notification=disable_notification, + protect_content=protect_content, + reply_to_message_id=reply_to_message_id, + allow_sending_without_reply=allow_sending_without_reply, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def send_photo( + self, + chat_id: Union[int, str], + photo: Union[FileInput, "PhotoSize"], + caption: str = None, + disable_notification: DVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int = None, + reply_markup: ReplyMarkup = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + caption_entities: Union[List["MessageEntity"], Tuple["MessageEntity", ...]] = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + filename: str = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = 20, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Message: + return await super().send_photo( + chat_id=chat_id, + photo=photo, + caption=caption, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_markup=reply_markup, + parse_mode=parse_mode, + allow_sending_without_reply=allow_sending_without_reply, + caption_entities=caption_entities, + protect_content=protect_content, + filename=filename, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def send_poll( + self, + chat_id: Union[int, str], + question: str, + options: List[str], + is_anonymous: bool = None, + type: str = None, # pylint: disable=redefined-builtin + allows_multiple_answers: bool = None, + correct_option_id: int = None, + is_closed: bool = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int = None, + reply_markup: ReplyMarkup = None, + explanation: str = None, + explanation_parse_mode: ODVInput[str] = DEFAULT_NONE, + open_period: int = None, + close_date: Union[int, datetime] = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + explanation_entities: Union[List["MessageEntity"], Tuple["MessageEntity", ...]] = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Message: + return await super().send_poll( + chat_id=chat_id, + question=question, + options=options, + is_anonymous=is_anonymous, + type=type, + allows_multiple_answers=allows_multiple_answers, + correct_option_id=correct_option_id, + is_closed=is_closed, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_markup=reply_markup, + explanation=explanation, + explanation_parse_mode=explanation_parse_mode, + open_period=open_period, + close_date=close_date, + allow_sending_without_reply=allow_sending_without_reply, + explanation_entities=explanation_entities, + protect_content=protect_content, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def send_sticker( + self, + chat_id: Union[int, str], + sticker: Union[FileInput, "Sticker"], + disable_notification: DVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int = None, + reply_markup: ReplyMarkup = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = 20, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Message: + return await super().send_sticker( + chat_id=chat_id, + sticker=sticker, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_markup=reply_markup, + allow_sending_without_reply=allow_sending_without_reply, + protect_content=protect_content, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def send_venue( + self, + chat_id: Union[int, str], + latitude: float = None, + longitude: float = None, + title: str = None, + address: str = None, + foursquare_id: str = None, + disable_notification: DVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int = None, + reply_markup: ReplyMarkup = None, + foursquare_type: str = None, + google_place_id: str = None, + google_place_type: str = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + venue: Venue = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Message: + return await super().send_venue( + chat_id=chat_id, + latitude=latitude, + longitude=longitude, + title=title, + address=address, + foursquare_id=foursquare_id, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_markup=reply_markup, + foursquare_type=foursquare_type, + google_place_id=google_place_id, + google_place_type=google_place_type, + allow_sending_without_reply=allow_sending_without_reply, + protect_content=protect_content, + venue=venue, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def send_video( + self, + chat_id: Union[int, str], + video: Union[FileInput, "Video"], + duration: int = None, + caption: str = None, + disable_notification: DVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int = None, + reply_markup: ReplyMarkup = None, + width: int = None, + height: int = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + supports_streaming: bool = None, + thumb: FileInput = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + caption_entities: Union[List["MessageEntity"], Tuple["MessageEntity", ...]] = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + filename: str = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = 20, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Message: + return await super().send_video( + chat_id=chat_id, + video=video, + duration=duration, + caption=caption, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_markup=reply_markup, + width=width, + height=height, + parse_mode=parse_mode, + supports_streaming=supports_streaming, + thumb=thumb, + allow_sending_without_reply=allow_sending_without_reply, + caption_entities=caption_entities, + protect_content=protect_content, + filename=filename, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def send_video_note( + self, + chat_id: Union[int, str], + video_note: Union[FileInput, "VideoNote"], + duration: int = None, + length: int = None, + disable_notification: DVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int = None, + reply_markup: ReplyMarkup = None, + thumb: FileInput = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + filename: str = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = 20, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Message: + return await super().send_video_note( + chat_id=chat_id, + video_note=video_note, + duration=duration, + length=length, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_markup=reply_markup, + thumb=thumb, + allow_sending_without_reply=allow_sending_without_reply, + protect_content=protect_content, + filename=filename, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def send_voice( + self, + chat_id: Union[int, str], + voice: Union[FileInput, "Voice"], + duration: int = None, + caption: str = None, + disable_notification: DVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int = None, + reply_markup: ReplyMarkup = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + caption_entities: Union[List["MessageEntity"], Tuple["MessageEntity", ...]] = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + filename: str = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = 20, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Message: + return await super().send_voice( + chat_id=chat_id, + voice=voice, + duration=duration, + caption=caption, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_markup=reply_markup, + parse_mode=parse_mode, + allow_sending_without_reply=allow_sending_without_reply, + caption_entities=caption_entities, + protect_content=protect_content, + filename=filename, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def set_chat_administrator_custom_title( + self, + chat_id: Union[int, str], + user_id: Union[int, str], + custom_title: str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().set_chat_administrator_custom_title( + chat_id=chat_id, + user_id=user_id, + custom_title=custom_title, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def set_chat_description( + self, + chat_id: Union[str, int], + description: str = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().set_chat_description( + chat_id=chat_id, + description=description, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def set_chat_menu_button( + self, + chat_id: int = None, + menu_button: MenuButton = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().set_chat_menu_button( + chat_id=chat_id, + menu_button=menu_button, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def set_chat_permissions( + self, + chat_id: Union[str, int], + permissions: ChatPermissions, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().set_chat_permissions( + chat_id=chat_id, + permissions=permissions, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def set_chat_photo( + self, + chat_id: Union[str, int], + photo: FileInput, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = 20, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().set_chat_photo( + chat_id=chat_id, + photo=photo, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def set_chat_sticker_set( + self, + chat_id: Union[str, int], + sticker_set_name: str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().set_chat_sticker_set( + chat_id=chat_id, + sticker_set_name=sticker_set_name, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def set_chat_title( + self, + chat_id: Union[str, int], + title: str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().set_chat_title( + chat_id=chat_id, + title=title, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def set_game_score( + self, + user_id: Union[int, str], + score: int, + chat_id: Union[str, int] = None, + message_id: int = None, + inline_message_id: int = None, + force: bool = None, + disable_edit_message: bool = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Union[Message, bool]: + return await super().set_game_score( + user_id=user_id, + score=score, + chat_id=chat_id, + message_id=message_id, + inline_message_id=inline_message_id, + force=force, + disable_edit_message=disable_edit_message, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def set_my_commands( + self, + commands: List[Union[BotCommand, Tuple[str, str]]], + scope: BotCommandScope = None, + language_code: str = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().set_my_commands( + commands=commands, + scope=scope, + language_code=language_code, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def set_my_default_administrator_rights( + self, + rights: ChatAdministratorRights = None, + for_channels: bool = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().set_my_default_administrator_rights( + rights=rights, + for_channels=for_channels, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def set_passport_data_errors( + self, + user_id: Union[str, int], + errors: List[PassportElementError], + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().set_passport_data_errors( + user_id=user_id, + errors=errors, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def set_sticker_position_in_set( + self, + sticker: str, + position: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().set_sticker_position_in_set( + sticker=sticker, + position=position, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def set_sticker_set_thumb( + self, + name: str, + user_id: Union[str, int], + thumb: FileInput = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().set_sticker_set_thumb( + name=name, + user_id=user_id, + thumb=thumb, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def set_webhook( + self, + url: str, + certificate: FileInput = None, + max_connections: int = None, + allowed_updates: List[str] = None, + ip_address: str = None, + drop_pending_updates: bool = None, + secret_token: str = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().set_webhook( + url=url, + certificate=certificate, + max_connections=max_connections, + allowed_updates=allowed_updates, + ip_address=ip_address, + drop_pending_updates=drop_pending_updates, + secret_token=secret_token, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def stop_message_live_location( + self, + chat_id: Union[str, int] = None, + message_id: int = None, + inline_message_id: int = None, + reply_markup: InlineKeyboardMarkup = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> Union[Message, bool]: + return await super().stop_message_live_location( + chat_id=chat_id, + message_id=message_id, + inline_message_id=inline_message_id, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def unban_chat_member( + self, + chat_id: Union[str, int], + user_id: Union[str, int], + only_if_banned: bool = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().unban_chat_member( + chat_id=chat_id, + user_id=user_id, + only_if_banned=only_if_banned, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def unban_chat_sender_chat( + self, + chat_id: Union[str, int], + sender_chat_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().unban_chat_sender_chat( + chat_id=chat_id, + sender_chat_id=sender_chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def unpin_all_chat_messages( + self, + chat_id: Union[str, int], + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().unpin_all_chat_messages( + chat_id=chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def unpin_chat_message( + self, + chat_id: Union[str, int], + message_id: int = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> bool: + return await super().unpin_chat_message( + chat_id=chat_id, + message_id=message_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def upload_sticker_file( + self, + user_id: Union[str, int], + png_sticker: FileInput, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = 20, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + rate_limit_args: RLARGS = None, + ) -> File: + return await super().upload_sticker_file( + user_id=user_id, + png_sticker=png_sticker, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + # updated camelCase aliases - getChat = get_chat - """Alias for :meth:`get_chat`""" - copyMessage = copy_message - """Alias for :meth:`copy_message`""" + getMe = get_me + sendMessage = send_message + deleteMessage = delete_message + forwardMessage = forward_message + sendPhoto = send_photo + sendAudio = send_audio + sendDocument = send_document + sendSticker = send_sticker + sendVideo = send_video + sendAnimation = send_animation + sendVoice = send_voice + sendVideoNote = send_video_note + sendMediaGroup = send_media_group + sendLocation = send_location + editMessageLiveLocation = edit_message_live_location + stopMessageLiveLocation = stop_message_live_location + sendVenue = send_venue + sendContact = send_contact + sendGame = send_game + sendChatAction = send_chat_action + answerInlineQuery = answer_inline_query + getUserProfilePhotos = get_user_profile_photos + getFile = get_file + banChatMember = ban_chat_member + banChatSenderChat = ban_chat_sender_chat + unbanChatMember = unban_chat_member + unbanChatSenderChat = unban_chat_sender_chat + answerCallbackQuery = answer_callback_query + editMessageText = edit_message_text + editMessageCaption = edit_message_caption + editMessageMedia = edit_message_media + editMessageReplyMarkup = edit_message_reply_markup getUpdates = get_updates - """Alias for :meth:`get_updates`""" + setWebhook = set_webhook + deleteWebhook = delete_webhook + leaveChat = leave_chat + getChat = get_chat + getChatAdministrators = get_chat_administrators + getChatMember = get_chat_member + setChatStickerSet = set_chat_sticker_set + deleteChatStickerSet = delete_chat_sticker_set + getChatMemberCount = get_chat_member_count + getWebhookInfo = get_webhook_info + setGameScore = set_game_score + getGameHighScores = get_game_high_scores + sendInvoice = send_invoice + answerShippingQuery = answer_shipping_query + answerPreCheckoutQuery = answer_pre_checkout_query + answerWebAppQuery = answer_web_app_query + restrictChatMember = restrict_chat_member + promoteChatMember = promote_chat_member + setChatPermissions = set_chat_permissions + setChatAdministratorCustomTitle = set_chat_administrator_custom_title + exportChatInviteLink = export_chat_invite_link + createChatInviteLink = create_chat_invite_link + editChatInviteLink = edit_chat_invite_link + revokeChatInviteLink = revoke_chat_invite_link + approveChatJoinRequest = approve_chat_join_request + declineChatJoinRequest = decline_chat_join_request + setChatPhoto = set_chat_photo + deleteChatPhoto = delete_chat_photo + setChatTitle = set_chat_title + setChatDescription = set_chat_description + pinChatMessage = pin_chat_message + unpinChatMessage = unpin_chat_message + unpinAllChatMessages = unpin_all_chat_messages + getStickerSet = get_sticker_set + getCustomEmojiStickers = get_custom_emoji_stickers + uploadStickerFile = upload_sticker_file + createNewStickerSet = create_new_sticker_set + addStickerToSet = add_sticker_to_set + setStickerPositionInSet = set_sticker_position_in_set + deleteStickerFromSet = delete_sticker_from_set + setStickerSetThumb = set_sticker_set_thumb + setPassportDataErrors = set_passport_data_errors + sendPoll = send_poll stopPoll = stop_poll - """Alias for :meth:`stop_poll`""" + sendDice = send_dice + getMyCommands = get_my_commands + setMyCommands = set_my_commands + deleteMyCommands = delete_my_commands + logOut = log_out + copyMessage = copy_message + getChatMenuButton = get_chat_menu_button + setChatMenuButton = set_chat_menu_button + getMyDefaultAdministratorRights = get_my_default_administrator_rights + setMyDefaultAdministratorRights = set_my_default_administrator_rights + createInvoiceLink = create_invoice_link diff --git a/telegram/ext/_utils/types.py b/telegram/ext/_utils/types.py index bd9cc3cc6..e5b4a5653 100644 --- a/telegram/ext/_utils/types.py +++ b/telegram/ext/_utils/types.py @@ -39,8 +39,10 @@ from typing import ( ) if TYPE_CHECKING: + from typing import Optional + from telegram import Bot - from telegram.ext import CallbackContext, JobQueue + from telegram.ext import BaseRateLimiter, CallbackContext, JobQueue CCT = TypeVar("CCT", bound="CallbackContext") """An instance of :class:`telegram.ext.CallbackContext` or a custom subclass. @@ -101,3 +103,13 @@ JQ = TypeVar("JQ", bound=Union[None, "JobQueue"]) """Type of the job queue. .. versionadded:: 20.0""" + +RL = TypeVar("RL", bound="Optional[BaseRateLimiter]") +"""Type of the rate limiter. + +.. versionadded:: 20.0""" + +RLARGS = TypeVar("RLARGS") +"""Type of the rate limiter arguments. + +.. versionadded:: 20.0""" diff --git a/tests/conftest.py b/tests/conftest.py index b2efc36be..2515d6a58 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -558,6 +558,7 @@ async def check_shortcut_call( bot: The bot bot_method_name: The bot methods name, e.g. `'send_message'` skip_params: Parameters that are allowed to be missing, e.g. `['inline_message_id']` + `rate_limit_args` will be skipped by default shortcut_kwargs: The kwargs passed by the shortcut directly, e.g. ``chat_id`` Returns: @@ -565,8 +566,13 @@ async def check_shortcut_call( """ if not skip_params: skip_params = set() + else: + skip_params = set(skip_params) + skip_params.add("rate_limit_args") if not shortcut_kwargs: shortcut_kwargs = set() + else: + shortcut_kwargs = set(shortcut_kwargs) orig_bot_method = getattr(bot, bot_method_name) bot_signature = inspect.signature(orig_bot_method) diff --git a/tests/test_applicationbuilder.py b/tests/test_applicationbuilder.py index 6502c4707..5d48e7805 100644 --- a/tests/test_applicationbuilder.py +++ b/tests/test_applicationbuilder.py @@ -23,6 +23,7 @@ import httpx import pytest from telegram.ext import ( + AIORateLimiter, Application, ApplicationBuilder, ContextTypes, @@ -82,6 +83,7 @@ class TestApplicationBuilder: assert app.bot.private_key is None assert app.bot.arbitrary_callback_data is False assert app.bot.defaults is None + assert app.bot.rate_limiter is None get_updates_client = app.bot._request[0]._client assert get_updates_client.limits == httpx.Limits( @@ -196,6 +198,7 @@ class TestApplicationBuilder: "proxy_url", "bot", "update_queue", + "rate_limiter", ] + [entry[0] for entry in _BOT_CHECKS], ) @@ -247,10 +250,13 @@ class TestApplicationBuilder: defaults = Defaults() request = HTTPXRequest() get_updates_request = HTTPXRequest() + rate_limiter = AIORateLimiter() builder.token(bot.token).base_url("base_url").base_file_url("base_file_url").private_key( PRIVATE_KEY ).defaults(defaults).arbitrary_callback_data(42).request(request).get_updates_request( get_updates_request + ).rate_limiter( + rate_limiter ) built_bot = builder.build().bot @@ -266,6 +272,7 @@ class TestApplicationBuilder: assert built_bot._request[0] is get_updates_request assert built_bot.callback_data_cache.maxsize == 42 assert built_bot.private_key + assert built_bot.rate_limiter is rate_limiter @dataclass class Client: diff --git a/tests/test_bot.py b/tests/test_bot.py index a0b585373..4ee91e2c9 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -481,9 +481,9 @@ class TestBot: corresponding methods of tg.Bot. """ # Some methods of ext.ExtBot - global_extra_args = set() + global_extra_args = {"rate_limit_args"} extra_args_per_method = defaultdict( - set, {"__init__": {"arbitrary_callback_data", "defaults"}} + set, {"__init__": {"arbitrary_callback_data", "defaults", "rate_limiter"}} ) different_hints_per_method = defaultdict(set, {"__setattr__": {"ext_bot"}}) @@ -2948,3 +2948,8 @@ class TestBot: assert ( "api_kwargs" in param_names ), f"{bot_method_name} is missing the parameter `api_kwargs`" + + if bot_class is ExtBot and bot_method_name.replace("_", "").lower() != "getupdates": + assert ( + "rate_limit_args" in param_names + ), f"{bot_method_name} of ExtBot is missing the parameter `rate_limit_args`" diff --git a/tests/test_datetime.py b/tests/test_datetime.py index 8a285c0e7..38a820102 100644 --- a/tests/test_datetime.py +++ b/tests/test_datetime.py @@ -48,16 +48,16 @@ Because imports in pytest are intricate, we just run pytest -k test_helpers.py -with the TEST_NO_PYTZ environment variable set in addition to the regular test suite. +with the TEST_PYTZ environment variable set to False in addition to the regular test suite. Because actually uninstalling pytz would lead to errors in the test suite we just mock the import to raise the expected exception. Note that a fixture that just does this for every test that needs it is a nice idea, but for some reason makes test_updater.py hang indefinitely on GitHub Actions (at least when Hinrich tried that) """ -TEST_NO_PYTZ = env_var_2_bool(os.getenv("TEST_NO_PYTZ", False)) +TEST_PYTZ = env_var_2_bool(os.getenv("TEST_PYTZ", True)) -if TEST_NO_PYTZ: +if not TEST_PYTZ: orig_import = __import__ def import_mock(module_name, *args, **kwargs): @@ -72,7 +72,7 @@ if TEST_NO_PYTZ: class TestDatetime: def test_helpers_utc(self): # Here we just test, that we got the correct UTC variant - if TEST_NO_PYTZ: + if not TEST_PYTZ: assert tg_dtm.UTC is tg_dtm.DTM_UTC else: assert tg_dtm.UTC is not tg_dtm.DTM_UTC diff --git a/tests/test_no_passport.py b/tests/test_no_passport.py index 80852b05c..1924ad3d7 100644 --- a/tests/test_no_passport.py +++ b/tests/test_no_passport.py @@ -24,7 +24,7 @@ Currently this only means that cryptography is not installed. Because imports in pytest are intricate, we just run pytest -k test_no_passport.py -with the TEST_NO_PASSPORT environment variable set in addition to the regular test suite. +with the TEST_PASSPORT environment variable set to False in addition to the regular test suite. Because actually uninstalling the optional dependencies would lead to errors in the test suite we just mock the import to raise the expected exception. @@ -41,9 +41,9 @@ from telegram import _bot as bot from telegram._passport import credentials as credentials from tests.conftest import env_var_2_bool -TEST_NO_PASSPORT = env_var_2_bool(os.getenv("TEST_NO_PASSPORT", False)) +TEST_PASSPORT = env_var_2_bool(os.getenv("TEST_PASSPORT", True)) -if TEST_NO_PASSPORT: +if not TEST_PASSPORT: orig_import = __import__ def import_mock(module_name, *args, **kwargs): @@ -58,24 +58,24 @@ if TEST_NO_PASSPORT: class TestNoPassport: """ - The monkeypatches simulate cryptography not being installed even when TEST_NO_PASSPORT is - False, though that doesn't test the actual imports + The monkeypatches simulate cryptography not being installed even when TEST_PASSPORT is + True, though that doesn't test the actual imports """ def test_bot_init(self, bot_info, monkeypatch): - if not TEST_NO_PASSPORT: + if TEST_PASSPORT: monkeypatch.setattr(bot, "CRYPTO_INSTALLED", False) with pytest.raises(RuntimeError, match="passport"): bot.Bot(bot_info["token"], private_key=1, private_key_password=2) def test_credentials_decrypt(self, monkeypatch): - if not TEST_NO_PASSPORT: + if TEST_PASSPORT: monkeypatch.setattr(credentials, "CRYPTO_INSTALLED", False) with pytest.raises(RuntimeError, match="passport"): credentials.decrypt(1, 1, 1) def test_encrypted_credentials_decrypted_secret(self, monkeypatch): - if not TEST_NO_PASSPORT: + if TEST_PASSPORT: monkeypatch.setattr(credentials, "CRYPTO_INSTALLED", False) ec = credentials.EncryptedCredentials("data", "hash", "secret") with pytest.raises(RuntimeError, match="passport"): diff --git a/tests/test_ratelimiter.py b/tests/test_ratelimiter.py new file mode 100644 index 000000000..c62fc4bff --- /dev/null +++ b/tests/test_ratelimiter.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2022 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. + +""" +We mostly test on directly on AIORateLimiter here, b/c BaseRateLimiter doesn't contain anything +notable +""" +import asyncio +import json +import os +import platform +import time +from datetime import datetime +from http import HTTPStatus + +import pytest +from flaky import flaky + +from telegram import BotCommand, Chat, Message, User +from telegram.constants import ParseMode +from telegram.error import RetryAfter +from telegram.ext import AIORateLimiter, BaseRateLimiter, Defaults, ExtBot +from telegram.request import BaseRequest, RequestData +from tests.conftest import env_var_2_bool + +TEST_RATE_LIMITER = env_var_2_bool(os.getenv("TEST_RATE_LIMITER", True)) + + +@pytest.mark.skipif( + TEST_RATE_LIMITER, reason="Only relevant if the optional dependency is not installed" +) +class TestNoRateLimiter: + def test_init(self): + with pytest.raises(RuntimeError, match=r"python-telegram-bot\[rate-limiter\]"): + AIORateLimiter() + + +class TestBaseRateLimiter: + rl_received = None + request_received = None + + async def test_no_rate_limiter(self, bot): + with pytest.raises(ValueError, match="if a `ExtBot.rate_limiter` is set"): + await bot.send_message(chat_id=42, text="test", rate_limit_args="something") + + async def test_argument_passing(self, bot_info, monkeypatch, bot): + class TestRateLimiter(BaseRateLimiter): + async def initialize(self) -> None: + pass + + async def shutdown(self) -> None: + pass + + async def process_request( + self, + callback, + args, + kwargs, + endpoint, + data, + rate_limit_args, + ): + if TestBaseRateLimiter.rl_received is None: + TestBaseRateLimiter.rl_received = [] + TestBaseRateLimiter.rl_received.append((endpoint, data, rate_limit_args)) + return await callback(*args, **kwargs) + + class TestRequest(BaseRequest): + async def initialize(self) -> None: + pass + + async def shutdown(self) -> None: + pass + + async def do_request(self, *args, **kwargs): + if TestBaseRateLimiter.request_received is None: + TestBaseRateLimiter.request_received = [] + TestBaseRateLimiter.request_received.append((args, kwargs)) + # return bot.bot.to_dict() for the `get_me` call in `Bot.initialize` + return 200, json.dumps({"ok": True, "result": bot.bot.to_dict()}).encode() + + defaults = Defaults(parse_mode=ParseMode.HTML) + test_request = TestRequest() + standard_bot = ExtBot(token=bot.token, defaults=defaults, request=test_request) + rl_bot = ExtBot( + token=bot.token, + defaults=defaults, + request=test_request, + rate_limiter=TestRateLimiter(), + ) + + async with standard_bot: + await standard_bot.set_my_commands( + commands=[BotCommand("test", "test")], + language_code="en", + api_kwargs={"api": "kwargs"}, + ) + async with rl_bot: + await rl_bot.set_my_commands( + commands=[BotCommand("test", "test")], + language_code="en", + rate_limit_args=(43, "test-1"), + api_kwargs={"api": "kwargs"}, + ) + + assert len(self.rl_received) == 2 + assert self.rl_received[0] == ("getMe", {}, None) + assert self.rl_received[1] == ( + "setMyCommands", + dict(commands=[BotCommand("test", "test")], language_code="en", api="kwargs"), + (43, "test-1"), + ) + assert len(self.request_received) == 4 + # self.request_received[i] = i-th received request + # self.request_received[i][0] = i-th received request's args + # self.request_received[i][1] = i-th received request's kwargs + assert self.request_received[0][1]["url"].endswith("getMe") + assert self.request_received[2][1]["url"].endswith("getMe") + assert self.request_received[1][0] == self.request_received[3][0] + assert self.request_received[1][1].keys() == self.request_received[3][1].keys() + for key, value in self.request_received[1][1].items(): + if isinstance(value, RequestData): + assert value.parameters == self.request_received[3][1][key].parameters + assert value.parameters["api"] == "kwargs" + else: + assert value == self.request_received[3][1][key] + + +@pytest.mark.skipif( + not TEST_RATE_LIMITER, reason="Only relevant if the optional dependency is installed" +) +@pytest.mark.skipif( + os.getenv("GITHUB_ACTIONS", False) and platform.system() == "Darwin", + reason="The timings are apparently rather inaccurate on MacOS.", +) +@flaky(10, 1) # Timings aren't quite perfect +class TestAIORateLimiter: + count = 0 + call_times = [] + + class CountRequest(BaseRequest): + def __init__(self, retry_after=None): + self.retry_after = retry_after + + async def initialize(self) -> None: + pass + + async def shutdown(self) -> None: + pass + + async def do_request(self, *args, **kwargs): + TestAIORateLimiter.count += 1 + TestAIORateLimiter.call_times.append(time.time()) + if self.retry_after: + raise RetryAfter(retry_after=1) + + url = kwargs.get("url").lower() + if url.endswith("getme"): + return ( + HTTPStatus.OK, + json.dumps( + {"ok": True, "result": User(id=1, first_name="bot", is_bot=True).to_dict()} + ).encode(), + ) + if url.endswith("sendmessage"): + return ( + HTTPStatus.OK, + json.dumps( + { + "ok": True, + "result": Message( + message_id=1, date=datetime.now(), chat=Chat(1, "chat") + ).to_dict(), + } + ).encode(), + ) + + @pytest.fixture(autouse=True) + def reset(self): + self.count = 0 + TestAIORateLimiter.count = 0 + self.call_times = [] + TestAIORateLimiter.call_times = [] + + @pytest.mark.parametrize("max_retries", [0, 1, 4]) + async def test_max_retries(self, bot, max_retries): + + bot = ExtBot( + token=bot.token, + request=self.CountRequest(retry_after=1), + rate_limiter=AIORateLimiter( + max_retries=max_retries, overall_max_rate=0, group_max_rate=0 + ), + ) + with pytest.raises(RetryAfter): + await bot.get_me() + + # Check that we retried the request the correct number of times + assert TestAIORateLimiter.count == max_retries + 1 + + # Check that the retries were delayed correctly + times = TestAIORateLimiter.call_times + if len(times) <= 1: + return + delays = [j - i for i, j in zip(times[:-1], times[1:])] + assert delays == pytest.approx([1.1 for _ in range(max_retries)], rel=0.05) + + async def test_delay_all_pending_on_retry(self, bot): + # Makes sure that a RetryAfter blocks *all* pending requests + bot = ExtBot( + token=bot.token, + request=self.CountRequest(retry_after=1), + rate_limiter=AIORateLimiter(max_retries=1, overall_max_rate=0, group_max_rate=0), + ) + task_1 = asyncio.create_task(bot.get_me()) + await asyncio.sleep(0.1) + task_2 = asyncio.create_task(bot.get_me()) + + assert not task_1.done() + assert not task_2.done() + + await asyncio.sleep(1.1) + assert isinstance(task_1.exception(), RetryAfter) + assert not task_2.done() + + await asyncio.sleep(1.1) + assert isinstance(task_2.exception(), RetryAfter) + + @pytest.mark.parametrize("group_id", [-1, "-1", "@username"]) + @pytest.mark.parametrize("chat_id", [1, "1"]) + async def test_basic_rate_limiting(self, bot, group_id, chat_id): + try: + rl_bot = ExtBot( + token=bot.token, + request=self.CountRequest(retry_after=None), + rate_limiter=AIORateLimiter( + overall_max_rate=1, + overall_time_period=1 / 4, + group_max_rate=1, + group_time_period=1 / 2, + ), + ) + + async with rl_bot: + non_group_tasks = {} + group_tasks = {} + for i in range(4): + group_tasks[i] = asyncio.create_task( + rl_bot.send_message(chat_id=group_id, text="test") + ) + for i in range(8): + non_group_tasks[i] = asyncio.create_task( + rl_bot.send_message(chat_id=chat_id, text="test") + ) + + await asyncio.sleep(0.85) + # We expect 5 requests: + # 1: `get_me` from `async with rl_bot` + # 2: `send_message` at time 0.00 + # 3: `send_message` at time 0.25 + # 4: `send_message` at time 0.50 + # 5: `send_message` at time 0.75 + assert TestAIORateLimiter.count == 5 + assert sum(1 for task in non_group_tasks.values() if task.done()) < 8 + assert sum(1 for task in group_tasks.values() if task.done()) < 4 + + # 3 seconds after start + await asyncio.sleep(3.1 - 0.85) + assert all(task.done() for task in non_group_tasks.values()) + assert all(task.done() for task in group_tasks.values()) + + finally: + # cleanup + await asyncio.gather(*non_group_tasks.values(), *group_tasks.values()) + TestAIORateLimiter.count = 0 + TestAIORateLimiter.call_times = [] + + async def test_rate_limiting_no_chat_id(self, bot): + try: + rl_bot = ExtBot( + token=bot.token, + request=self.CountRequest(retry_after=None), + rate_limiter=AIORateLimiter( + overall_max_rate=1, + overall_time_period=1 / 2, + ), + ) + + async with rl_bot: + non_chat_tasks = {} + chat_tasks = {} + for i in range(4): + chat_tasks[i] = asyncio.create_task( + rl_bot.send_message(chat_id=-1, text="test") + ) + for i in range(8): + non_chat_tasks[i] = asyncio.create_task(rl_bot.get_me()) + + await asyncio.sleep(0.6) + # We expect 11 requests: + # 1: `get_me` from `async with rl_bot` + # 2: `send_message` at time 0.00 + # 3: `send_message` at time 0.05 + # 4: 8 times `get_me` + assert TestAIORateLimiter.count == 11 + assert sum(1 for task in non_chat_tasks.values() if task.done()) == 8 + assert sum(1 for task in chat_tasks.values() if task.done()) == 2 + + # 1.6 seconds after start + await asyncio.sleep(1.6 - 0.6) + assert all(task.done() for task in non_chat_tasks.values()) + assert all(task.done() for task in chat_tasks.values()) + finally: + # cleanup + await asyncio.gather(*non_chat_tasks.values(), *chat_tasks.values()) + TestAIORateLimiter.count = 0 + TestAIORateLimiter.call_times = [] + + @pytest.mark.parametrize("intermediate", [True, False]) + async def test_group_caching(self, bot, intermediate): + try: + max_rate = 1000 + rl_bot = ExtBot( + token=bot.token, + request=self.CountRequest(retry_after=None), + rate_limiter=AIORateLimiter( + overall_max_rate=max_rate, + overall_time_period=1, + group_max_rate=max_rate, + group_time_period=1, + ), + ) + + # Unfortunately, there is no reliable way to test this without checking the internals + assert len(rl_bot.rate_limiter._group_limiters) == 0 + await asyncio.gather( + *(rl_bot.send_message(chat_id=-(i + 1), text=f"{i}") for i in range(513)) + ) + if intermediate: + await rl_bot.send_message(chat_id=-1, text="999") + assert 1 <= len(rl_bot.rate_limiter._group_limiters) <= 513 + else: + await asyncio.sleep(1) + await rl_bot.send_message(chat_id=-1, text="999") + assert len(rl_bot.rate_limiter._group_limiters) == 1 + finally: + TestAIORateLimiter.count = 0 + TestAIORateLimiter.call_times = []