diff --git a/.github/CONTRIBUTING.rst b/.github/CONTRIBUTING.rst index b5ce5921484..63906fabd6f 100644 --- a/.github/CONTRIBUTING.rst +++ b/.github/CONTRIBUTING.rst @@ -26,7 +26,7 @@ Setting things up .. code-block:: bash - $ pip install -r requirements-all.txt + $ pip install -r requirements-dev-all.txt 5. Install pre-commit hooks: @@ -194,7 +194,7 @@ Feel free to copy (parts of) the checklist to the PR description to remind you o - Added or updated documentation for the changed class(es) and/or method(s) - Added the new method(s) to ``_extbot.py`` - Added or updated ``bot_methods.rst`` - - Updated the Bot API version number in all places: ``README.rst`` and ``README_RAW.rst`` (including the badge), as well as ``telegram.constants.BOT_API_VERSION_INFO`` + - Updated the Bot API version number in all places: ``README.rst`` (including the badge) and ``telegram.constants.BOT_API_VERSION_INFO`` - Added logic for arbitrary callback data in :class:`telegram.ext.ExtBot` for new methods that either accept a ``reply_markup`` in some form or have a return type that is/contains :class:`~telegram.Message` Documenting @@ -210,13 +210,8 @@ doc strings don't have a separate documentation site they generate, instead, the User facing documentation ------------------------- -We use `sphinx`_ to generate static HTML docs. To build them, first make sure you're running Python 3.9 or above and have the required dependencies: - -.. code-block:: bash - - $ pip install -r docs/requirements-docs.txt - -then run the following from the PTB root directory: +We use `sphinx`_ to generate static HTML docs. To build them, first make sure you're running Python 3.9 or above and have the required dependencies installed as explained above. +Then, run the following from the PTB root directory: .. code-block:: bash diff --git a/.github/workflows/docs-linkcheck.yml b/.github/workflows/docs-linkcheck.yml index 32f451c72b6..82fafe3e53f 100644 --- a/.github/workflows/docs-linkcheck.yml +++ b/.github/workflows/docs-linkcheck.yml @@ -22,6 +22,6 @@ jobs: - name: Install dependencies run: | python -W ignore -m pip install --upgrade pip - python -W ignore -m pip install -r requirements-all.txt + python -W ignore -m pip install -r requirements-dev-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 ea1173d6986..73e123e17ea 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -28,7 +28,7 @@ jobs: - name: Install dependencies run: | python -W ignore -m pip install --upgrade pip - python -W ignore -m pip install -r requirements-all.txt + python -W ignore -m pip install -r requirements-dev-all.txt - name: Test autogeneration of admonitions run: pytest -v --tb=short tests/docs/admonition_inserter.py - name: Build docs diff --git a/.github/workflows/pre-commit_dependencies_notifier.yml b/.github/workflows/pre-commit_dependencies_notifier.yml deleted file mode 100644 index 6f6428faf36..00000000000 --- a/.github/workflows/pre-commit_dependencies_notifier.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Warning maintainers -on: - pull_request_target: - paths: - - requirements.txt - - requirements-opts.txt - - .pre-commit-config.yaml -permissions: - pull-requests: write -jobs: - job: - runs-on: ubuntu-latest - name: about pre-commit and dependency change - steps: - - name: running the check - uses: Poolitzer/notifier-action@master - with: - 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/readme_notifier.yml b/.github/workflows/readme_notifier.yml deleted file mode 100644 index 4ec7d458760..00000000000 --- a/.github/workflows/readme_notifier.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Warning maintainers -on: - pull_request_target: - paths: - - README.rst - - README_RAW.rst -permissions: - pull-requests: write -jobs: - job: - runs-on: ubuntu-latest - name: about readme change - steps: - - name: running the check - uses: Poolitzer/notifier-action@master - with: - notify-message: Hey! Looks like you edited README.rst or README_RAW.rst. I'm just a friendly reminder to apply relevant changes to both of those files :) - repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release_pypi.yml b/.github/workflows/release_pypi.yml new file mode 100644 index 00000000000..bcd1794c468 --- /dev/null +++ b/.github/workflows/release_pypi.yml @@ -0,0 +1,204 @@ +name: Publish to PyPI + +on: + # Run on any tag + push: + tags: + - '**' + # manually trigger the workflow - for testing only + workflow_dispatch: + +jobs: + build: + name: Build Distribution + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Install pypa/build + run: >- + python3 -m pip install build --user + - name: Build a binary wheel and a source tarball + run: python3 -m build + - name: Store the distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + publish-to-pypi: + name: Publish to PyPI + # only publish to PyPI on tag pushes + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') + needs: + - build + runs-on: ubuntu-latest + environment: + name: release_pypi + url: https://pypi.org/p/python-telegram-bot + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + publish-to-test-pypi: + name: Publish to Test PyPI + needs: + - build + runs-on: ubuntu-latest + environment: + name: release_test_pypi + url: https://test.pypi.org/p/python-telegram-bot + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Publish to Test PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + compute-signatures: + name: Compute SHA1 Sums and Sign with Sigstore + runs-on: ubuntu-latest + needs: + - publish-to-pypi + - publish-to-test-pypi + # run if either of the publishing jobs ran successfully + # see also: + # https://github.com/actions/runner/issues/491#issuecomment-850884422 + if: | + always() && ( + (needs.publish-to-pypi.result == 'success') || + (needs.publish-to-test-pypi.result == 'success') + ) + + permissions: + id-token: write # IMPORTANT: mandatory for sigstore + + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Compute SHA1 Sums + run: | + # Compute SHA1 sum of the distribution packages and save it to a file with the same name, + # but with .sha1 extension + for file in dist/*; do + sha1sum $file > $file.sha1 + done + - name: Sign the dists with Sigstore + uses: sigstore/gh-action-sigstore-python@v2.1.1 + with: + inputs: >- + ./dist/*.tar.gz + ./dist/*.whl + - name: Store the distribution packages and signatures + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions-and-signatures + path: dist/ + + github-release: + name: Upload to GitHub Release + needs: + - publish-to-pypi + - compute-signatures + if: | + always() && ( + (needs.publish-to-pypi.result == 'success') && + (needs.compute-signatures.result == 'success') + ) + + runs-on: ubuntu-latest + + permissions: + contents: write # IMPORTANT: mandatory for making GitHub Releases + + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions-and-signatures + path: dist/ + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + # Create a GitHub Release for this tag. The description can be changed later, as for now + # we don't define it through this workflow. + run: >- + gh release create + '${{ github.ref_name }}' + --repo '${{ github.repository }}' + --generate-notes + - name: Upload artifact signatures to GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + # Upload to GitHub Release using the `gh` CLI. + # `dist/` contains the built packages, and the + # sigstore-produced signatures and certificates. + run: >- + gh release upload + '${{ github.ref_name }}' dist/** + --repo '${{ github.repository }}' + + github-test-release: + name: Upload to GitHub Release Draft + needs: + - publish-to-test-pypi + - compute-signatures + if: | + always() && ( + (needs.publish-to-test-pypi.result == 'success') && + (needs.compute-signatures.result == 'success') + ) + runs-on: ubuntu-latest + + permissions: + contents: write # IMPORTANT: mandatory for making GitHub Releases + + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions-and-signatures + path: dist/ + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + # Create a GitHub Release *draft*. The description can be changed later, as for now + # we don't define it through this workflow. + run: >- + gh release create + '${{ github.ref_name }}' + --repo '${{ github.repository }}' + --generate-notes + --draft + - name: Upload artifact signatures to GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + # Upload to GitHub Release using the `gh` CLI. + # `dist/` contains the built packages, and the + # sigstore-produced signatures and certificates. + run: >- + gh release upload + '${{ github.ref_name }}' dist/** + --repo '${{ github.repository }}' diff --git a/.github/workflows/test_official.yml b/.github/workflows/test_official.yml index 0510d334f3f..5de11471eaa 100644 --- a/.github/workflows/test_official.yml +++ b/.github/workflows/test_official.yml @@ -29,9 +29,8 @@ 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-opts.txt - python -W ignore -m pip install -r requirements-dev.txt + python -W ignore -m pip install .[all] + python -W ignore -m pip install -r requirements-unit-tests.txt - name: Compare to official api run: | pytest -v tests/test_official/test_official.py --junit-xml=.test_report_official.xml diff --git a/.github/workflows/type_completeness.yml b/.github/workflows/type_completeness.yml index b2ebd45e303..17dc249c81f 100644 --- a/.github/workflows/type_completeness.yml +++ b/.github/workflows/type_completeness.yml @@ -3,8 +3,8 @@ on: pull_request: paths: - telegram/** - - requirements.txt - - requirements-opts.txt + - pyproject.toml + - .github/workflows/type_completeness.yml push: branches: - master @@ -14,66 +14,8 @@ jobs: name: test-type-completeness runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - run: git fetch --depth=1 # https://github.com/actions/checkout/issues/329#issuecomment-674881489 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - uses: Bibo-Joshi/pyright-type-completeness@1.0.0 with: - python-version: 3.9 - cache: 'pip' - cache-dependency-path: '**/requirements*.txt' - - name: Install Pyright - run: | - python -W ignore -m pip install pyright~=1.1.316 - - name: Get PR Completeness - # Must run before base completeness, as base completeness will checkout the base branch - # And we can't go back to the PR branch after that in case the PR is coming from a fork - run: | - pip install . -U - pyright --verifytypes telegram --ignoreexternal --outputjson > pr.json || true - pyright --verifytypes telegram --ignoreexternal > pr.readable || true - - name: Get Base Completeness - run: | - git checkout ${{ github.base_ref }} - pip install . -U - pyright --verifytypes telegram --ignoreexternal --outputjson > base.json || true - - name: Compare Completeness - uses: jannekem/run-python-script-action@v1 - with: - script: | - import json - import os - from pathlib import Path - - base = float( - json.load(open("base.json", "rb"))["typeCompleteness"]["completenessScore"] - ) - pr = float( - json.load(open("pr.json", "rb"))["typeCompleteness"]["completenessScore"] - ) - base_text = f"This PR changes type completeness from {round(base, 3)} to {round(pr, 3)}." - - if base == 0: - text = f"Something is broken in the workflow. Reported type completeness is 0. 💥" - set_summary(text) - print(Path("pr.readable").read_text(encoding="utf-8")) - error(text) - exit(1) - - if pr < (base - 0.001): - text = f"{base_text} ❌" - set_summary(text) - print(Path("pr.readable").read_text(encoding="utf-8")) - error(text) - exit(1) - elif pr > (base + 0.001): - text = f"{base_text} ✨" - set_summary(text) - if pr < 1: - print(Path("pr.readable").read_text(encoding="utf-8")) - print(text) - else: - text = f"{base_text} This is less than 0.1 percentage points. ✅" - set_summary(text) - print(Path("pr.readable").read_text(encoding="utf-8")) - print(text) + package-name: telegram + python-version: 3.12 + pyright-version: ~=1.1.367 diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index fffe4573ddb..6eac67758da 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -4,9 +4,9 @@ on: paths: - telegram/** - tests/** - - requirements.txt - - requirements-opts.txt - - requirements-dev.txt + - .github/workflows/unit_tests.yml + - pyproject.toml + - requirements-unit-tests.txt push: branches: - master @@ -20,7 +20,7 @@ jobs: runs-on: ${{matrix.os}} strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13.0-beta.3'] os: [ubuntu-latest, windows-latest, macos-latest] fail-fast: False steps: @@ -35,8 +35,8 @@ jobs: run: | python -W ignore -m pip install --upgrade pip python -W ignore -m pip install -U pytest-cov - 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 . + python -W ignore -m pip install -r requirements-unit-tests.txt python -W ignore -m pip install pytest-xdist[psutil] - name: Test with pytest @@ -65,7 +65,7 @@ jobs: # Test the rest export TEST_WITH_OPT_DEPS='true' - pip install -r requirements-opts.txt + pip install .[all] # `-n auto --dist loadfile` uses pytest-xdist to run each test file on a different CPU # worker. Increasing number of workers has little effect on test duration, but it seems # to increase flakyness, specially on python 3.7 with --dist=loadgroup. diff --git a/.gitignore b/.gitignore index fa3d7aa52c9..470d2a2aac1 100644 --- a/.gitignore +++ b/.gitignore @@ -92,3 +92,6 @@ telegram.jpg # virtual env venv* + +# environment manager: +.mise.toml \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5760c9eac06..e0d933ea11c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,4 @@ -# Make sure that the additional_dependencies here match requirements(-opts).txt +# Make sure that the additional_dependencies here match pyproject.toml ci: autofix_prs: false @@ -7,7 +7,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.4.3' + rev: 'v0.5.0' hooks: - id: ruff name: ruff @@ -25,11 +25,11 @@ repos: - --diff - --check - repo: https://github.com/PyCQA/flake8 - rev: 7.0.0 + rev: 7.1.0 hooks: - id: flake8 - repo: https://github.com/PyCQA/pylint - rev: v3.1.0 + rev: v3.2.4 hooks: - id: pylint files: ^(?!(tests|docs)).*\.py$ @@ -41,7 +41,7 @@ repos: - aiolimiter~=1.1.0 - . # this basically does `pip install -e .` - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.10.0 + rev: v1.10.1 hooks: - id: mypy name: mypy-ptb @@ -68,7 +68,7 @@ repos: - cachetools~=5.3.3 - . # this basically does `pip install -e .` - repo: https://github.com/asottile/pyupgrade - rev: v3.15.2 + rev: v3.16.0 hooks: - id: pyupgrade args: diff --git a/CHANGES.rst b/CHANGES.rst index 79a6f124496..f56a61b9b0c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,58 @@ Changelog ========= +Version 21.4 +============ + +*Released 2024-07-12* + +This is the technical changelog for version 21.4. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Major Changes +------------- + +- Full Support for Bot API 7.5 (:pr:`4328`, :pr:`4316`, :pr:`4315`, :pr:`4312` closes :issue:`4310`, :pr:`4311`) +- Full Support for Bot API 7.6 (:pr:`4333` closes :issue:`4331`, :pr:`4344`, :pr:`4341`, :pr:`4334`, :pr:`4335`, :pr:`4351`, :pr:`4342`, :pr:`4348`) +- Full Support for Bot API 7.7 (:pr:`4356` closes :issue:`4355`) +- Drop ``python-telegram-bot-raw`` And Switch to ``pyproject.toml`` Based Packaging (:pr:`4288` closes :issue:`4129` and :issue:`4296`) +- Deprecate Inclusion of ``successful_payment`` in ``Message.effective_attachment`` (:pr:`4365` closes :issue:`4350`) + +New Features +------------ + +- Add Support for Python 3.13 Beta (:pr:`4253`) +- Add ``filters.PAID_MEDIA`` (:pr:`4357`) +- Log Received Data on Deserialization Errors (:pr:`4304`) +- Add ``MessageEntity.adjust_message_entities_to_utf_16`` Utility Function (:pr:`4323` by `Antares0982 `_ closes :issue:`4319`) +- Make Argument ``bot`` of ``TelegramObject.de_json`` Optional (:pr:`4320`) + +Documentation Improvements +-------------------------- + +- Documentation Improvements (:pr:`4303` closes :issue:`4301`) +- Restructure Readme (:pr:`4362`) +- Fix Link-Check Workflow (:pr:`4332`) + +Internal Changes +---------------- + +- Automate PyPI Releases (:pr:`4364` closes :issue:`4318`) +- Add ``mise-en-place`` to ``.gitignore`` (:pr:`4300`) +- Use a Composite Action for Testing Type Completeness (:pr:`4367`) +- Stabilize Some Concurrency Usages in Test Suite (:pr:`4360`) +- Add a Test Case for ``MenuButton`` (:pr:`4363`) +- Extend ``SuccessfulPayment`` Test (:pr:`4349`) +- Small Fixes for ``test_stars.py`` (:pr:`4347`) +- Use Python 3.13 Beta 3 in Test Suite (:pr:`4336`) + +Dependency Updates +------------------ + +- Bump ``ruff`` and Add New Rules (:pr:`4329`) +- Bump ``pre-commit`` Hooks to Latest Versions (:pr:`4337`) +- Add Lower Bound for ``flaky`` Dependency (:pr:`4322` by `Palaptin `_) +- Bump ``pytest`` from 8.2.1 to 8.2.2 (:pr:`4294`) + Version 21.3 ============ *Released 2024-06-07* diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 0efb3c7eca4..00000000000 --- a/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include LICENSE LICENSE.lesser requirements.txt requirements-opts.txt README_RAW.rst telegram/py.typed diff --git a/README.rst b/README.rst index c3b29aa626f..09b01d923a8 100644 --- a/README.rst +++ b/README.rst @@ -11,7 +11,7 @@ :target: https://pypi.org/project/python-telegram-bot/ :alt: Supported Python versions -.. image:: https://img.shields.io/badge/Bot%20API-7.4-blue?logo=telegram +.. image:: https://img.shields.io/badge/Bot%20API-7.7-blue?logo=telegram :target: https://core.telegram.org/bots/api-changelog :alt: Supported Bot API version @@ -66,23 +66,36 @@ We have a vibrant community of developers helping each other in our `Telegram gr *Stay tuned for library updates and new releases on our* `Telegram Channel `_. Introduction -============ +------------ This library provides a pure Python, asynchronous interface for the `Telegram Bot API `_. It's compatible with Python versions **3.8+**. -In addition to the pure API implementation, this library features a number of high-level classes to +In addition to the pure API implementation, this library features several convenience methods and shortcuts as well as a number of high-level classes to make the development of bots easy and straightforward. These classes are contained in the ``telegram.ext`` submodule. +After installing_ the library, be sure to check out the section on `working with PTB`_. + Telegram API support -==================== +~~~~~~~~~~~~~~~~~~~~ + +All types and methods of the Telegram Bot API **7.7** are natively supported by this library. +In addition, Bot API functionality not yet natively included can still be used as described `in our wiki `_. + +Notable Features +~~~~~~~~~~~~~~~~ -All types and methods of the Telegram Bot API **7.4** are supported. +- `Fully asynchronous `_ +- Convenient shortcut methods, e.g. `Message.reply_text `_ +- `Fully annotated with static type hints `_ +- `Customizable and extendable interface `_ +- Seamless integration with `webhooks `_ and `polling `_ +- `Comprehensive documentation and examples <#working-with-ptb>`_ Installing -========== +---------- You can install or upgrade ``python-telegram-bot`` via @@ -98,22 +111,27 @@ You can also install ``python-telegram-bot`` from source, though this is usually $ git clone https://github.com/python-telegram-bot/python-telegram-bot $ cd python-telegram-bot - $ python setup.py install + $ pip install build + $ python -m build Verifying Releases ------------------- +~~~~~~~~~~~~~~~~~~ + +To enable you to verify that a release file that you downloaded was indeed provided by the ``python-telegram-bot`` team, we have taken the following measures. + +Starting with v21.4, all releases are signed via `sigstore `_. +The corresponding signature files are uploaded to the `GitHub releases page`_. +To verify the signature, please install the `sigstore Python client `_ and follow the instructions for `verifying signatures from GitHub Actions `_. As input for the ``--repository`` parameter, please use the value ``python-telegram-bot/python-telegram-bot``. -We sign all the releases with a GPG key. -The signatures are uploaded to both the `GitHub releases page `_ and the `PyPI project `_ and end with a suffix ``.asc``. +Earlier releases are signed with a GPG key. +The signatures are uploaded to both the `GitHub releases page`_ and the `PyPI project `_ and end with a suffix ``.asc``. Please find the public keys `here `_. -The keys are named in the format ``-.gpg`` or ``-current.gpg`` if the key is currently being used for new releases. +The keys are named in the format ``-.gpg``. In addition, the GitHub release page also contains the sha1 hashes of the release files in the files with the suffix ``.sha1``. -This allows you to verify that a release file that you downloaded was indeed provided by the ``python-telegram-bot`` team. - Dependencies & Their Versions ------------------------------ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``python-telegram-bot`` tries to use as few 3rd party dependencies as possible. However, for some features using a 3rd party library is more sane than implementing the functionality again. @@ -149,14 +167,19 @@ Additionally, two shortcuts are provided: * ``pip install "python-telegram-bot[all]"`` installs all optional dependencies. * ``pip install "python-telegram-bot[ext]"`` installs all optional dependencies that are related to ``telegram.ext``, i.e. ``[rate-limiter, webhooks, callback-data, job-queue]``. +Working with PTB +---------------- + +Once you have installed the library, you can begin working with it - so let's get started! + Quick Start -=========== +~~~~~~~~~~~ Our Wiki contains an `Introduction to the API `_ explaining how the pure Bot API can be accessed via ``python-telegram-bot``. Moreover, the `Tutorial: Your first Bot `_ gives an introduction on how chatbots can be easily programmed with the help of the ``telegram.ext`` module. Resources -========= +~~~~~~~~~ - The `package documentation `_ is the technical reference for ``python-telegram-bot``. It contains descriptions of all available classes, modules, methods and arguments as well as the `changelog `_. @@ -167,7 +190,7 @@ Resources - The `official Telegram Bot API documentation `_ is of course always worth a read. Getting help -============ +~~~~~~~~~~~~ If the resources mentioned above don't answer your questions or simply overwhelm you, there are several ways of getting help. @@ -178,7 +201,7 @@ If the resources mentioned above don't answer your questions or simply overwhelm 3. You can even ask for help on Stack Overflow using the `python-telegram-bot tag `_. Concurrency -=========== +~~~~~~~~~~~ Since v20.0, ``python-telegram-bot`` is built on top of Pythons ``asyncio`` module. Because ``asyncio`` is in general single-threaded, ``python-telegram-bot`` does currently not aim to be thread-safe. @@ -191,20 +214,22 @@ Noteworthy parts of ``python-telegram-bots`` API that are likely to cause issues * all classes in the ``telegram.ext.filters`` module that allow to add/remove allowed users/chats at runtime Contributing -============ +------------ Contributions of all sizes are welcome. Please review our `contribution guidelines `_ to get started. You can also help by `reporting bugs or feature requests `_. Donating -======== +-------- Occasionally we are asked if we accept donations to support the development. While we appreciate the thought, maintaining PTB is our hobby, and we have almost no running costs for it. We therefore have nothing set up to accept donations. If you still want to donate, we kindly ask you to donate to another open source project/initiative of your choice instead. License -======= +------- You may copy, distribute and modify the software provided that modifications are described and licensed for free under `LGPL-3 `_. Derivatives works (including modifications or anything statically linked to the library) can only be redistributed under LGPL-3, but applications that use the library don't have to be. + +.. _`GitHub releases page`: https://github.com/python-telegram-bot/python-telegram-bot/releases> \ No newline at end of file diff --git a/README_RAW.rst b/README_RAW.rst deleted file mode 100644 index e82270959f1..00000000000 --- a/README_RAW.rst +++ /dev/null @@ -1,213 +0,0 @@ -.. image:: https://github.com/python-telegram-bot/logos/blob/master/logo-text/png/ptb-raw-logo-text_768.png?raw=true - :align: center - :target: https://python-telegram-bot.org - :alt: python-telegram-bot-raw Logo - -.. image:: https://img.shields.io/pypi/v/python-telegram-bot-raw.svg - :target: https://pypi.org/project/python-telegram-bot-raw/ - :alt: PyPi Package Version - -.. image:: https://img.shields.io/pypi/pyversions/python-telegram-bot-raw.svg - :target: https://pypi.org/project/python-telegram-bot-raw/ - :alt: Supported Python versions - -.. image:: https://img.shields.io/badge/Bot%20API-7.4-blue?logo=telegram - :target: https://core.telegram.org/bots/api-changelog - :alt: Supported Bot API version - -.. image:: https://img.shields.io/pypi/dm/python-telegram-bot-raw - :target: https://pypistats.org/packages/python-telegram-bot-raw - :alt: PyPi Package Monthly Download - -.. image:: https://readthedocs.org/projects/python-telegram-bot/badge/?version=stable - :target: https://docs.python-telegram-bot.org/ - :alt: Documentation Status - -.. image:: https://img.shields.io/pypi/l/python-telegram-bot-raw.svg - :target: https://www.gnu.org/licenses/lgpl-3.0.html - :alt: LGPLv3 License - -.. image:: https://github.com/python-telegram-bot/python-telegram-bot/actions/workflows/unit_tests.yml/badge.svg?branch=master - :target: https://github.com/python-telegram-bot/python-telegram-bot/ - :alt: Github Actions workflow - -.. image:: https://codecov.io/gh/python-telegram-bot/python-telegram-bot/branch/master/graph/badge.svg - :target: https://app.codecov.io/gh/python-telegram-bot/python-telegram-bot - :alt: Code coverage - -.. image:: https://isitmaintained.com/badge/resolution/python-telegram-bot/python-telegram-bot.svg - :target: https://isitmaintained.com/project/python-telegram-bot/python-telegram-bot - :alt: Median time to resolve an issue - -.. image:: https://api.codacy.com/project/badge/Grade/99d901eaa09b44b4819aec05c330c968 - :target: https://app.codacy.com/gh/python-telegram-bot/python-telegram-bot/dashboard - :alt: Code quality: Codacy - -.. image:: https://results.pre-commit.ci/badge/github/python-telegram-bot/python-telegram-bot/master.svg - :target: https://results.pre-commit.ci/latest/github/python-telegram-bot/python-telegram-bot/master - :alt: pre-commit.ci status - -.. image:: https://img.shields.io/badge/code%20style-black-000000.svg - :target: https://github.com/psf/black - :alt: Code Style: Black - -.. image:: https://img.shields.io/badge/Telegram-Channel-blue.svg?logo=telegram - :target: https://t.me/pythontelegrambotchannel - :alt: Telegram Channel - -.. image:: https://img.shields.io/badge/Telegram-Group-blue.svg?logo=telegram - :target: https://telegram.me/pythontelegrambotgroup - :alt: Telegram Group - -⚠️ Deprecation Notice -===================== - -The ``python-telegram-bot-raw`` library will no longer be updated after 21.3. -Please instead use the ``python-telegram-bot`` `library `_. -The change requires no changes in your code and requires no additional dependencies. -For additional information, please see this `channel post `_. - ----- - -We have made you a wrapper you can't refuse - -We have a vibrant community of developers helping each other in our `Telegram group `_. Join us! - -*Stay tuned for library updates and new releases on our* `Telegram Channel `_. - -Introduction -============ - -This library provides a pure Python, asynchronous interface for the -`Telegram Bot API `_. -It's compatible with Python versions **3.8+**. - -``python-telegram-bot-raw`` is part of the `python-telegram-bot `_ ecosystem and provides the pure API functionality extracted from PTB. It therefore does not have independent release schedules, changelogs or documentation. - -Note ----- - -Installing both ``python-telegram-bot`` and ``python-telegram-bot-raw`` in conjunction will result in undesired side-effects, so only install *one* of both. - -Telegram API support -==================== - -All types and methods of the Telegram Bot API **7.4** are supported. - -Installing -========== - -You can install or upgrade ``python-telegram-bot`` via - -.. code:: shell - - $ pip install python-telegram-bot-raw --upgrade - -To install a pre-release, use the ``--pre`` `flag `_ in addition. - -You can also install ``python-telegram-bot-raw`` from source, though this is usually not necessary. - -.. code:: shell - - $ git clone https://github.com/python-telegram-bot/python-telegram-bot - $ cd python-telegram-bot - $ python setup_raw.py install - -Note ----- - -Installing the ``.tar.gz`` archive available on PyPi directly via ``pip`` will *not* work as expected, as ``pip`` does not recognize that it should use ``setup_raw.py`` instead of ``setup.py``. - -Verifying Releases ------------------- - -We sign all the releases with a GPG key. -The signatures are uploaded to both the `GitHub releases page `_ and the `PyPI project `_ and end with a suffix ``.asc``. -Please find the public keys `here `_. -The keys are named in the format ``-.gpg`` or ``-current.gpg`` if the key is currently being used for new releases. - -In addition, the GitHub release page also contains the sha1 hashes of the release files in the files with the suffix ``.sha1``. - -This allows you to verify that a release file that you downloaded was indeed provided by the ``python-telegram-bot`` team. - -Dependencies & Their Versions ------------------------------ - -``python-telegram-bot`` tries to use as few 3rd party dependencies as possible. -However, for some features using a 3rd party library is more sane than implementing the functionality again. -As these features are *optional*, the corresponding 3rd party dependencies are not installed by default. -Instead, they are listed as optional dependencies. -This allows to avoid unnecessary dependency conflicts for users who don't need the optional features. - -The only required dependency is `httpx ~= 0.27 `_ for -``telegram.request.HTTPXRequest``, the default networking backend. - -``python-telegram-bot`` is most useful when used along with additional libraries. -To minimize dependency conflicts, we try to be liberal in terms of version requirements on the (optional) dependencies. -On the other hand, we have to ensure stability of ``python-telegram-bot``, which is why we do apply version bounds. -If you encounter dependency conflicts due to these bounds, feel free to reach out. - -Optional Dependencies -##################### - -PTB can be installed with optional dependencies: - -* ``pip install "python-telegram-bot-raw[passport]"`` installs the `cryptography>=39.0.1 `_ library. Use this, if you want to use Telegram Passport related functionality. -* ``pip install "python-telegram-bot-raw[socks]"`` installs `httpx[socks] `_. Use this, if you want to work behind a Socks5 server. -* ``pip install "python-telegram-bot-raw[http2]"`` installs `httpx[http2] `_. Use this, if you want to use HTTP/2. - -To install multiple optional dependencies, separate them by commas, e.g. ``pip install "python-telegram-bot-raw[passport,socks]"``. - -Additionally, the shortcut ``pip install "python-telegram-bot-raw[all]"`` installs all optional dependencies. - -Quick Start -=========== - -Our Wiki contains an `Introduction to the API `_ explaining how the pure Bot API can be accessed via ``python-telegram-bot``. - -Resources -========= - -- The `package documentation `_ is the technical reference for ``python-telegram-bot``. - It contains descriptions of all available classes, modules, methods and arguments as well as the `changelog `_. -- The `wiki `_ is home to number of more elaborate introductions of the different features of ``python-telegram-bot`` and other useful resources that go beyond the technical documentation. -- Our `examples section `_ contains several examples that showcase the different features of both the Bot API and ``python-telegram-bot``. - Even if it is not your approach for learning, please take a look at ``echobot.py``. It is the de facto base for most of the bots out there. - The code for these examples is released to the public domain, so you can start by grabbing the code and building on top of it. -- The `official Telegram Bot API documentation `_ is of course always worth a read. - -Getting help -============ - -If the resources mentioned above don't answer your questions or simply overwhelm you, there are several ways of getting help. - -1. We have a vibrant community of developers helping each other in our `Telegram group `_. Join us! Asking a question here is often the quickest way to get a pointer in the right direction. - -2. Ask questions by opening `a discussion `_. - -3. You can even ask for help on Stack Overflow using the `python-telegram-bot tag `_. - -Concurrency -=========== - -Since v20.0, ``python-telegram-bot`` is built on top of Pythons ``asyncio`` module. -Because ``asyncio`` is in general single-threaded, ``python-telegram-bot`` does currently not aim to be thread-safe. - -Contributing -============ - -Contributions of all sizes are welcome. -Please review our `contribution guidelines `_ to get started. -You can also help by `reporting bugs or feature requests `_. - -Donating -======== -Occasionally we are asked if we accept donations to support the development. -While we appreciate the thought, maintaining PTB is our hobby, and we have almost no running costs for it. We therefore have nothing set up to accept donations. -If you still want to donate, we kindly ask you to donate to another open source project/initiative of your choice instead. - -License -======= - -You may copy, distribute and modify the software provided that modifications are described and licensed for free under `LGPL-3 `_. -Derivatives works (including modifications or anything statically linked to the library) can only be redistributed under LGPL-3, but applications that use the library don't have to be. diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index 13bface7d25..08fba15d32f 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -4,4 +4,4 @@ furo-sphinx-search @ git+https://github.com/harshil21/furo-sphinx-search@v0.2.0. sphinx-paramlinks==0.6.0 sphinxcontrib-mermaid==0.9.2 sphinx-copybutton==0.5.2 -sphinx-inline-tabs==2023.4.21 +sphinx-inline-tabs==2023.4.21 \ No newline at end of file diff --git a/docs/source/inclusions/bot_methods.rst b/docs/source/inclusions/bot_methods.rst index bece5296e22..3189de1c1d3 100644 --- a/docs/source/inclusions/bot_methods.rst +++ b/docs/source/inclusions/bot_methods.rst @@ -33,6 +33,8 @@ - Used for sending media grouped together * - :meth:`~telegram.Bot.send_message` - Used for sending text messages + * - :meth:`~telegram.Bot.send_paid_media` + - Used for sending paid media to channels * - :meth:`~telegram.Bot.send_photo` - Used for sending photos * - :meth:`~telegram.Bot.send_poll` @@ -369,6 +371,8 @@ - Used for getting basic info about a file * - :meth:`~telegram.Bot.get_me` - Used for getting basic information about the bot + * - :meth:`~telegram.Bot.get_star_transactions` + - Used for obtaining the bot's Telegram Stars transactions * - :meth:`~telegram.Bot.refund_star_payment` - Used for refunding a payment in Telegram Stars diff --git a/docs/source/index.rst b/docs/source/index.rst index d7be3ab9edf..f8aa9e7b647 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -3,6 +3,18 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. +.. raw:: html + +
+ +Hidden Headline +=============== +This is just here to get furo to display the right sidebar. + +.. raw:: html + +
+ .. include:: ../../README.rst .. The toctrees are hidden such that they don't render on the start page but still include the contents into the documentation. diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index f9ac8dd6702..8d3238a27e4 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -88,6 +88,9 @@ Available Types telegram.inputmediadocument telegram.inputmediaphoto telegram.inputmediavideo + telegram.inputpaidmedia + telegram.inputpaidmediaphoto + telegram.inputpaidmediavideo telegram.inputpolloption telegram.inputsticker telegram.keyboardbutton @@ -113,6 +116,11 @@ Available Types telegram.messageoriginuser telegram.messagereactioncountupdated telegram.messagereactionupdated + telegram.paidmedia + telegram.paidmediainfo + telegram.paidmediaphoto + telegram.paidmediapreview + telegram.paidmediavideo telegram.photosize telegram.poll telegram.pollanswer diff --git a/docs/source/telegram.inputpaidmedia.rst b/docs/source/telegram.inputpaidmedia.rst new file mode 100644 index 00000000000..ecb45d35f6d --- /dev/null +++ b/docs/source/telegram.inputpaidmedia.rst @@ -0,0 +1,6 @@ +InputPaidMedia +============== + +.. autoclass:: telegram.InputPaidMedia + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.inputpaidmediaphoto.rst b/docs/source/telegram.inputpaidmediaphoto.rst new file mode 100644 index 00000000000..f8df55823a2 --- /dev/null +++ b/docs/source/telegram.inputpaidmediaphoto.rst @@ -0,0 +1,6 @@ +InputPaidMediaPhoto +=================== + +.. autoclass:: telegram.InputPaidMediaPhoto + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.inputpaidmediavideo.rst b/docs/source/telegram.inputpaidmediavideo.rst new file mode 100644 index 00000000000..8a3789f5028 --- /dev/null +++ b/docs/source/telegram.inputpaidmediavideo.rst @@ -0,0 +1,6 @@ +InputPaidMediaVideo +=================== + +.. autoclass:: telegram.InputPaidMediaVideo + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.paidmedia.rst b/docs/source/telegram.paidmedia.rst new file mode 100644 index 00000000000..0883310f324 --- /dev/null +++ b/docs/source/telegram.paidmedia.rst @@ -0,0 +1,6 @@ +PaidMedia +========= + +.. autoclass:: telegram.PaidMedia + :members: + :show-inheritance: diff --git a/docs/source/telegram.paidmediainfo.rst b/docs/source/telegram.paidmediainfo.rst new file mode 100644 index 00000000000..3c0d1e75c52 --- /dev/null +++ b/docs/source/telegram.paidmediainfo.rst @@ -0,0 +1,6 @@ +PaidMediaInfo +============= + +.. autoclass:: telegram.PaidMediaInfo + :members: + :show-inheritance: diff --git a/docs/source/telegram.paidmediaphoto.rst b/docs/source/telegram.paidmediaphoto.rst new file mode 100644 index 00000000000..4092cfcc187 --- /dev/null +++ b/docs/source/telegram.paidmediaphoto.rst @@ -0,0 +1,6 @@ +PaidMediaPhoto +============== + +.. autoclass:: telegram.PaidMediaPhoto + :members: + :show-inheritance: diff --git a/docs/source/telegram.paidmediapreview.rst b/docs/source/telegram.paidmediapreview.rst new file mode 100644 index 00000000000..32ff4809d69 --- /dev/null +++ b/docs/source/telegram.paidmediapreview.rst @@ -0,0 +1,6 @@ +PaidMediaPreview +================ + +.. autoclass:: telegram.PaidMediaPreview + :members: + :show-inheritance: diff --git a/docs/source/telegram.paidmediavideo.rst b/docs/source/telegram.paidmediavideo.rst new file mode 100644 index 00000000000..30f2377ac86 --- /dev/null +++ b/docs/source/telegram.paidmediavideo.rst @@ -0,0 +1,6 @@ +PaidMediaVideo +============== + +.. autoclass:: telegram.PaidMediaVideo + :members: + :show-inheritance: diff --git a/docs/source/telegram.payments-tree.rst b/docs/source/telegram.payments-tree.rst index 67f686ecc4b..0db0ba21959 100644 --- a/docs/source/telegram.payments-tree.rst +++ b/docs/source/telegram.payments-tree.rst @@ -8,7 +8,19 @@ Payments telegram.labeledprice telegram.orderinfo telegram.precheckoutquery + telegram.refundedpayment + telegram.revenuewithdrawalstate + telegram.revenuewithdrawalstatefailed + telegram.revenuewithdrawalstatepending + telegram.revenuewithdrawalstatesucceeded telegram.shippingaddress telegram.shippingoption telegram.shippingquery + telegram.startransaction + telegram.startransactions telegram.successfulpayment + telegram.transactionpartner + telegram.transactionpartnerfragment + telegram.transactionpartnerother + telegram.transactionpartnertelegramads + telegram.transactionpartneruser diff --git a/docs/source/telegram.refundedpayment.rst b/docs/source/telegram.refundedpayment.rst new file mode 100644 index 00000000000..f99349c859c --- /dev/null +++ b/docs/source/telegram.refundedpayment.rst @@ -0,0 +1,6 @@ +RefundedPayment +=============== + +.. autoclass:: telegram.RefundedPayment + :members: + :show-inheritance: diff --git a/docs/source/telegram.revenuewithdrawalstate.rst b/docs/source/telegram.revenuewithdrawalstate.rst new file mode 100644 index 00000000000..d3f7eef81cc --- /dev/null +++ b/docs/source/telegram.revenuewithdrawalstate.rst @@ -0,0 +1,7 @@ +RevenueWithdrawalState +====================== + +.. autoclass:: telegram.RevenueWithdrawalState + :members: + :show-inheritance: + :inherited-members: TelegramObject diff --git a/docs/source/telegram.revenuewithdrawalstatefailed.rst b/docs/source/telegram.revenuewithdrawalstatefailed.rst new file mode 100644 index 00000000000..ac319c6a67a --- /dev/null +++ b/docs/source/telegram.revenuewithdrawalstatefailed.rst @@ -0,0 +1,7 @@ +RevenueWithdrawalStateFailed +============================= + +.. autoclass:: telegram.RevenueWithdrawalStateFailed + :members: + :show-inheritance: + :inherited-members: TelegramObject diff --git a/docs/source/telegram.revenuewithdrawalstatepending.rst b/docs/source/telegram.revenuewithdrawalstatepending.rst new file mode 100644 index 00000000000..19a74e5f28c --- /dev/null +++ b/docs/source/telegram.revenuewithdrawalstatepending.rst @@ -0,0 +1,7 @@ +RevenueWithdrawalStatePending +============================= + +.. autoclass:: telegram.RevenueWithdrawalStatePending + :members: + :show-inheritance: + :inherited-members: TelegramObject diff --git a/docs/source/telegram.revenuewithdrawalstatesucceeded.rst b/docs/source/telegram.revenuewithdrawalstatesucceeded.rst new file mode 100644 index 00000000000..7f7980e799f --- /dev/null +++ b/docs/source/telegram.revenuewithdrawalstatesucceeded.rst @@ -0,0 +1,7 @@ +RevenueWithdrawalStateSucceeded +=============================== + +.. autoclass:: telegram.RevenueWithdrawalStateSucceeded + :members: + :show-inheritance: + :inherited-members: TelegramObject diff --git a/docs/source/telegram.startransaction.rst b/docs/source/telegram.startransaction.rst new file mode 100644 index 00000000000..42f84e39b67 --- /dev/null +++ b/docs/source/telegram.startransaction.rst @@ -0,0 +1,7 @@ +StarTransaction +=============== + +.. autoclass:: telegram.StarTransaction + :members: + :show-inheritance: + :inherited-members: TelegramObject diff --git a/docs/source/telegram.startransactions.rst b/docs/source/telegram.startransactions.rst new file mode 100644 index 00000000000..1f1860920b5 --- /dev/null +++ b/docs/source/telegram.startransactions.rst @@ -0,0 +1,8 @@ +StarTransactions +================ + +.. autoclass:: telegram.StarTransactions + :members: + :show-inheritance: + :inherited-members: TelegramObject + diff --git a/docs/source/telegram.transactionpartner.rst b/docs/source/telegram.transactionpartner.rst new file mode 100644 index 00000000000..9ccca02cec0 --- /dev/null +++ b/docs/source/telegram.transactionpartner.rst @@ -0,0 +1,7 @@ +TransactionPartner +================== + +.. autoclass:: telegram.TransactionPartner + :members: + :show-inheritance: + :inherited-members: TelegramObject diff --git a/docs/source/telegram.transactionpartnerfragment.rst b/docs/source/telegram.transactionpartnerfragment.rst new file mode 100644 index 00000000000..0845b4a800b --- /dev/null +++ b/docs/source/telegram.transactionpartnerfragment.rst @@ -0,0 +1,7 @@ +TransactionPartnerFragment +========================== + +.. autoclass:: telegram.TransactionPartnerFragment + :members: + :show-inheritance: + :inherited-members: TelegramObject diff --git a/docs/source/telegram.transactionpartnerother.rst b/docs/source/telegram.transactionpartnerother.rst new file mode 100644 index 00000000000..c3ffddc7de0 --- /dev/null +++ b/docs/source/telegram.transactionpartnerother.rst @@ -0,0 +1,7 @@ +TransactionPartnerOther +======================= + +.. autoclass:: telegram.TransactionPartnerOther + :members: + :show-inheritance: + :inherited-members: TelegramObject diff --git a/docs/source/telegram.transactionpartnertelegramads.rst b/docs/source/telegram.transactionpartnertelegramads.rst new file mode 100644 index 00000000000..926b25bdcd4 --- /dev/null +++ b/docs/source/telegram.transactionpartnertelegramads.rst @@ -0,0 +1,7 @@ +TransactionPartnerTelegramAds +============================= + +.. autoclass:: telegram.TransactionPartnerTelegramAds + :members: + :show-inheritance: + :inherited-members: TelegramObject diff --git a/docs/source/telegram.transactionpartneruser.rst b/docs/source/telegram.transactionpartneruser.rst new file mode 100644 index 00000000000..d2e145e1866 --- /dev/null +++ b/docs/source/telegram.transactionpartneruser.rst @@ -0,0 +1,7 @@ +TransactionPartnerUser +====================== + +.. autoclass:: telegram.TransactionPartnerUser + :members: + :show-inheritance: + :inherited-members: TelegramObject diff --git a/docs/substitutions/global.rst b/docs/substitutions/global.rst index 37edc74a446..c4e9e493bb3 100644 --- a/docs/substitutions/global.rst +++ b/docs/substitutions/global.rst @@ -16,6 +16,8 @@ .. |editreplymarkup| replace:: It is currently only possible to edit messages without :attr:`telegram.Message.reply_markup` or with inline keyboards. +.. |bcid_edit_time| replace:: Note that business messages that were not sent by the bot and do not contain an inline keyboard can only be edited within *48 hours* from the time they were sent. + .. |toapikwargsbase| replace:: These arguments are also considered by :meth:`~telegram.TelegramObject.to_dict` and :meth:`~telegram.TelegramObject.to_json`, i.e. when passing objects to Telegram. Passing them to Telegram is however not guaranteed to work for all kinds of objects, e.g. this will fail for objects that can not directly be JSON serialized. .. |toapikwargsarg| replace:: Arbitrary keyword arguments. Can be used to store data for which there are no dedicated attributes. |toapikwargsbase| @@ -82,6 +84,8 @@ .. |business_id_str| replace:: Unique identifier of the business connection on behalf of which the message will be sent. +.. |business_id_str_edit| replace:: Unique identifier of the business connection on behalf of which the message to be edited was sent + .. |message_effect_id| replace:: Unique identifier of the message effect to be added to the message; for private chats only. .. |show_cap_above_med| replace:: :obj:`True`, if the caption must be shown above the message media. diff --git a/examples/passportbot.py b/examples/passportbot.py index e6d783240fb..c883e0f76fe 100644 --- a/examples/passportbot.py +++ b/examples/passportbot.py @@ -47,9 +47,9 @@ async def msg(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: # Files will be downloaded to current directory for data in passport_data.decrypted_data: # This is where the data gets decrypted if data.type == "phone_number": - print("Phone: ", data.phone_number) + logger.info("Phone: %s", data.phone_number) elif data.type == "email": - print("Email: ", data.email) + logger.info("Email: %s", data.email) if data.type in ( "personal_details", "passport", @@ -58,7 +58,7 @@ async def msg(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: "internal_passport", "address", ): - print(data.type, data.data) + logger.info(data.type, data.data) if data.type in ( "utility_bill", "bank_statement", @@ -66,28 +66,28 @@ async def msg(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: "passport_registration", "temporary_registration", ): - print(data.type, len(data.files), "files") + logger.info(data.type, len(data.files), "files") for file in data.files: actual_file = await file.get_file() - print(actual_file) + logger.info(actual_file) await actual_file.download_to_drive() if ( data.type in ("passport", "driver_license", "identity_card", "internal_passport") and data.front_side ): front_file = await data.front_side.get_file() - print(data.type, front_file) + logger.info(data.type, front_file) await front_file.download_to_drive() if data.type in ("driver_license" and "identity_card") and data.reverse_side: reverse_file = await data.reverse_side.get_file() - print(data.type, reverse_file) + logger.info(data.type, reverse_file) await reverse_file.download_to_drive() if ( data.type in ("passport", "driver_license", "identity_card", "internal_passport") and data.selfie ): selfie_file = await data.selfie.get_file() - print(data.type, selfie_file) + logger.info(data.type, selfie_file) await selfie_file.download_to_drive() if data.translation and data.type in ( "passport", @@ -100,10 +100,10 @@ async def msg(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: "passport_registration", "temporary_registration", ): - print(data.type, len(data.translation), "translation") + logger.info(data.type, len(data.translation), "translation") for file in data.translation: actual_file = await file.get_file() - print(actual_file) + logger.info(actual_file) await actual_file.download_to_drive() diff --git a/public_keys/v20.0-current.gpg b/public_keys/v20.0-v21.3.gpg similarity index 100% rename from public_keys/v20.0-current.gpg rename to public_keys/v20.0-v21.3.gpg diff --git a/pyproject.toml b/pyproject.toml index b02870776ca..2484250c3d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,116 @@ +# PACKAGING +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +dynamic = ["version"] +name = "python-telegram-bot" +description = "We have made you a wrapper you can't refuse" +readme = "README.rst" +requires-python = ">=3.8" +license = "LGPL-3.0-only" +license-files = { paths = ["LICENSE", "LICENSE.dual", "LICENSE.lesser"] } +authors = [ + { name = "Leandro Toledo", email = "devs@python-telegram-bot.org" } +] +keywords = [ + "python", + "telegram", + "bot", + "api", + "wrapper", +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", + "Operating System :: OS Independent", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Communications :: Chat", + "Topic :: Internet", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dependencies = [ + "httpx ~= 0.27", +] + +[project.urls] +"Homepage" = "https://python-telegram-bot.org" +"Documentation" = "https://docs.python-telegram-bot.org" +"Bug Tracker" = "https://github.com/python-telegram-bot/python-telegram-bot/issues" +"Source Code" = "https://github.com/python-telegram-bot/python-telegram-bot" +"News" = "https://t.me/pythontelegrambotchannel" +"Changelog" = "https://docs.python-telegram-bot.org/en/stable/changelog.html" +"Support" = "https://t.me/pythontelegrambotgroup" + +[project.optional-dependencies] +# Make sure to install those as additional_dependencies in the +# pre-commit hooks for pylint & mypy +# Also update the readme accordingly +# +# When dependencies release new versions and tests succeed, we should try to expand the allowed +# versions and only increase the lower bound if necessary +# +# When adding new groups, make sure to update `ext` and `all` accordingly + +# Optional dependencies for production +all = [ + "python-telegram-bot[ext,http2,passport,socks]", +] +callback-data = [ + # Cachetools doesn't have a strict stability policy. Let's be cautious for now. + "cachetools~=5.3.3", +] +ext = [ + "python-telegram-bot[callback-data,job-queue,rate-limiter,webhooks]", +] +http2 = [ + "httpx[http2]", +] +job-queue = [ + # APS doesn't have a strict stability policy. Let's be cautious for now. + "APScheduler~=3.10.4", + # pytz is required by APS and just needs the lower bound due to #2120 + "pytz>=2018.6", +] +passport = [ + "cryptography!=3.4,!=3.4.1,!=3.4.2,!=3.4.3,>=39.0.1", + # cffi is a dependency of cryptography and added support for python 3.13 in 1.17.0rc1 + "cffi >= 1.17.0rc1; python_version > '3.12'" +] +rate-limiter = [ + "aiolimiter~=1.1.0", +] +socks = [ + "httpx[socks]", +] +webhooks = [ + # tornado is rather stable, but let's not allow the next major release without prior testing + "tornado~=6.4", +] + + +# HATCH +[tool.hatch.version] +# dynamically evaluates the `__version__` variable in that file +source = "code" +path = "telegram/_version.py" +search-paths = ["telegram"] + +[tool.hatch.build] +packages = ["telegram"] + # BLACK: [tool.black] line-length = 99 -target-version = ['py38', 'py39', 'py310', 'py311'] # ISORT: [tool.isort] # black config @@ -11,22 +120,20 @@ line_length = 99 # RUFF: [tool.ruff] line-length = 99 -target-version = "py38" show-fixes = true [tool.ruff.lint] preview = true -explicit-preview-rules = true +explicit-preview-rules = true # TODO: Drop this when RUF022 and RUF023 are out of preview ignore = ["PLR2004", "PLR0911", "PLR0912", "PLR0913", "PLR0915", "PERF203"] select = ["E", "F", "I", "PL", "UP", "RUF", "PTH", "C4", "B", "PIE", "SIM", "RET", "RSE", "G", "ISC", "PT", "ASYNC", "TCH", "SLOT", "PERF", "PYI", "FLY", "AIR", "RUF022", - "RUF023", "Q", "INP", "W", "YTT", "DTZ", "ARG"] -# Add "FURB" after it's out of preview + "RUF023", "Q", "INP", "W", "YTT", "DTZ", "ARG", "T20", "FURB"] # Add "A (flake8-builtins)" after we drop pylint [tool.ruff.lint.per-file-ignores] "tests/*.py" = ["B018"] -"tests/**.py" = ["RUF012", "ASYNC101", "DTZ", "ARG"] +"tests/**.py" = ["RUF012", "ASYNC230", "DTZ", "ARG", "T201"] "docs/**.py" = ["INP001", "ARG"] "examples/**.py" = ["ARG"] diff --git a/requirements-all.txt b/requirements-all.txt deleted file mode 100644 index d38ad669196..00000000000 --- a/requirements-all.txt +++ /dev/null @@ -1,4 +0,0 @@ --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-all.txt b/requirements-dev-all.txt new file mode 100644 index 00000000000..995e067c420 --- /dev/null +++ b/requirements-dev-all.txt @@ -0,0 +1,5 @@ +-e .[all] +# needed for pre-commit hooks in the git commit command +pre-commit +-r requirements-unit-tests.txt +-r docs/requirements-docs.txt diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 63f6432ad4a..00000000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,11 +0,0 @@ -pre-commit # needed for pre-commit hooks in the git commit command - -# For the test suite -setuptools # required for test_meta -pytest==8.2.1 -pytest-asyncio==0.21.2 # needed because pytest doesn't come with native support for coroutines as tests -pytest-xdist==3.6.1 # xdist runs tests in parallel -flaky # Used for flaky tests (flaky decorator) -beautifulsoup4 # used in test_official for parsing tg docs - -wheel # required for building the wheels for releases diff --git a/requirements-opts.txt b/requirements-opts.txt deleted file mode 100644 index 05ac0d8c718..00000000000 --- a/requirements-opts.txt +++ /dev/null @@ -1,27 +0,0 @@ -# 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` - -# Make sure to install those as additional_dependencies in the -# pre-commit hooks for pylint & mypy -# Also update the readme accordingly - -# When dependencies release new versions and tests succeed, we should try to expand the allowed -# versions and only increase the lower bound if necessary - -httpx[socks] # socks -httpx[http2] # http2 -cryptography!=3.4,!=3.4.1,!=3.4.2,!=3.4.3,>=39.0.1 # passport -aiolimiter~=1.1.0 # rate-limiter!ext - -# tornado is rather stable, but let's not allow the next mayor release without prior testing -tornado~=6.4 # webhooks!ext - -# Cachetools and APS don't have a strict stability policy. -# Let's be cautious for now. -cachetools~=5.3.3 # callback-data!ext -APScheduler~=3.10.4 # job-queue!ext - -# pytz is required by APS and just needs the lower bound due to #2120 -pytz>=2018.6 # job-queue!ext diff --git a/requirements-unit-tests.txt b/requirements-unit-tests.txt new file mode 100644 index 00000000000..eb69f9d9283 --- /dev/null +++ b/requirements-unit-tests.txt @@ -0,0 +1,19 @@ +-e . + +# required for building the wheels for releases +build + +# For the test suite +pytest==8.2.2 + +# needed because pytest doesn't come with native support for coroutines as tests +pytest-asyncio==0.21.2 + +# xdist runs tests in parallel +pytest-xdist==3.6.1 + +# Used for flaky tests (flaky decorator) +flaky>=3.8.1 + +# used in test_official for parsing tg docs +beautifulsoup4 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 90203e49733..00000000000 --- a/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -# Make sure to install those as additional_dependencies in the -# pre-commit hooks for pylint & mypy -# Also update the readme accordingly - -# When dependencies release new versions and tests succeed, we should try to expand the allowed -# versions and only increase the lower bound if necessary - -# httpx has no stable release yet, but we've had no stability problems since v20.0a0 either -# Since there have been requests to relax the bound a bit, we allow versions < 1.0.0 -httpx ~= 0.27 diff --git a/setup.cfg b/setup.cfg index 278056b06d2..c24e78bc4e1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,8 +1,5 @@ -[metadata] -license_files = LICENSE, LICENSE.dual, LICENSE.lesser - [flake8] max-line-length = 99 ignore = W503, W605 extend-ignore = E203, E704 -exclude = setup.py, setup_raw.py docs/source/conf.py +exclude = docs/source/conf.py diff --git a/setup.py b/setup.py deleted file mode 100644 index d62ad39ed32..00000000000 --- a/setup.py +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env python -"""The setup and build script for the python-telegram-bot library.""" -import subprocess -import sys -from collections import defaultdict -from pathlib import Path -from typing import Any, Dict, List, Tuple - -from setuptools import find_packages, setup - - -def get_requirements() -> List[str]: - """Build the requirements list for this project""" - requirements_list = [] - - with Path("requirements.txt").open(encoding="utf-8") as reqs: - for install in reqs: - if install.startswith("#"): - continue - requirements_list.append(install.strip()) - - return requirements_list - - -def get_packages_requirements(raw: bool = False) -> Tuple[List[str], List[str]]: - """Build the package & requirements list for this project""" - reqs = get_requirements() - - exclude = ["tests*", "docs*"] - if raw: - exclude.append("telegram.ext*") - - packs = find_packages(exclude=exclude) - - return packs, reqs - - -def get_optional_requirements(raw: bool = False) -> Dict[str, List[str]]: - """Build the optional dependencies""" - requirements = defaultdict(list) - - with Path("requirements-opts.txt").open(encoding="utf-8") as reqs: - for line in reqs: - effective_line = line.strip() - if not effective_line or effective_line.startswith("#"): - continue - dependency, names = effective_line.split("#") - dependency = dependency.strip() - for name in names.split(","): - effective_name = name.strip() - if effective_name.endswith("!ext"): - if raw: - continue - effective_name = effective_name[:-4] - requirements["ext"].append(dependency) - requirements[effective_name].append(dependency) - requirements["all"].append(dependency) - - return requirements - - -def get_setup_kwargs(raw: bool = False) -> Dict[str, Any]: - """Builds a dictionary of kwargs for the setup function""" - packages, requirements = get_packages_requirements(raw=raw) - - raw_ext = "-raw" if raw else "" - readme = Path(f'README{"_RAW" if raw else ""}.rst') - - version_file = Path("telegram/_version.py").read_text(encoding="utf-8") - first_part = version_file.split("# SETUP.PY MARKER")[0] - exec(first_part) # pylint: disable=exec-used - - return { - "script_name": f"setup{raw_ext}.py", - "name": f"python-telegram-bot{raw_ext}", - "version": locals()["__version__"], - "author": "Leandro Toledo", - "author_email": "devs@python-telegram-bot.org", - "license": "LGPLv3", - "url": "https://python-telegram-bot.org/", - # Keywords supported by PyPI can be found at - # https://github.com/pypa/warehouse/blob/aafc5185e57e67d43487ce4faa95913dd4573e14/ - # warehouse/templates/packaging/detail.html#L20-L58 - "project_urls": { - "Documentation": "https://docs.python-telegram-bot.org", - "Bug Tracker": "https://github.com/python-telegram-bot/python-telegram-bot/issues", - "Source Code": "https://github.com/python-telegram-bot/python-telegram-bot", - "News": "https://t.me/pythontelegrambotchannel", - "Changelog": "https://docs.python-telegram-bot.org/en/stable/changelog.html", - }, - "download_url": f"https://pypi.org/project/python-telegram-bot{raw_ext}/", - "keywords": "python telegram bot api wrapper", - "description": "We have made you a wrapper you can't refuse", - "long_description": readme.read_text(encoding="utf-8"), - "long_description_content_type": "text/x-rst", - "packages": packages, - "install_requires": requirements, - "extras_require": get_optional_requirements(raw=raw), - "include_package_data": True, - "classifiers": [ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", - "Operating System :: OS Independent", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: Communications :: Chat", - "Topic :: Internet", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - ], - "python_requires": ">=3.8", - } - - -def main() -> None: - # If we're building, build ptb-raw as well - if set(sys.argv[1:]) in [{"bdist_wheel"}, {"sdist"}, {"sdist", "bdist_wheel"}]: - args = ["python", "setup_raw.py"] - args.extend(sys.argv[1:]) - subprocess.run(args, check=True, capture_output=True) - - setup(**get_setup_kwargs(raw=False)) - - -if __name__ == "__main__": - main() diff --git a/setup_raw.py b/setup_raw.py deleted file mode 100644 index 0e99fb68559..00000000000 --- a/setup_raw.py +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env python -"""The setup and build script for the python-telegram-bot-raw library.""" - -from setuptools import setup - -from setup import get_setup_kwargs - -setup(**get_setup_kwargs(raw=True)) diff --git a/telegram/__init__.py b/telegram/__init__.py index 6105c9780c8..5b52bf85c40 100644 --- a/telegram/__init__.py +++ b/telegram/__init__.py @@ -142,6 +142,9 @@ "InputMediaPhoto", "InputMediaVideo", "InputMessageContent", + "InputPaidMedia", + "InputPaidMediaPhoto", + "InputPaidMediaVideo", "InputPollOption", "InputSticker", "InputTextMessageContent", @@ -173,6 +176,11 @@ "MessageReactionCountUpdated", "MessageReactionUpdated", "OrderInfo", + "PaidMedia", + "PaidMediaInfo", + "PaidMediaPhoto", + "PaidMediaPreview", + "PaidMediaVideo", "PassportData", "PassportElementError", "PassportElementErrorDataField", @@ -196,10 +204,15 @@ "ReactionType", "ReactionTypeCustomEmoji", "ReactionTypeEmoji", + "RefundedPayment", "ReplyKeyboardMarkup", "ReplyKeyboardRemove", "ReplyParameters", "ResidentialAddress", + "RevenueWithdrawalState", + "RevenueWithdrawalStateFailed", + "RevenueWithdrawalStatePending", + "RevenueWithdrawalStateSucceeded", "SecureData", "SecureValue", "SentWebAppMessage", @@ -207,6 +220,8 @@ "ShippingAddress", "ShippingOption", "ShippingQuery", + "StarTransaction", + "StarTransactions", "Sticker", "StickerSet", "Story", @@ -214,6 +229,11 @@ "SwitchInlineQueryChosenChat", "TelegramObject", "TextQuote", + "TransactionPartner", + "TransactionPartnerFragment", + "TransactionPartnerOther", + "TransactionPartnerTelegramAds", + "TransactionPartnerUser", "Update", "User", "UserChatBoosts", @@ -242,8 +262,6 @@ "warnings", ) -from pathlib import Path - from . import _version, constants, error, helpers, request, warnings from ._birthdate import Birthdate from ._bot import Bot @@ -325,6 +343,9 @@ InputMediaDocument, InputMediaPhoto, InputMediaVideo, + InputPaidMedia, + InputPaidMediaPhoto, + InputPaidMediaVideo, ) from ._files.inputsticker import InputSticker from ._files.location import Location @@ -397,6 +418,7 @@ MessageOriginUser, ) from ._messagereactionupdated import MessageReactionCountUpdated, MessageReactionUpdated +from ._paidmedia import PaidMedia, PaidMediaInfo, PaidMediaPhoto, PaidMediaPreview, PaidMediaVideo from ._passport.credentials import ( Credentials, DataCredentials, @@ -425,9 +447,23 @@ from ._payment.labeledprice import LabeledPrice from ._payment.orderinfo import OrderInfo from ._payment.precheckoutquery import PreCheckoutQuery +from ._payment.refundedpayment import RefundedPayment from ._payment.shippingaddress import ShippingAddress from ._payment.shippingoption import ShippingOption from ._payment.shippingquery import ShippingQuery +from ._payment.stars import ( + RevenueWithdrawalState, + RevenueWithdrawalStateFailed, + RevenueWithdrawalStatePending, + RevenueWithdrawalStateSucceeded, + StarTransaction, + StarTransactions, + TransactionPartner, + TransactionPartnerFragment, + TransactionPartnerOther, + TransactionPartnerTelegramAds, + TransactionPartnerUser, +) from ._payment.successfulpayment import SuccessfulPayment from ._poll import InputPollOption, Poll, PollAnswer, PollOption from ._proximityalerttriggered import ProximityAlertTriggered @@ -443,7 +479,6 @@ from ._update import Update from ._user import User from ._userprofilephotos import UserProfilePhotos -from ._utils.warnings import warn from ._videochat import ( VideoChatEnded, VideoChatParticipantsInvited, @@ -472,33 +507,8 @@ #: #: .. versionchanged:: 20.0 #: This constant was previously named ``bot_api_version``. -__bot_api_version__: str = _version.__bot_api_version__ +__bot_api_version__: str = constants.BOT_API_VERSION #: :class:`typing.NamedTuple`: Shortcut for :const:`telegram.constants.BOT_API_VERSION_INFO`. #: #: .. versionadded:: 20.0 -__bot_api_version_info__: constants._BotAPIVersion = _version.__bot_api_version_info__ - - -if not (Path(__file__).parent.resolve().absolute() / "ext").exists(): - _MESSAGE = ( - "Hey. You seem to be using the `python-telegram-bot-raw` library. " - "Please note that this libray has been deprecated and will no longer be updated. " - "Please instead use the `python-telegram-bot` library. The change requires no " - "changes in your code and requires no additional dependencies. For additional " - "information, please see the channel post at " - "https://t.me/pythontelegrambotchannel/145." - ) - - # DeprecationWarning is ignored by default in Python 3.7 and later by default outside - # __main__ modules. We use both warning categories to increase the chance of the user - # seeing the warning. - - warn( - warnings.PTBDeprecationWarning(version="21.3", message=_MESSAGE), - stacklevel=2, - ) - warn( - message=_MESSAGE, - category=warnings.PTBUserWarning, - stacklevel=2, - ) +__bot_api_version_info__: constants._BotAPIVersion = constants.BOT_API_VERSION_INFO diff --git a/telegram/__main__.py b/telegram/__main__.py index 7f79dc0278a..2491a330ac6 100644 --- a/telegram/__main__.py +++ b/telegram/__main__.py @@ -17,6 +17,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. # pylint: disable=missing-module-docstring +# ruff: noqa: T201 import subprocess import sys from typing import Optional diff --git a/telegram/_bot.py b/telegram/_bot.py index ebc7817b9d2..4d8a778cc63 100644 --- a/telegram/_bot.py +++ b/telegram/_bot.py @@ -70,7 +70,7 @@ from telegram._files.contact import Contact from telegram._files.document import Document from telegram._files.file import File -from telegram._files.inputmedia import InputMedia +from telegram._files.inputmedia import InputMedia, InputPaidMedia from telegram._files.location import Location from telegram._files.photosize import PhotoSize from telegram._files.sticker import MaskPosition, Sticker, StickerSet @@ -84,6 +84,7 @@ from telegram._menubutton import MenuButton from telegram._message import Message from telegram._messageid import MessageId +from telegram._payment.stars import StarTransactions from telegram._poll import InputPollOption, Poll from telegram._reaction import ReactionType, ReactionTypeCustomEmoji, ReactionTypeEmoji from telegram._reply import ReplyParameters @@ -577,13 +578,16 @@ def _insert_defaults(self, data: Dict[str, object]) -> None: with new._unfrozen(): new.parse_mode = DefaultValue.get_value(new.parse_mode) data[key] = new - elif key == "media" and isinstance(val, Sequence): + elif ( + key == "media" + and isinstance(val, Sequence) + and not isinstance(val[0], InputPaidMedia) + ): # Copy objects as not to edit them in-place copy_list = [copy.copy(media) for media in val] for media in copy_list: with media._unfrozen(): media.parse_mode = DefaultValue.get_value(media.parse_mode) - data[key] = copy_list # 2) else: @@ -2802,6 +2806,7 @@ async def edit_message_live_location( heading: Optional[int] = None, proximity_alert_radius: Optional[int] = None, live_period: Optional[int] = None, + business_connection_id: Optional[str] = None, *, location: Optional[Location] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2849,6 +2854,9 @@ async def edit_message_live_location( remains unchanged .. versionadded:: 21.2. + business_connection_id (:obj:`str`, optional): |business_id_str_edit| + + .. versionadded:: 21.4 Keyword Args: location (:class:`telegram.Location`, optional): The location to send. @@ -2888,6 +2896,7 @@ async def edit_message_live_location( "editMessageLiveLocation", data, reply_markup=reply_markup, + business_connection_id=business_connection_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -2901,6 +2910,7 @@ async def stop_message_live_location( message_id: Optional[int] = None, inline_message_id: Optional[str] = None, reply_markup: Optional["InlineKeyboardMarkup"] = None, + business_connection_id: Optional[str] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2920,6 +2930,9 @@ async def stop_message_live_location( :paramref:`message_id` are not specified. Identifier of the inline message. reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): An object for a new inline keyboard. + business_connection_id (:obj:`str`, optional): |business_id_str_edit| + + .. versionadded:: 21.4 Returns: :class:`telegram.Message`: On success, if edited message is not an inline message, the @@ -2935,6 +2948,7 @@ async def stop_message_live_location( "stopMessageLiveLocation", data, reply_markup=reply_markup, + business_connection_id=business_connection_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -3945,6 +3959,7 @@ async def edit_message_text( reply_markup: Optional["InlineKeyboardMarkup"] = None, entities: Optional[Sequence["MessageEntity"]] = None, link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, + business_connection_id: Optional[str] = None, *, disable_web_page_preview: Optional[bool] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -3957,7 +3972,8 @@ async def edit_message_text( Use this method to edit text and game messages. Note: - |editreplymarkup|. + * |editreplymarkup| + * |bcid_edit_time| .. seealso:: :attr:`telegram.Game.text` @@ -3988,6 +4004,9 @@ async def edit_message_text( reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): An object for an inline keyboard. + business_connection_id (:obj:`str`, optional): |business_id_str_edit| + + .. versionadded:: 21.4 Keyword Args: disable_web_page_preview (:obj:`bool`, optional): Disables link previews for links in @@ -4029,6 +4048,7 @@ async def edit_message_text( reply_markup=reply_markup, parse_mode=parse_mode, link_preview_options=link_preview_options, + business_connection_id=business_connection_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -4046,6 +4066,7 @@ async def edit_message_caption( parse_mode: ODVInput[str] = DEFAULT_NONE, caption_entities: Optional[Sequence["MessageEntity"]] = None, show_caption_above_media: Optional[bool] = None, + business_connection_id: Optional[str] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -4057,7 +4078,8 @@ async def edit_message_caption( Use this method to edit captions of messages. Note: - |editreplymarkup| + * |editreplymarkup| + * |bcid_edit_time| Args: chat_id (:obj:`int` | :obj:`str`, optional): Required if inline_message_id is not @@ -4080,6 +4102,9 @@ async def edit_message_caption( show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| .. versionadded:: 21.3 + business_connection_id (:obj:`str`, optional): |business_id_str_edit| + + .. versionadded:: 21.4 Returns: :class:`telegram.Message`: On success, if edited message is not an inline message, the @@ -4103,6 +4128,7 @@ async def edit_message_caption( caption=caption, parse_mode=parse_mode, caption_entities=caption_entities, + business_connection_id=business_connection_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -4117,6 +4143,7 @@ async def edit_message_media( message_id: Optional[int] = None, inline_message_id: Optional[str] = None, reply_markup: Optional["InlineKeyboardMarkup"] = None, + business_connection_id: Optional[str] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -4132,7 +4159,8 @@ async def edit_message_media( :attr:`~telegram.File.file_id` or specify a URL. Note: - |editreplymarkup| + * |editreplymarkup| + * |bcid_edit_time| .. seealso:: :wiki:`Working with Files and Media ` @@ -4147,6 +4175,9 @@ async def edit_message_media( specified. Identifier of the inline message. reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): An object for an inline keyboard. + business_connection_id (:obj:`str`, optional): |business_id_str_edit| + + .. versionadded:: 21.4 Returns: :class:`telegram.Message`: On success, if edited message is not an inline message, the @@ -4166,6 +4197,7 @@ async def edit_message_media( "editMessageMedia", data, reply_markup=reply_markup, + business_connection_id=business_connection_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -4179,6 +4211,7 @@ async def edit_message_reply_markup( message_id: Optional[int] = None, inline_message_id: Optional[str] = None, reply_markup: Optional["InlineKeyboardMarkup"] = None, + business_connection_id: Optional[str] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -4191,7 +4224,8 @@ async def edit_message_reply_markup( (for inline bots). Note: - |editreplymarkup| + * |editreplymarkup| + * |bcid_edit_time| Args: chat_id (:obj:`int` | :obj:`str`, optional): Required if inline_message_id is not @@ -4202,6 +4236,9 @@ async def edit_message_reply_markup( specified. Identifier of the inline message. reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): An object for an inline keyboard. + business_connection_id (:obj:`str`, optional): |business_id_str_edit| + + .. versionadded:: 21.4 Returns: :class:`telegram.Message`: On success, if edited message is not an inline message, the @@ -4221,6 +4258,7 @@ async def edit_message_reply_markup( "editMessageReplyMarkup", data, reply_markup=reply_markup, + business_connection_id=business_connection_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -4339,7 +4377,16 @@ async def get_updates( else: self._LOGGER.debug("No new updates found.") - return Update.de_list(result, self) + try: + return Update.de_list(result, self) + except Exception as exc: + # This logging is in place mostly b/c we can't access the raw json data in Updater, + # where the exception is caught and logged again. Still, it might also be beneficial + # for custom usages of `get_updates`. + self._LOGGER.critical( + "Error while parsing updates! Received data was %r", result, exc_info=exc + ) + raise exc async def set_webhook( self, @@ -4990,15 +5037,16 @@ async def send_invoice( .. versionchanged:: 20.0 |sequenceargs| max_tip_amount (:obj:`int`, optional): The maximum accepted amount for tips in the - *smallest* units of the currency (integer, **not** float/double). For example, for - a maximum tip of US$ 1.45 pass ``max_tip_amount = 145``. See the exp parameter in - `currencies.json `_, it - shows the number of digits past the decimal point for each currency (2 for the - majority of currencies). Defaults to ``0``. Not supported for payment in |tg_stars| + *smallest units* of the currency (integer, **not** float/double). For example, for + a maximum tip of ``US$ 1.45`` pass ``max_tip_amount = 145``. See the ``exp`` + parameter in `currencies.json + `_, it shows the number of + digits past the decimal point for each currency (2 for the majority of currencies). + Defaults to ``0``. Not supported for payment in |tg_stars|. .. versionadded:: 13.5 suggested_tip_amounts (Sequence[:obj:`int`], optional): An array of - suggested amounts of tips in the *smallest* units of the currency (integer, **not** + suggested amounts of tips in the *smallest units* of the currency (integer, **not** float/double). At most :tg-const:`telegram.Invoice.MAX_TIP_AMOUNTS` suggested tip amounts can be specified. The suggested tip amounts must be positive, passed in a strictly increased order and must not exceed :paramref:`max_tip_amount`. @@ -7119,6 +7167,7 @@ async def stop_poll( chat_id: Union[int, str], message_id: int, reply_markup: Optional["InlineKeyboardMarkup"] = None, + business_connection_id: Optional[str] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -7134,6 +7183,9 @@ async def stop_poll( message_id (:obj:`int`): Identifier of the original message with the poll. reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): An object for a new message inline keyboard. + business_connection_id (:obj:`str`, optional): |business_id_str_edit| + + .. versionadded:: 21.4 Returns: :class:`telegram.Poll`: On success, the stopped Poll is returned. @@ -7146,6 +7198,7 @@ async def stop_poll( "chat_id": chat_id, "message_id": message_id, "reply_markup": reply_markup, + "business_connection_id": business_connection_id, } result = await self._post( @@ -7605,7 +7658,8 @@ async def copy_message( pool_timeout: ODVInput[float] = DEFAULT_NONE, api_kwargs: Optional[JSONDict] = None, ) -> MessageId: - """Use this method to copy messages of any kind. Service messages and invoice messages + """Use this method to copy messages of any kind. Service messages, paid media messages, + giveaway messages, giveaway winners messages, and invoice messages can't be copied. The method is analogous to the method :meth:`forward_message`, but the copied message doesn't have a link to the original message. @@ -7731,11 +7785,12 @@ async def copy_messages( ) -> Tuple["MessageId", ...]: """ Use this method to copy messages of any kind. If some of the specified messages can't be - found or copied, they are skipped. Service messages, giveaway messages, giveaway winners - messages, and invoice messages can't be copied. A quiz poll can be copied only if the value - of the field correct_option_id is known to the bot. The method is analogous to the method - :meth:`forward_messages`, but the copied messages don't have a link to the original - message. Album grouping is kept for copied messages. + found or copied, they are skipped. Service messages, paid media messages, giveaway + messages, giveaway winners messages, and invoice messages can't be copied. A quiz poll can + be copied only if the value + of the field :attr:`telegram.Poll.correct_option_id` is known to the bot. The method is + analogous to the method :meth:`forward_messages`, but the copied messages don't have a + link to the original message. Album grouping is kept for copied messages. .. versionadded:: 20.8 @@ -7924,14 +7979,14 @@ async def create_invoice_link( .. versionchanged:: 20.0 |sequenceargs| max_tip_amount (:obj:`int`, optional): The maximum accepted amount for tips in the - *smallest* units of the currency (integer, **not** float/double). For example, for - a maximum tip of US$ 1.45 pass ``max_tip_amount = 145``. See the exp parameter in - `currencies.json `_, it - shows the number of digits past the decimal point for each currency (2 for the - majority of currencies). Defaults to ``0``. Not supported for payments in - |tg_stars|. + *smallest units* of the currency (integer, **not** float/double). For example, for + a maximum tip of ``US$ 1.45`` pass ``max_tip_amount = 145``. See the ``exp`` + parameter in `currencies.json + `_, it shows the number of + digits past the decimal point for each currency (2 for the majority of currencies). + Defaults to ``0``. Not supported for payments in |tg_stars|. suggested_tip_amounts (Sequence[:obj:`int`], optional): An array of - suggested amounts of tips in the *smallest* units of the currency (integer, **not** + suggested amounts of tips in the *smallest units* of the currency (integer, **not** float/double). At most :tg-const:`telegram.Invoice.MAX_TIP_AMOUNTS` suggested tip amounts can be specified. The suggested tip amounts must be positive, passed in a strictly increased order and must not exceed :paramref:`max_tip_amount`. @@ -9070,6 +9125,138 @@ async def refund_star_payment( api_kwargs=api_kwargs, ) + async def get_star_transactions( + self, + offset: Optional[int] = None, + limit: Optional[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: Optional[JSONDict] = None, + ) -> StarTransactions: + """Returns the bot's Telegram Star transactions in chronological order. + + .. versionadded:: 21.4 + + Args: + offset (:obj:`int`, optional): Number of transactions to skip in the response. + limit (:obj:`int`, optional): The maximum number of transactions to be retrieved. + Values between :tg-const:`telegram.constants.StarTransactionsLimit.MIN_LIMIT`- + :tg-const:`telegram.constants.StarTransactionsLimit.MAX_LIMIT` are accepted. + Defaults to :tg-const:`telegram.constants.StarTransactionsLimit.MAX_LIMIT`. + + Returns: + :class:`telegram.StarTransactions`: On success. + + Raises: + :class:`telegram.error.TelegramError` + """ + + data: JSONDict = {"offset": offset, "limit": limit} + + return StarTransactions.de_json( # type: ignore[return-value] + await self._post( + "getStarTransactions", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ), + bot=self, + ) + + async def send_paid_media( + self, + chat_id: Union[str, int], + star_count: int, + media: Sequence["InputPaidMedia"], + caption: Optional[str] = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Optional[Sequence["MessageEntity"]] = None, + show_caption_above_media: Optional[bool] = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + reply_parameters: Optional["ReplyParameters"] = None, + reply_markup: Optional[ReplyMarkup] = None, + *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: Optional[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: Optional[JSONDict] = None, + ) -> Message: + """Use this method to send paid media to channel chats. + + .. versionadded:: 21.4 + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + star_count (:obj:`int`): The number of Telegram Stars that must be paid to buy access + to the media. + media (Sequence[:class:`telegram.InputPaidMedia`]): A list describing the media to be + sent; up to :tg-const:`telegram.constants.MediaGroupLimit.MAX_MEDIA_LENGTH` items. + caption (:obj:`str`, optional): Caption of the media to be sent, + 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters. + parse_mode (:obj:`str`, optional): |parse_mode| + caption_entities (Sequence[:class:`telegram.MessageEntity`], optional): + |caption_entities| + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| + disable_notification (:obj:`bool`, optional): |disable_notification| + protect_content (:obj:`bool`, optional): |protect_content| + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + reply_markup (:class:`InlineKeyboardMarkup` | :class:`ReplyKeyboardMarkup` | \ + :class:`ReplyKeyboardRemove` | :class:`ForceReply`, optional): + Additional interface options. An object for an inline keyboard, custom reply + keyboard, instructions to remove reply keyboard or to force a reply from the user. + + Keyword Args: + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + Returns: + :class:`telegram.Message`: On success, the sent message is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + + data: JSONDict = { + "chat_id": chat_id, + "star_count": star_count, + "media": media, + "show_caption_above_media": show_caption_above_media, + } + + return await self._send_message( + "sendPaidMedia", + data, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + disable_notification=disable_notification, + protect_content=protect_content, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + allow_sending_without_reply=allow_sending_without_reply, + reply_to_message_id=reply_to_message_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """See :meth:`telegram.TelegramObject.to_dict`.""" data: JSONDict = {"id": self.id, "username": self.username, "first_name": self.first_name} @@ -9322,3 +9509,7 @@ def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """Alias for :meth:`replace_sticker_in_set`""" refundStarPayment = refund_star_payment """Alias for :meth:`refund_star_payment`""" + getStarTransactions = get_star_transactions + """Alias for :meth:`get_star_transactions`""" + sendPaidMedia = send_paid_media + """Alias for :meth:`send_paid_media`""" diff --git a/telegram/_botcommandscope.py b/telegram/_botcommandscope.py index 2cac2f50a5b..73cafd17599 100644 --- a/telegram/_botcommandscope.py +++ b/telegram/_botcommandscope.py @@ -84,13 +84,19 @@ def __init__(self, type: str, *, api_kwargs: Optional[JSONDict] = None): self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["BotCommandScope"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["BotCommandScope"]: """Converts JSON data to the appropriate :class:`BotCommandScope` object, i.e. takes care of selecting the correct subclass. Args: data (Dict[:obj:`str`, ...]): The JSON data. - bot (:class:`telegram.Bot`): The bot associated with this object. + bot (:class:`telegram.Bot`, optional): The bot associated with this object. Defaults to + :obj:`None`, in which case shortcut methods will not be available. + + .. versionchanged:: 21.4 + :paramref:`bot` is now optional and defaults to :obj:`None` Returns: The Telegram object. diff --git a/telegram/_business.py b/telegram/_business.py index ab1fdb91b51..22c89e024b4 100644 --- a/telegram/_business.py +++ b/telegram/_business.py @@ -106,7 +106,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["BusinessConnection"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["BusinessConnection"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -175,7 +177,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["BusinessMessagesDeleted"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["BusinessMessagesDeleted"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -232,7 +236,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["BusinessIntro"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["BusinessIntro"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -284,7 +290,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["BusinessLocation"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["BusinessLocation"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -431,7 +439,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["BusinessOpeningHours"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["BusinessOpeningHours"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_callbackquery.py b/telegram/_callbackquery.py index af89c784b10..bdfa569dbfd 100644 --- a/telegram/_callbackquery.py +++ b/telegram/_callbackquery.py @@ -93,7 +93,7 @@ class CallbackQuery(TelegramObject): with the callback button that originated the query. .. versionchanged:: 20.8 - Objects maybe be of type :class:`telegram.MaybeInaccessibleMessage` since Bot API + Objects may be of type :class:`telegram.MaybeInaccessibleMessage` since Bot API 7.0. data (:obj:`str` | :obj:`object`): Optional. Data associated with the callback button. Be aware that the message, which originated the query, can contain no callback buttons @@ -148,7 +148,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["CallbackQuery"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["CallbackQuery"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -260,6 +262,8 @@ async def edit_message_text( entities=entities, chat_id=None, message_id=None, + # inline messages can not be sent on behalf of a bcid + business_connection_id=None, ) return await self._get_message().edit_text( text=text, @@ -328,6 +332,8 @@ async def edit_message_caption( chat_id=None, message_id=None, show_caption_above_media=show_caption_above_media, + # inline messages can not be sent on behalf of a bcid + business_connection_id=None, ) return await self._get_message().edit_caption( caption=caption, @@ -388,6 +394,8 @@ async def edit_message_reply_markup( api_kwargs=api_kwargs, chat_id=None, message_id=None, + # inline messages can not be sent on behalf of a bcid + business_connection_id=None, ) return await self._get_message().edit_reply_markup( reply_markup=reply_markup, @@ -445,6 +453,8 @@ async def edit_message_media( api_kwargs=api_kwargs, chat_id=None, message_id=None, + # inline messages can not be sent on behalf of a bcid + business_connection_id=None, ) return await self._get_message().edit_media( media=media, @@ -516,6 +526,8 @@ async def edit_message_live_location( live_period=live_period, chat_id=None, message_id=None, + # inline messages can not be sent on behalf of a bcid + business_connection_id=None, ) return await self._get_message().edit_live_location( latitude=latitude, @@ -579,6 +591,8 @@ async def stop_message_live_location( api_kwargs=api_kwargs, chat_id=None, message_id=None, + # inline messages can not be sent on behalf of a bcid + business_connection_id=None, ) return await self._get_message().stop_live_location( reply_markup=reply_markup, diff --git a/telegram/_chat.py b/telegram/_chat.py index b5e2d111f1a..200e192c95f 100644 --- a/telegram/_chat.py +++ b/telegram/_chat.py @@ -48,6 +48,7 @@ InputMediaDocument, InputMediaPhoto, InputMediaVideo, + InputPaidMedia, InputPollOption, LabeledPrice, LinkPreviewOptions, @@ -3257,6 +3258,60 @@ async def set_message_reaction( api_kwargs=api_kwargs, ) + async def send_paid_media( + self, + star_count: int, + media: Sequence["InputPaidMedia"], + caption: Optional[str] = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Optional[Sequence["MessageEntity"]] = None, + show_caption_above_media: Optional[bool] = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + reply_parameters: Optional["ReplyParameters"] = None, + reply_markup: Optional[ReplyMarkup] = None, + *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: Optional[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: Optional[JSONDict] = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_paid_media(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.send_paid_media`. + + .. versionadded:: 21.4 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + """ + return await self.get_bot().send_paid_media( + chat_id=self.id, + star_count=star_count, + media=media, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + show_caption_above_media=show_caption_above_media, + disable_notification=disable_notification, + protect_content=protect_content, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + allow_sending_without_reply=allow_sending_without_reply, + reply_to_message_id=reply_to_message_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + class Chat(_ChatBase): """This object represents a chat. diff --git a/telegram/_chatbackground.py b/telegram/_chatbackground.py index 66b58c92a25..f9c77619f4c 100644 --- a/telegram/_chatbackground.py +++ b/telegram/_chatbackground.py @@ -78,7 +78,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["BackgroundFill"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["BackgroundFill"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -267,7 +269,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["BackgroundType"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["BackgroundType"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -528,7 +532,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatBackground"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["ChatBackground"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_chatboost.py b/telegram/_chatboost.py index cb7010a3cd4..7b972eec6d8 100644 --- a/telegram/_chatboost.py +++ b/telegram/_chatboost.py @@ -110,7 +110,9 @@ def __init__(self, source: str, *, api_kwargs: Optional[JSONDict] = None): self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatBoostSource"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["ChatBoostSource"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -275,7 +277,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatBoost"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["ChatBoost"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -325,7 +329,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatBoostUpdated"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["ChatBoostUpdated"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -382,7 +388,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatBoostRemoved"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["ChatBoostRemoved"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -429,7 +437,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["UserChatBoosts"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["UserChatBoosts"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_chatfullinfo.py b/telegram/_chatfullinfo.py index 213baed7ef2..04898659c3c 100644 --- a/telegram/_chatfullinfo.py +++ b/telegram/_chatfullinfo.py @@ -121,7 +121,8 @@ class ChatFullInfo(_ChatBase): .. versionadded:: 20.0 emoji_status_expiration_date (:class:`datetime.datetime`, optional): Expiration date of - emoji status of the chat or the other party in a private chat, in seconds. + emoji status of the chat or the other party in a private chat, as a datetime object, + if any. |datetime_localization| @@ -194,6 +195,10 @@ class ChatFullInfo(_ChatBase): chats. location (:class:`telegram.ChatLocation`, optional): For supergroups, the location to which the supergroup is connected. + can_send_paid_media (:obj:`bool`, optional): :obj:`True`, if paid media messages can be + sent or forwarded to the channel chat. The field is available only for channel chats. + + .. versionadded:: 21.4 Attributes: id (:obj:`int`): Unique identifier for this chat. @@ -270,7 +275,8 @@ class ChatFullInfo(_ChatBase): .. versionadded:: 20.0 emoji_status_expiration_date (:class:`datetime.datetime`): Optional. Expiration date of - emoji status of the chat or the other party in a private chat, in seconds. + emoji status of the chat or the other party in a private chat, as a datetime object, + if any. |datetime_localization| @@ -343,6 +349,10 @@ class ChatFullInfo(_ChatBase): chats. location (:class:`telegram.ChatLocation`): Optional. For supergroups, the location to which the supergroup is connected. + can_send_paid_media (:obj:`bool`): Optional. :obj:`True`, if paid media messages can be + sent or forwarded to the channel chat. The field is available only for channel chats. + + .. versionadded:: 21.4 .. _accent colors: https://core.telegram.org/bots/api#accent-colors .. _topics: https://telegram.org/blog/topics-in-groups-collectible-usernames#topics-in-groups @@ -358,6 +368,7 @@ class ChatFullInfo(_ChatBase): "business_intro", "business_location", "business_opening_hours", + "can_send_paid_media", "can_set_sticker_set", "custom_emoji_sticker_set_name", "description", @@ -432,6 +443,7 @@ def __init__( custom_emoji_sticker_set_name: Optional[str] = None, linked_chat_id: Optional[int] = None, location: Optional[ChatLocation] = None, + can_send_paid_media: Optional[bool] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -490,13 +502,16 @@ def __init__( self.unrestrict_boost_count: Optional[int] = unrestrict_boost_count self.custom_emoji_sticker_set_name: Optional[str] = custom_emoji_sticker_set_name self.birthdate: Optional[Birthdate] = birthdate - self.personal_chat: Optional["Chat"] = personal_chat - self.business_intro: Optional["BusinessIntro"] = business_intro - self.business_location: Optional["BusinessLocation"] = business_location - self.business_opening_hours: Optional["BusinessOpeningHours"] = business_opening_hours + self.personal_chat: Optional[Chat] = personal_chat + self.business_intro: Optional[BusinessIntro] = business_intro + self.business_location: Optional[BusinessLocation] = business_location + self.business_opening_hours: Optional[BusinessOpeningHours] = business_opening_hours + self.can_send_paid_media: Optional[bool] = can_send_paid_media @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatFullInfo"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["ChatFullInfo"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_chatinvitelink.py b/telegram/_chatinvitelink.py index 18799d9028a..43e7e8ab62d 100644 --- a/telegram/_chatinvitelink.py +++ b/telegram/_chatinvitelink.py @@ -151,7 +151,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatInviteLink"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["ChatInviteLink"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_chatjoinrequest.py b/telegram/_chatjoinrequest.py index 4d6fdf88581..9c444d97b4d 100644 --- a/telegram/_chatjoinrequest.py +++ b/telegram/_chatjoinrequest.py @@ -129,7 +129,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatJoinRequest"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["ChatJoinRequest"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_chatlocation.py b/telegram/_chatlocation.py index 3a3c561fc07..04f9854a23a 100644 --- a/telegram/_chatlocation.py +++ b/telegram/_chatlocation.py @@ -68,7 +68,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatLocation"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["ChatLocation"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_chatmember.py b/telegram/_chatmember.py index b399af30e28..0cc06bf5804 100644 --- a/telegram/_chatmember.py +++ b/telegram/_chatmember.py @@ -104,7 +104,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatMember"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["ChatMember"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_chatmemberupdated.py b/telegram/_chatmemberupdated.py index c51b88b7b3b..1aacb218533 100644 --- a/telegram/_chatmemberupdated.py +++ b/telegram/_chatmemberupdated.py @@ -141,7 +141,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatMemberUpdated"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["ChatMemberUpdated"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_chatpermissions.py b/telegram/_chatpermissions.py index 1bda731072e..c4e9e94b7a9 100644 --- a/telegram/_chatpermissions.py +++ b/telegram/_chatpermissions.py @@ -231,7 +231,9 @@ def no_permissions(cls) -> "ChatPermissions": return cls(*(14 * (False,))) @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatPermissions"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["ChatPermissions"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_choseninlineresult.py b/telegram/_choseninlineresult.py index bef8fbb3164..76380e95839 100644 --- a/telegram/_choseninlineresult.py +++ b/telegram/_choseninlineresult.py @@ -92,7 +92,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChosenInlineResult"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["ChosenInlineResult"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_files/_basethumbedmedium.py b/telegram/_files/_basethumbedmedium.py index 6212e38f69a..20ff82eab5e 100644 --- a/telegram/_files/_basethumbedmedium.py +++ b/telegram/_files/_basethumbedmedium.py @@ -44,7 +44,7 @@ class _BaseThumbedMedium(_BaseMedium): is supposed to be the same over time and for different bots. Can't be used to download or reuse the file. file_size (:obj:`int`, optional): File size. - thumbnail (:class:`telegram.PhotoSize`, optional): Thumbnail as defined by sender. + thumbnail (:class:`telegram.PhotoSize`, optional): Thumbnail as defined by the sender. .. versionadded:: 20.2 @@ -54,7 +54,7 @@ class _BaseThumbedMedium(_BaseMedium): is supposed to be the same over time and for different bots. Can't be used to download or reuse the file. file_size (:obj:`int`): Optional. File size. - thumbnail (:class:`telegram.PhotoSize`): Optional. Thumbnail as defined by sender. + thumbnail (:class:`telegram.PhotoSize`): Optional. Thumbnail as defined by the sender. .. versionadded:: 20.2 @@ -82,7 +82,7 @@ def __init__( @classmethod def de_json( - cls: Type[ThumbedMT_co], data: Optional[JSONDict], bot: "Bot" + cls: Type[ThumbedMT_co], data: Optional[JSONDict], bot: Optional["Bot"] = None ) -> Optional[ThumbedMT_co]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_files/animation.py b/telegram/_files/animation.py index 3459e642778..5191ce83d89 100644 --- a/telegram/_files/animation.py +++ b/telegram/_files/animation.py @@ -39,11 +39,11 @@ class Animation(_BaseThumbedMedium): file_unique_id (:obj:`str`): Unique identifier for this file, which is supposed to be the same over time and for different bots. Can't be used to download or reuse the file. - width (:obj:`int`): Video width as defined by sender. - height (:obj:`int`): Video height as defined by sender. - duration (:obj:`int`): Duration of the video in seconds as defined by sender. - file_name (:obj:`str`, optional): Original animation filename as defined by sender. - mime_type (:obj:`str`, optional): MIME type of the file as defined by sender. + width (:obj:`int`): Video width as defined by the sender. + height (:obj:`int`): Video height as defined by the sender. + duration (:obj:`int`): Duration of the video in seconds as defined by the sender. + file_name (:obj:`str`, optional): Original animation filename as defined by the sender. + mime_type (:obj:`str`, optional): MIME type of the file as defined by the sender. file_size (:obj:`int`, optional): File size in bytes. thumbnail (:class:`telegram.PhotoSize`, optional): Animation thumbnail as defined by sender. @@ -56,11 +56,11 @@ class Animation(_BaseThumbedMedium): file_unique_id (:obj:`str`): Unique identifier for this file, which is supposed to be the same over time and for different bots. Can't be used to download or reuse the file. - width (:obj:`int`): Video width as defined by sender. - height (:obj:`int`): Video height as defined by sender. - duration (:obj:`int`): Duration of the video in seconds as defined by sender. - file_name (:obj:`str`): Optional. Original animation filename as defined by sender. - mime_type (:obj:`str`): Optional. MIME type of the file as defined by sender. + width (:obj:`int`): Video width as defined by the sender. + height (:obj:`int`): Video height as defined by the sender. + duration (:obj:`int`): Duration of the video in seconds as defined by the sender. + file_name (:obj:`str`): Optional. Original animation filename as defined by the sender. + mime_type (:obj:`str`): Optional. MIME type of the file as defined by the sender. file_size (:obj:`int`): Optional. File size in bytes. thumbnail (:class:`telegram.PhotoSize`): Optional. Animation thumbnail as defined by sender. diff --git a/telegram/_files/audio.py b/telegram/_files/audio.py index bf5eb123d00..fb7bc2ce7d1 100644 --- a/telegram/_files/audio.py +++ b/telegram/_files/audio.py @@ -39,12 +39,12 @@ class Audio(_BaseThumbedMedium): or reuse the file. file_unique_id (:obj:`str`): Unique identifier for this file, which is supposed to be the same over time and for different bots. Can't be used to download or reuse the file. - duration (:obj:`int`): Duration of the audio in seconds as defined by sender. - performer (:obj:`str`, optional): Performer of the audio as defined by sender or by audio - tags. - title (:obj:`str`, optional): Title of the audio as defined by sender or by audio tags. - file_name (:obj:`str`, optional): Original filename as defined by sender. - mime_type (:obj:`str`, optional): MIME type of the file as defined by sender. + duration (:obj:`int`): Duration of the audio in seconds as defined by the sender. + performer (:obj:`str`, optional): Performer of the audio as defined by the sender or by + audio tags. + title (:obj:`str`, optional): Title of the audio as defined by the sender or by audio tags. + file_name (:obj:`str`, optional): Original filename as defined by the sender. + mime_type (:obj:`str`, optional): MIME type of the file as defined by the sender. file_size (:obj:`int`, optional): File size in bytes. thumbnail (:class:`telegram.PhotoSize`, optional): Thumbnail of the album cover to which the music file belongs. @@ -56,12 +56,12 @@ class Audio(_BaseThumbedMedium): or reuse the file. file_unique_id (:obj:`str`): Unique identifier for this file, which is supposed to be the same over time and for different bots. Can't be used to download or reuse the file. - duration (:obj:`int`): Duration of the audio in seconds as defined by sender. - performer (:obj:`str`): Optional. Performer of the audio as defined by sender or by audio - tags. - title (:obj:`str`): Optional. Title of the audio as defined by sender or by audio tags. - file_name (:obj:`str`): Optional. Original filename as defined by sender. - mime_type (:obj:`str`): Optional. MIME type of the file as defined by sender. + duration (:obj:`int`): Duration of the audio in seconds as defined by the sender. + performer (:obj:`str`): Optional. Performer of the audio as defined by the sender or by + audio tags. + title (:obj:`str`): Optional. Title of the audio as defined by the sender or by audio tags. + file_name (:obj:`str`): Optional. Original filename as defined by the sender. + mime_type (:obj:`str`): Optional. MIME type of the file as defined by the sender. file_size (:obj:`int`): Optional. File size in bytes. thumbnail (:class:`telegram.PhotoSize`): Optional. Thumbnail of the album cover to which the music file belongs. diff --git a/telegram/_files/document.py b/telegram/_files/document.py index a281ffefeaf..e278dc43e3b 100644 --- a/telegram/_files/document.py +++ b/telegram/_files/document.py @@ -39,10 +39,11 @@ class Document(_BaseThumbedMedium): or reuse the file. file_unique_id (:obj:`str`): Unique identifier for this file, which is supposed to be the same over time and for different bots. Can't be used to download or reuse the file. - file_name (:obj:`str`, optional): Original filename as defined by sender. - mime_type (:obj:`str`, optional): MIME type of the file as defined by sender. + file_name (:obj:`str`, optional): Original filename as defined by the sender. + mime_type (:obj:`str`, optional): MIME type of the file as defined by the sender. file_size (:obj:`int`, optional): File size in bytes. - thumbnail (:class:`telegram.PhotoSize`, optional): Document thumbnail as defined by sender. + thumbnail (:class:`telegram.PhotoSize`, optional): Document thumbnail as defined by the + sender. .. versionadded:: 20.2 @@ -51,10 +52,11 @@ class Document(_BaseThumbedMedium): or reuse the file. file_unique_id (:obj:`str`): Unique identifier for this file, which is supposed to be the same over time and for different bots. Can't be used to download or reuse the file. - file_name (:obj:`str`): Optional. Original filename as defined by sender. - mime_type (:obj:`str`): Optional. MIME type of the file as defined by sender. + file_name (:obj:`str`): Optional. Original filename as defined by the sender. + mime_type (:obj:`str`): Optional. MIME type of the file as defined by the sender. file_size (:obj:`int`): Optional. File size in bytes. - thumbnail (:class:`telegram.PhotoSize`): Optional. Document thumbnail as defined by sender. + thumbnail (:class:`telegram.PhotoSize`): Optional. Document thumbnail as defined by the + sender. .. versionadded:: 20.2 diff --git a/telegram/_files/inputmedia.py b/telegram/_files/inputmedia.py index 0cf5955a4d3..692369130a4 100644 --- a/telegram/_files/inputmedia.py +++ b/telegram/_files/inputmedia.py @@ -17,7 +17,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """Base class for Telegram InputMedia Objects.""" -from typing import Optional, Sequence, Tuple, Union +from typing import Final, Optional, Sequence, Tuple, Union from telegram import constants from telegram._files.animation import Animation @@ -115,13 +115,162 @@ def _parse_thumbnail_input(thumbnail: Optional[FileInput]) -> Optional[Union[str ) +class InputPaidMedia(TelegramObject): + """ + Base class for Telegram InputPaidMedia Objects. Currently, it can be one of: + + * :class:`telegram.InputMediaPhoto` + * :class:`telegram.InputMediaVideo` + + .. seealso:: :wiki:`Working with Files and Media ` + + .. versionadded:: 21.4 + + Args: + type (:obj:`str`): Type of media that the instance represents. + media (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | \ + :class:`telegram.PhotoSize` | :class:`telegram.Video`): File to send. |fileinputnopath| + Lastly you can pass an existing telegram media object of the corresponding type + to send. + + Attributes: + type (:obj:`str`): Type of the input media. + media (:obj:`str` | :class:`telegram.InputFile`): Media to send. + """ + + PHOTO: Final[str] = constants.InputPaidMediaType.PHOTO + """:const:`telegram.constants.InputPaidMediaType.PHOTO`""" + VIDEO: Final[str] = constants.InputPaidMediaType.VIDEO + """:const:`telegram.constants.InputPaidMediaType.VIDEO`""" + + __slots__ = ("media", "type") + + def __init__( + self, + type: str, # pylint: disable=redefined-builtin + media: Union[str, InputFile, PhotoSize, Video], + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.type: str = enum.get_member(constants.InputPaidMediaType, type, type) + self.media: Union[str, InputFile, PhotoSize, Video] = media + + self._freeze() + + +class InputPaidMediaPhoto(InputPaidMedia): + """The paid media to send is a photo. + + .. seealso:: :wiki:`Working with Files and Media ` + + .. versionadded:: 21.4 + + Args: + media (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | \ + :class:`telegram.PhotoSize`): File to send. |fileinputnopath| + Lastly you can pass an existing :class:`telegram.PhotoSize` object to send. + + Attributes: + type (:obj:`str`): Type of the media, always + :tg-const:`telegram.constants.InputPaidMediaType.PHOTO`. + media (:obj:`str` | :class:`telegram.InputFile`): Photo to send. + """ + + __slots__ = () + + def __init__( + self, + media: Union[FileInput, PhotoSize], + *, + api_kwargs: Optional[JSONDict] = None, + ): + media = parse_file_input(media, PhotoSize, attach=True, local_mode=True) + super().__init__(type=InputPaidMedia.PHOTO, media=media, api_kwargs=api_kwargs) + self._freeze() + + +class InputPaidMediaVideo(InputPaidMedia): + """ + The paid media to send is a video. + + .. seealso:: :wiki:`Working with Files and Media ` + + .. versionadded:: 21.4 + + Note: + * When using a :class:`telegram.Video` for the :attr:`media` attribute, it will take the + width, height and duration from that video, unless otherwise specified with the optional + arguments. + * :paramref:`thumbnail` will be ignored for small video files, for which Telegram can + easily generate thumbnails. However, this behaviour is undocumented and might be + changed by Telegram. + + Args: + media (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | \ + :class:`telegram.Video`): File to send. |fileinputnopath| + Lastly you can pass an existing :class:`telegram.Video` object to send. + thumbnail (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ + optional): |thumbdocstringnopath| + width (:obj:`int`, optional): Video width. + height (:obj:`int`, optional): Video height. + duration (:obj:`int`, optional): Video duration in seconds. + supports_streaming (:obj:`bool`, optional): Pass :obj:`True`, if the uploaded video is + suitable for streaming. + + Attributes: + type (:obj:`str`): Type of the media, always + :tg-const:`telegram.constants.InputPaidMediaType.VIDEO`. + media (:obj:`str` | :class:`telegram.InputFile`): Video to send. + thumbnail (:class:`telegram.InputFile`): Optional. |thumbdocstringbase| + width (:obj:`int`): Optional. Video width. + height (:obj:`int`): Optional. Video height. + duration (:obj:`int`): Optional. Video duration in seconds. + supports_streaming (:obj:`bool`): Optional. :obj:`True`, if the uploaded video is + suitable for streaming. + """ + + __slots__ = ("duration", "height", "supports_streaming", "thumbnail", "width") + + def __init__( + self, + media: Union[FileInput, Video], + thumbnail: Optional[FileInput] = None, + width: Optional[int] = None, + height: Optional[int] = None, + duration: Optional[int] = None, + supports_streaming: Optional[bool] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + if isinstance(media, Video): + width = width if width is not None else media.width + height = height if height is not None else media.height + duration = duration if duration is not None else media.duration + media = media.file_id + else: + # We use local_mode=True because we don't have access to the actual setting and want + # things to work in local mode. + media = parse_file_input(media, attach=True, local_mode=True) + + super().__init__(type=InputPaidMedia.VIDEO, media=media, api_kwargs=api_kwargs) + with self._unfrozen(): + self.thumbnail: Optional[Union[str, InputFile]] = InputMedia._parse_thumbnail_input( + thumbnail + ) + self.width: Optional[int] = width + self.height: Optional[int] = height + self.duration: Optional[int] = duration + self.supports_streaming: Optional[bool] = supports_streaming + + class InputMediaAnimation(InputMedia): """Represents an animation file (GIF or H.264/MPEG-4 AVC video without sound) to be sent. Note: When using a :class:`telegram.Animation` for the :attr:`media` attribute, it will take the - width, height and duration from that video, unless otherwise specified with the optional - arguments. + width, height and duration from that animation, unless otherwise specified with the + optional arguments. .. seealso:: :wiki:`Working with Files and Media ` @@ -510,10 +659,10 @@ class InputMediaAudio(InputMedia): .. versionchanged:: 20.0 |sequenceclassargs| - duration (:obj:`int`, optional): Duration of the audio in seconds as defined by sender. - performer (:obj:`str`, optional): Performer of the audio as defined by sender or by audio - tags. - title (:obj:`str`, optional): Title of the audio as defined by sender or by audio tags. + duration (:obj:`int`, optional): Duration of the audio in seconds as defined by the sender. + performer (:obj:`str`, optional): Performer of the audio as defined by the sender or by + audio tags. + title (:obj:`str`, optional): Title of the audio as defined by the sender or by audio tags. thumbnail (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ optional): |thumbdocstringnopath| @@ -533,9 +682,9 @@ class InputMediaAudio(InputMedia): * |tupleclassattrs| * |alwaystuple| duration (:obj:`int`): Optional. Duration of the audio in seconds. - performer (:obj:`str`): Optional. Performer of the audio as defined by sender or by audio - tags. - title (:obj:`str`): Optional. Title of the audio as defined by sender or by audio tags. + performer (:obj:`str`): Optional. Performer of the audio as defined by the sender or by + audio tags. + title (:obj:`str`): Optional. Title of the audio as defined by the sender or by audio tags. thumbnail (:class:`telegram.InputFile`): Optional. |thumbdocstringbase| .. versionadded:: 20.2 diff --git a/telegram/_files/location.py b/telegram/_files/location.py index 45401868720..b2e1458d17f 100644 --- a/telegram/_files/location.py +++ b/telegram/_files/location.py @@ -32,8 +32,8 @@ class Location(TelegramObject): considered equal, if their :attr:`longitude` and :attr:`latitude` are equal. Args: - longitude (:obj:`float`): Longitude as defined by sender. - latitude (:obj:`float`): Latitude as defined by sender. + longitude (:obj:`float`): Longitude as defined by the sender. + latitude (:obj:`float`): Latitude as defined by the sender. horizontal_accuracy (:obj:`float`, optional): The radius of uncertainty for the location, measured in meters; 0-:tg-const:`telegram.Location.HORIZONTAL_ACCURACY`. live_period (:obj:`int`, optional): Time relative to the message sending date, during which @@ -45,8 +45,8 @@ class Location(TelegramObject): approaching another chat member, in meters. For sent live locations only. Attributes: - longitude (:obj:`float`): Longitude as defined by sender. - latitude (:obj:`float`): Latitude as defined by sender. + longitude (:obj:`float`): Longitude as defined by the sender. + latitude (:obj:`float`): Latitude as defined by the sender. horizontal_accuracy (:obj:`float`): Optional. The radius of uncertainty for the location, measured in meters; 0-:tg-const:`telegram.Location.HORIZONTAL_ACCURACY`. live_period (:obj:`int`): Optional. Time relative to the message sending date, during which diff --git a/telegram/_files/sticker.py b/telegram/_files/sticker.py index b1194deeeea..3c3c1cd7e72 100644 --- a/telegram/_files/sticker.py +++ b/telegram/_files/sticker.py @@ -193,7 +193,7 @@ def __init__( """:const:`telegram.constants.StickerType.CUSTOM_EMOJI`""" @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Sticker"]: + def de_json(cls, data: Optional[JSONDict], bot: Optional["Bot"] = None) -> Optional["Sticker"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -305,7 +305,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["StickerSet"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["StickerSet"]: """See :meth:`telegram.TelegramObject.de_json`.""" if not data: return None diff --git a/telegram/_files/venue.py b/telegram/_files/venue.py index caf60355533..443bd009c17 100644 --- a/telegram/_files/venue.py +++ b/telegram/_files/venue.py @@ -103,7 +103,7 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Venue"]: + def de_json(cls, data: Optional[JSONDict], bot: Optional["Bot"] = None) -> Optional["Venue"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_files/video.py b/telegram/_files/video.py index d28138c75e1..7a1201c431e 100644 --- a/telegram/_files/video.py +++ b/telegram/_files/video.py @@ -39,11 +39,11 @@ class Video(_BaseThumbedMedium): file_unique_id (:obj:`str`): Unique identifier for this file, which is supposed to be the same over time and for different bots. Can't be used to download or reuse the file. - width (:obj:`int`): Video width as defined by sender. - height (:obj:`int`): Video height as defined by sender. - duration (:obj:`int`): Duration of the video in seconds as defined by sender. - file_name (:obj:`str`, optional): Original filename as defined by sender. - mime_type (:obj:`str`, optional): MIME type of a file as defined by sender. + width (:obj:`int`): Video width as defined by the sender. + height (:obj:`int`): Video height as defined by the sender. + duration (:obj:`int`): Duration of the video in seconds as defined by the sender. + file_name (:obj:`str`, optional): Original filename as defined by the sender. + mime_type (:obj:`str`, optional): MIME type of a file as defined by the sender. file_size (:obj:`int`, optional): File size in bytes. thumbnail (:class:`telegram.PhotoSize`, optional): Video thumbnail. @@ -55,11 +55,11 @@ class Video(_BaseThumbedMedium): file_unique_id (:obj:`str`): Unique identifier for this file, which is supposed to be the same over time and for different bots. Can't be used to download or reuse the file. - width (:obj:`int`): Video width as defined by sender. - height (:obj:`int`): Video height as defined by sender. - duration (:obj:`int`): Duration of the video in seconds as defined by sender. - file_name (:obj:`str`): Optional. Original filename as defined by sender. - mime_type (:obj:`str`): Optional. MIME type of a file as defined by sender. + width (:obj:`int`): Video width as defined by the sender. + height (:obj:`int`): Video height as defined by the sender. + duration (:obj:`int`): Duration of the video in seconds as defined by the sender. + file_name (:obj:`str`): Optional. Original filename as defined by the sender. + mime_type (:obj:`str`): Optional. MIME type of a file as defined by the sender. file_size (:obj:`int`): Optional. File size in bytes. thumbnail (:class:`telegram.PhotoSize`): Optional. Video thumbnail. diff --git a/telegram/_files/videonote.py b/telegram/_files/videonote.py index 2a1f760c1d5..15b23a69bf2 100644 --- a/telegram/_files/videonote.py +++ b/telegram/_files/videonote.py @@ -42,7 +42,7 @@ class VideoNote(_BaseThumbedMedium): Can't be used to download or reuse the file. length (:obj:`int`): Video width and height (diameter of the video message) as defined by sender. - duration (:obj:`int`): Duration of the video in seconds as defined by sender. + duration (:obj:`int`): Duration of the video in seconds as defined by the sender. file_size (:obj:`int`, optional): File size in bytes. thumbnail (:class:`telegram.PhotoSize`, optional): Video thumbnail. @@ -56,7 +56,7 @@ class VideoNote(_BaseThumbedMedium): Can't be used to download or reuse the file. length (:obj:`int`): Video width and height (diameter of the video message) as defined by sender. - duration (:obj:`int`): Duration of the video in seconds as defined by sender. + duration (:obj:`int`): Duration of the video in seconds as defined by the sender. file_size (:obj:`int`): Optional. File size in bytes. thumbnail (:class:`telegram.PhotoSize`): Optional. Video thumbnail. diff --git a/telegram/_files/voice.py b/telegram/_files/voice.py index 6c1f4dfb289..ae4fa1d6195 100644 --- a/telegram/_files/voice.py +++ b/telegram/_files/voice.py @@ -35,8 +35,8 @@ class Voice(_BaseMedium): file_unique_id (:obj:`str`): Unique identifier for this file, which is supposed to be the same over time and for different bots. Can't be used to download or reuse the file. - duration (:obj:`int`): Duration of the audio in seconds as defined by sender. - mime_type (:obj:`str`, optional): MIME type of the file as defined by sender. + duration (:obj:`int`): Duration of the audio in seconds as defined by the sender. + mime_type (:obj:`str`, optional): MIME type of the file as defined by the sender. file_size (:obj:`int`, optional): File size in bytes. Attributes: @@ -45,8 +45,8 @@ class Voice(_BaseMedium): file_unique_id (:obj:`str`): Unique identifier for this file, which is supposed to be the same over time and for different bots. Can't be used to download or reuse the file. - duration (:obj:`int`): Duration of the audio in seconds as defined by sender. - mime_type (:obj:`str`): Optional. MIME type of the file as defined by sender. + duration (:obj:`int`): Duration of the audio in seconds as defined by the sender. + mime_type (:obj:`str`): Optional. MIME type of the file as defined by the sender. file_size (:obj:`int`): Optional. File size in bytes. """ diff --git a/telegram/_games/game.py b/telegram/_games/game.py index 8eff71a0a61..93b3f0161cc 100644 --- a/telegram/_games/game.py +++ b/telegram/_games/game.py @@ -122,7 +122,7 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Game"]: + def de_json(cls, data: Optional[JSONDict], bot: Optional["Bot"] = None) -> Optional["Game"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_games/gamehighscore.py b/telegram/_games/gamehighscore.py index 991255fe1d5..40f93fadd49 100644 --- a/telegram/_games/gamehighscore.py +++ b/telegram/_games/gamehighscore.py @@ -61,7 +61,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["GameHighScore"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["GameHighScore"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_giveaway.py b/telegram/_giveaway.py index 3251898031d..0008dc9dd4c 100644 --- a/telegram/_giveaway.py +++ b/telegram/_giveaway.py @@ -123,7 +123,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Giveaway"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["Giveaway"]: """See :obj:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -257,7 +259,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["GiveawayWinners"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["GiveawayWinners"]: """See :obj:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -313,7 +317,7 @@ def __init__( self.winner_count: int = winner_count self.unclaimed_prize_count: Optional[int] = unclaimed_prize_count - self.giveaway_message: Optional["Message"] = giveaway_message + self.giveaway_message: Optional[Message] = giveaway_message self._id_attrs = ( self.winner_count, @@ -323,7 +327,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["GiveawayCompleted"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["GiveawayCompleted"]: """See :obj:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_inline/inlinekeyboardbutton.py b/telegram/_inline/inlinekeyboardbutton.py index d88f222cad7..6ceca1a311b 100644 --- a/telegram/_inline/inlinekeyboardbutton.py +++ b/telegram/_inline/inlinekeyboardbutton.py @@ -46,7 +46,7 @@ class InlineKeyboardButton(TelegramObject): working as expected. Putting a game short name in it might, but is not guaranteed to work. * If your bot allows for arbitrary callback data, in keyboards returned in a response - from telegram, :attr:`callback_data` maybe be an instance of + from telegram, :attr:`callback_data` may be an instance of :class:`telegram.ext.InvalidCallbackData`. This will be the case, if the data associated with the button was already deleted. @@ -89,10 +89,9 @@ class InlineKeyboardButton(TelegramObject): Caution: Only ``HTTPS`` links are allowed after Bot API 6.1. callback_data (:obj:`str` | :obj:`object`, optional): Data to be sent in a callback query - to the bot when button is pressed, UTF-8 + to the bot when the button is pressed, UTF-8 :tg-const:`telegram.InlineKeyboardButton.MIN_CALLBACK_DATA`- :tg-const:`telegram.InlineKeyboardButton.MAX_CALLBACK_DATA` bytes. - Not supported for messages sent on behalf of a Telegram Business account. If the bot instance allows arbitrary callback data, anything can be passed. Tip: @@ -120,9 +119,12 @@ class InlineKeyboardButton(TelegramObject): This is similar to the new parameter :paramref:`switch_inline_query_chosen_chat`, but gives no control over which chats can be selected. switch_inline_query_current_chat (:obj:`str`, optional): If set, pressing the button will - prompt the user to select one of their chats of the specified type, open that chat and - insert the bot's username and the specified inline query in the input field. Not - supported for messages sent on behalf of a Telegram Business account. + insert the bot's username and the specified inline query in the current chat's input + field. May be empty, in which case only the bot's username will be inserted. + + This offers a quick way for the user to open your bot in inline mode in the same chat + - good for selecting something from multiple options. Not supported in channels and for + messages sent on behalf of a Telegram Business account. callback_game (:class:`telegram.CallbackGame`, optional): Description of the game that will be launched when the user presses the button @@ -165,10 +167,9 @@ class InlineKeyboardButton(TelegramObject): Caution: Only ``HTTPS`` links are allowed after Bot API 6.1. callback_data (:obj:`str` | :obj:`object`): Optional. Data to be sent in a callback query - to the bot when button is pressed, UTF-8 + to the bot when the button is pressed, UTF-8 :tg-const:`telegram.InlineKeyboardButton.MIN_CALLBACK_DATA`- :tg-const:`telegram.InlineKeyboardButton.MAX_CALLBACK_DATA` bytes. - Not supported for messages sent on behalf of a Telegram Business account. web_app (:obj:`telegram.WebAppInfo`): Optional. Description of the `Web App `_ that will be launched when the user presses the button. The Web App will be able to send an arbitrary message on behalf of the user @@ -189,9 +190,12 @@ class InlineKeyboardButton(TelegramObject): This is similar to the new parameter :paramref:`switch_inline_query_chosen_chat`, but gives no control over which chats can be selected. switch_inline_query_current_chat (:obj:`str`): Optional. If set, pressing the button will - prompt the user to select one of their chats of the specified type, open that chat and - insert the bot's username and the specified inline query in the input field. Not - supported for messages sent on behalf of a Telegram Business account. + insert the bot's username and the specified inline query in the current chat's input + field. May be empty, in which case only the bot's username will be inserted. + + This offers a quick way for the user to open your bot in inline mode in the same chat + - good for selecting something from multiple options. Not supported in channels and for + messages sent on behalf of a Telegram Business account. callback_game (:class:`telegram.CallbackGame`): Optional. Description of the game that will be launched when the user presses the button. @@ -284,7 +288,9 @@ def _set_id_attrs(self) -> None: ) @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["InlineKeyboardButton"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["InlineKeyboardButton"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_inline/inlinekeyboardmarkup.py b/telegram/_inline/inlinekeyboardmarkup.py index c806318246a..6857e4d8e3a 100644 --- a/telegram/_inline/inlinekeyboardmarkup.py +++ b/telegram/_inline/inlinekeyboardmarkup.py @@ -42,7 +42,7 @@ class InlineKeyboardMarkup(TelegramObject): An inline keyboard on a message .. seealso:: - An another kind of keyboard would be the :class:`telegram.ReplyKeyboardMarkup`. + Another kind of keyboard would be the :class:`telegram.ReplyKeyboardMarkup`. Examples: * :any:`Inline Keyboard 1 ` @@ -90,7 +90,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["InlineKeyboardMarkup"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["InlineKeyboardMarkup"]: """See :meth:`telegram.TelegramObject.de_json`.""" if not data: return None diff --git a/telegram/_inline/inlinequery.py b/telegram/_inline/inlinequery.py index cc9044beb18..ba29a8646fe 100644 --- a/telegram/_inline/inlinequery.py +++ b/telegram/_inline/inlinequery.py @@ -125,7 +125,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["InlineQuery"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["InlineQuery"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_inline/inlinequeryresultsbutton.py b/telegram/_inline/inlinequeryresultsbutton.py index da850340cac..ae0b404e1f8 100644 --- a/telegram/_inline/inlinequeryresultsbutton.py +++ b/telegram/_inline/inlinequeryresultsbutton.py @@ -97,7 +97,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["InlineQueryResultsButton"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["InlineQueryResultsButton"]: """See :meth:`telegram.TelegramObject.de_json`.""" if not data: return None diff --git a/telegram/_inline/inputinvoicemessagecontent.py b/telegram/_inline/inputinvoicemessagecontent.py index 74ea97de297..e13642da562 100644 --- a/telegram/_inline/inputinvoicemessagecontent.py +++ b/telegram/_inline/inputinvoicemessagecontent.py @@ -66,14 +66,13 @@ class InputInvoiceMessageContent(InputMessageContent): |sequenceclassargs| max_tip_amount (:obj:`int`, optional): The maximum accepted amount for tips in the - *smallest* units of the currency (integer, **not** float/double). For example, for a - maximum tip of US$ 1.45 pass ``max_tip_amount = 145``. See the ``exp`` parameter in + *smallest units* of the currency (integer, **not** float/double). For example, for a + maximum tip of ``US$ 1.45`` pass ``max_tip_amount = 145``. See the ``exp`` parameter in `currencies.json `_, it shows the number of digits past the decimal point for each currency (2 for the majority - of currencies). Defaults to ``0``. Defaults to ``0``. Not supported for payments in - |tg_stars|. + of currencies). Defaults to ``0``. Not supported for payments in |tg_stars|. suggested_tip_amounts (Sequence[:obj:`int`], optional): An array of suggested - amounts of tip in the *smallest* units of the currency (integer, **not** float/double). + amounts of tip in the *smallest units* of the currency (integer, **not** float/double). At most 4 suggested tip amounts can be specified. The suggested tip amounts must be positive, passed in a strictly increased order and must not exceed :attr:`max_tip_amount`. @@ -131,13 +130,13 @@ class InputInvoiceMessageContent(InputMessageContent): |tupleclassattrs| max_tip_amount (:obj:`int`): Optional. The maximum accepted amount for tips in the - *smallest* units of the currency (integer, **not** float/double). For example, for a - maximum tip of US$ 1.45 ``max_tip_amount`` is ``145``. See the ``exp`` parameter in + *smallest units* of the currency (integer, **not** float/double). For example, for a + maximum tip of ``US$ 1.45`` ``max_tip_amount`` is ``145``. See the ``exp`` parameter in `currencies.json `_, it shows the number of digits past the decimal point for each currency (2 for the majority of currencies). Defaults to ``0``. Not supported for payments in |tg_stars|. suggested_tip_amounts (Tuple[:obj:`int`]): Optional. An array of suggested - amounts of tip in the *smallest* units of the currency (integer, **not** float/double). + amounts of tip in the *smallest units* of the currency (integer, **not** float/double). At most 4 suggested tip amounts can be specified. The suggested tip amounts must be positive, passed in a strictly increased order and must not exceed :attr:`max_tip_amount`. @@ -255,7 +254,7 @@ def __init__( @classmethod def de_json( - cls, data: Optional[JSONDict], bot: "Bot" + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None ) -> Optional["InputInvoiceMessageContent"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_inline/inputtextmessagecontent.py b/telegram/_inline/inputtextmessagecontent.py index 0e127ce70a7..475f9c5bb28 100644 --- a/telegram/_inline/inputtextmessagecontent.py +++ b/telegram/_inline/inputtextmessagecontent.py @@ -108,7 +108,7 @@ def __init__( # Optionals self.parse_mode: ODVInput[str] = parse_mode self.entities: Tuple[MessageEntity, ...] = parse_sequence_arg(entities) - self.link_preview_options: ODVInput["LinkPreviewOptions"] = parse_lpo_and_dwpp( + self.link_preview_options: ODVInput[LinkPreviewOptions] = parse_lpo_and_dwpp( disable_web_page_preview, link_preview_options ) diff --git a/telegram/_keyboardbutton.py b/telegram/_keyboardbutton.py index 0cb4cd82e65..ad08f2f98ad 100644 --- a/telegram/_keyboardbutton.py +++ b/telegram/_keyboardbutton.py @@ -168,7 +168,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["KeyboardButton"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["KeyboardButton"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_keyboardbuttonrequest.py b/telegram/_keyboardbuttonrequest.py index fa94433ebd9..4416952112e 100644 --- a/telegram/_keyboardbuttonrequest.py +++ b/telegram/_keyboardbuttonrequest.py @@ -30,15 +30,12 @@ class KeyboardButtonRequestUsers(TelegramObject): """This object defines the criteria used to request a suitable user. The identifier of the - selected user will be shared with the bot when the corresponding button is pressed. + selected user will be shared with the bot when the corresponding button is pressed. `More + about requesting users » `_. Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their :attr:`request_id` is equal. - .. seealso:: - `Telegram Docs on requesting users \ - `_ - .. versionadded:: 20.8 This class was previously named ``KeyboardButtonRequestUser``. @@ -136,15 +133,12 @@ def __init__( class KeyboardButtonRequestChat(TelegramObject): """This object defines the criteria used to request a suitable chat. The identifier of the - selected user will be shared with the bot when the corresponding button is pressed. + selected user will be shared with the bot when the corresponding button is pressed. `More + about requesting users » `_. Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their :attr:`request_id` is equal. - .. seealso:: - `Telegram Docs on requesting chats \ - `_ - .. versionadded:: 20.1 Args: @@ -264,7 +258,7 @@ def __init__( @classmethod def de_json( - cls, data: Optional[JSONDict], bot: "Bot" + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None ) -> Optional["KeyboardButtonRequestChat"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_menubutton.py b/telegram/_menubutton.py index 2044fb551fe..50b6511b08d 100644 --- a/telegram/_menubutton.py +++ b/telegram/_menubutton.py @@ -69,13 +69,19 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["MenuButton"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["MenuButton"]: """Converts JSON data to the appropriate :class:`MenuButton` object, i.e. takes care of selecting the correct subclass. Args: data (Dict[:obj:`str`, ...]): The JSON data. - bot (:class:`telegram.Bot`): The bot associated with this object. + bot (:class:`telegram.Bot`, optional): The bot associated with this object. Defaults to + :obj:`None`, in which case shortcut methods will not be available. + + .. versionchanged:: 21.4 + :paramref:`bot` is now optional and defaults to :obj:`None` Returns: The Telegram object. @@ -139,7 +145,10 @@ class MenuButtonWebApp(MenuButton): web_app (:class:`telegram.WebAppInfo`): Description of the Web App that will be launched when the user presses the button. The Web App will be able to send an arbitrary message on behalf of the user using the method :meth:`~telegram.Bot.answerWebAppQuery` - of :class:`~telegram.Bot`. + of :class:`~telegram.Bot`. Alternatively, a ``t.me`` link to a Web App of the bot can + be specified in the object instead of the Web App's URL, in which case the Web App + will be opened as if the user pressed the link. + Attributes: type (:obj:`str`): :tg-const:`telegram.constants.MenuButtonType.WEB_APP`. @@ -147,7 +156,9 @@ class MenuButtonWebApp(MenuButton): web_app (:class:`telegram.WebAppInfo`): Description of the Web App that will be launched when the user presses the button. The Web App will be able to send an arbitrary message on behalf of the user using the method :meth:`~telegram.Bot.answerWebAppQuery` - of :class:`~telegram.Bot`. + of :class:`~telegram.Bot`. Alternatively, a ``t.me`` link to a Web App of the bot can + be specified in the object instead of the Web App's URL, in which case the Web App + will be opened as if the user pressed the link. """ __slots__ = ("text", "web_app") @@ -161,7 +172,9 @@ def __init__(self, text: str, web_app: WebAppInfo, *, api_kwargs: Optional[JSOND self._id_attrs = (self.type, self.text, self.web_app) @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["MenuButtonWebApp"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["MenuButtonWebApp"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_message.py b/telegram/_message.py index b0605cd094d..fceb8cb8768 100644 --- a/telegram/_message.py +++ b/telegram/_message.py @@ -52,8 +52,10 @@ from telegram._linkpreviewoptions import LinkPreviewOptions from telegram._messageautodeletetimerchanged import MessageAutoDeleteTimerChanged from telegram._messageentity import MessageEntity +from telegram._paidmedia import PaidMediaInfo from telegram._passport.passportdata import PassportData from telegram._payment.invoice import Invoice +from telegram._payment.refundedpayment import RefundedPayment from telegram._payment.successfulpayment import SuccessfulPayment from telegram._poll import Poll from telegram._proximityalerttriggered import ProximityAlertTriggered @@ -178,7 +180,10 @@ def is_accessible(self) -> bool: @classmethod def _de_json( - cls, data: Optional[JSONDict], bot: "Bot", api_kwargs: Optional[JSONDict] = None + cls, + data: Optional[JSONDict], + bot: Optional["Bot"] = None, + api_kwargs: Optional[JSONDict] = None, ) -> Optional["MaybeInaccessibleMessage"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -331,7 +336,7 @@ class Message(MaybeInaccessibleMessage): effect_id (:obj:`str`, optional): Unique identifier of the message effect added to the message. - ..versionadded:: 21.3 + .. versionadded:: 21.3 caption_entities (Sequence[:class:`telegram.MessageEntity`], optional): For messages with a Caption. Special entities like usernames, URLs, bot commands, etc. that appear in the @@ -353,6 +358,7 @@ class Message(MaybeInaccessibleMessage): about the animation. For backward compatibility, when this field is set, the document field will also be set. game (:class:`telegram.Game`, optional): Message is a game, information about the game. + `More about games >> `_. photo (Sequence[:class:`telegram.PhotoSize`], optional): Message is a photo, available sizes of the photo. This list is empty if the message does not contain a photo. @@ -368,7 +374,8 @@ class Message(MaybeInaccessibleMessage): video. voice (:class:`telegram.Voice`, optional): Message is a voice message, information about the file. - video_note (:class:`telegram.VideoNote`, optional): Message is a video note, information + video_note (:class:`telegram.VideoNote`, optional): Message is a + `video note `_, information about the video message. new_chat_members (Sequence[:class:`telegram.User`], optional): New members that were added to the group or supergroup and information about them (the bot itself may be one of @@ -377,7 +384,8 @@ class Message(MaybeInaccessibleMessage): .. versionchanged:: 20.0 |sequenceclassargs| - caption (:obj:`str`, optional): Caption for the animation, audio, document, photo, video + caption (:obj:`str`, optional): Caption for the animation, audio, document, paid media, + photo, video or voice, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters. contact (:class:`telegram.Contact`, optional): Message is a shared contact, information about the contact. @@ -423,10 +431,13 @@ class Message(MaybeInaccessibleMessage): :class:`telegram.InaccessibleMessage`. invoice (:class:`telegram.Invoice`, optional): Message is an invoice for a payment, information about the invoice. + `More about payments >> `_. successful_payment (:class:`telegram.SuccessfulPayment`, optional): Message is a service message about a successful payment, information about the payment. + `More about payments >> `_. connected_website (:obj:`str`, optional): The domain name of the website on which the user has logged in. + `More about Telegram Login >> `_. author_signature (:obj:`str`, optional): Signature of the post author for messages in channels, or the custom title of an anonymous group administrator. passport_data (:class:`telegram.PassportData`, optional): Telegram Passport data. @@ -568,6 +579,14 @@ class Message(MaybeInaccessibleMessage): background set. .. versionadded:: 21.2 + paid_media (:obj:`telegram.PaidMediaInfo`, optional): Message contains paid media; + information about the paid media. + + .. versionadded:: 21.4 + refunded_payment (:obj:`telegram.RefundedPayment`, optional): Message is a service message + about a refunded payment, information about the payment. + + .. versionadded:: 21.4 Attributes: message_id (:obj:`int`): Unique message identifier inside this chat. @@ -656,6 +675,7 @@ class Message(MaybeInaccessibleMessage): .. seealso:: :wiki:`Working with Files and Media ` game (:class:`telegram.Game`): Optional. Message is a game, information about the game. + `More about games >> `_. photo (Tuple[:class:`telegram.PhotoSize`]): Optional. Message is a photo, available sizes of the photo. This list is empty if the message does not contain a photo. @@ -679,7 +699,8 @@ class Message(MaybeInaccessibleMessage): the file. .. seealso:: :wiki:`Working with Files and Media ` - video_note (:class:`telegram.VideoNote`): Optional. Message is a video note, information + video_note (:class:`telegram.VideoNote`): Optional. Message is a + `video note `_, information about the video message. .. seealso:: :wiki:`Working with Files and Media ` @@ -689,7 +710,8 @@ class Message(MaybeInaccessibleMessage): .. versionchanged:: 20.0 |tupleclassattrs| - caption (:obj:`str`): Optional. Caption for the animation, audio, document, photo, video + caption (:obj:`str`): Optional. Caption for the animation, audio, document, paid media, + photo, video or voice, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters. contact (:class:`telegram.Contact`): Optional. Message is a shared contact, information about the contact. @@ -735,10 +757,13 @@ class Message(MaybeInaccessibleMessage): :class:`telegram.InaccessibleMessage`. invoice (:class:`telegram.Invoice`): Optional. Message is an invoice for a payment, information about the invoice. + `More about payments >> `_. successful_payment (:class:`telegram.SuccessfulPayment`): Optional. Message is a service message about a successful payment, information about the payment. + `More about payments >> `_. connected_website (:obj:`str`): Optional. The domain name of the website on which the user has logged in. + `More about Telegram Login >> `_. author_signature (:obj:`str`): Optional. Signature of the post author for messages in channels, or the custom title of an anonymous group administrator. passport_data (:class:`telegram.PassportData`): Optional. Telegram Passport data. @@ -881,6 +906,14 @@ class Message(MaybeInaccessibleMessage): background set .. versionadded:: 21.2 + paid_media (:obj:`telegram.PaidMediaInfo`): Optional. Message contains paid media; + information about the paid media. + + .. versionadded:: 21.4 + refunded_payment (:obj:`telegram.RefundedPayment`): Optional. Message is a service message + about a refunded payment, information about the payment. + + .. versionadded:: 21.4 .. |custom_emoji_no_md1_support| replace:: Since custom emoji entities are not supported by :attr:`~telegram.constants.ParseMode.MARKDOWN`, this method now raises a @@ -947,12 +980,14 @@ class Message(MaybeInaccessibleMessage): "new_chat_members", "new_chat_photo", "new_chat_title", + "paid_media", "passport_data", "photo", "pinned_message", "poll", "proximity_alert_triggered", "quote", + "refunded_payment", "reply_markup", "reply_to_message", "reply_to_story", @@ -1064,6 +1099,8 @@ def __init__( chat_background_set: Optional[ChatBackground] = None, effect_id: Optional[str] = None, show_caption_above_media: Optional[bool] = None, + paid_media: Optional[PaidMediaInfo] = None, + refunded_payment: Optional[RefundedPayment] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -1165,6 +1202,8 @@ def __init__( self.chat_background_set: Optional[ChatBackground] = chat_background_set self.effect_id: Optional[str] = effect_id self.show_caption_above_media: Optional[bool] = show_caption_above_media + self.paid_media: Optional[PaidMediaInfo] = paid_media + self.refunded_payment: Optional[RefundedPayment] = refunded_payment self._effective_attachment = DEFAULT_NONE @@ -1205,7 +1244,7 @@ def link(self) -> Optional[str]: return None @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Message"]: + def de_json(cls, data: Optional[JSONDict], bot: Optional["Bot"] = None) -> Optional["Message"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -1280,6 +1319,8 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Message"]: data["users_shared"] = UsersShared.de_json(data.get("users_shared"), bot) data["chat_shared"] = ChatShared.de_json(data.get("chat_shared"), bot) data["chat_background_set"] = ChatBackground.de_json(data.get("chat_background_set"), bot) + data["paid_media"] = PaidMediaInfo.de_json(data.get("paid_media"), bot) + data["refunded_payment"] = RefundedPayment.de_json(data.get("refunded_payment"), bot) # Unfortunately, this needs to be here due to cyclic imports from telegram._giveaway import ( # pylint: disable=import-outside-toplevel @@ -1343,6 +1384,7 @@ def effective_attachment( Location, PassportData, Sequence[PhotoSize], + PaidMediaInfo, Poll, Sticker, Story, @@ -1353,8 +1395,8 @@ def effective_attachment( Voice, None, ]: - """If this message is neither a plain text message nor a status update, this gives the - attachment that this message was sent with. This may be one of + """If the message is a user generated content which is not a plain text message, this + property is set to this content. It may be one of * :class:`telegram.Audio` * :class:`telegram.Dice` @@ -1366,6 +1408,7 @@ def effective_attachment( * :class:`telegram.Location` * :class:`telegram.PassportData` * List[:class:`telegram.PhotoSize`] + * :class:`telegram.PaidMediaInfo` * :class:`telegram.Poll` * :class:`telegram.Sticker` * :class:`telegram.Story` @@ -1383,6 +1426,12 @@ def effective_attachment( :attr:`dice`, :attr:`passport_data` and :attr:`poll` are now also considered to be an attachment. + .. versionchanged:: 21.4 + :attr:`paid_media` is now also considered to be an attachment. + + .. deprecated:: 21.4 + :attr:`successful_payment` will be removed in future major versions. + """ if not isinstance(self._effective_attachment, DefaultValue): return self._effective_attachment @@ -1390,6 +1439,15 @@ def effective_attachment( for attachment_type in MessageAttachmentType: if self[attachment_type]: self._effective_attachment = self[attachment_type] # type: ignore[assignment] + if attachment_type == MessageAttachmentType.SUCCESSFUL_PAYMENT: + warn( + PTBDeprecationWarning( + "21.4", + "successful_payment will no longer be considered an attachment in" + " future major versions", + ), + stacklevel=2, + ) break else: self._effective_attachment = None @@ -3578,7 +3636,10 @@ async def edit_text( """Shortcut for:: await bot.edit_message_text( - chat_id=message.chat_id, message_id=message.message_id, *args, **kwargs + chat_id=message.chat_id, + message_id=message.message_id, + business_connection_id=message.business_connection_id, + *args, **kwargs ) For the documentation of the arguments, please see :meth:`telegram.Bot.edit_message_text`. @@ -3588,6 +3649,9 @@ async def edit_text( of methods) or channel posts, if the bot is an admin in that channel. However, this behaviour is undocumented and might be changed by Telegram. + .. versionchanged:: 21.4 + Now also passes :attr:`business_connection_id`. + Returns: :class:`telegram.Message`: On success, if edited message is sent by the bot, the edited Message is returned, otherwise ``True`` is returned. @@ -3608,6 +3672,7 @@ async def edit_text( api_kwargs=api_kwargs, entities=entities, inline_message_id=None, + business_connection_id=self.business_connection_id, ) async def edit_caption( @@ -3627,7 +3692,10 @@ async def edit_caption( """Shortcut for:: await bot.edit_message_caption( - chat_id=message.chat_id, message_id=message.message_id, *args, **kwargs + chat_id=message.chat_id, + message_id=message.message_id, + business_connection_id=message.business_connection_id, + *args, **kwargs ) For the documentation of the arguments, please see @@ -3638,6 +3706,9 @@ async def edit_caption( of methods) or channel posts, if the bot is an admin in that channel. However, this behaviour is undocumented and might be changed by Telegram. + .. versionchanged:: 21.4 + Now also passes :attr:`business_connection_id`. + Returns: :class:`telegram.Message`: On success, if edited message is sent by the bot, the edited Message is returned, otherwise ``True`` is returned. @@ -3657,6 +3728,7 @@ async def edit_caption( caption_entities=caption_entities, inline_message_id=None, show_caption_above_media=show_caption_above_media, + business_connection_id=self.business_connection_id, ) async def edit_media( @@ -3673,7 +3745,10 @@ async def edit_media( """Shortcut for:: await bot.edit_message_media( - chat_id=message.chat_id, message_id=message.message_id, *args, **kwargs + chat_id=message.chat_id, + message_id=message.message_id, + business_connection_id=message.business_connection_id, + *args, **kwargs ) For the documentation of the arguments, please see @@ -3684,6 +3759,9 @@ async def edit_media( of methods) or channel posts, if the bot is an admin in that channel. However, this behaviour is undocumented and might be changed by Telegram. + .. versionchanged:: 21.4 + Now also passes :attr:`business_connection_id`. + Returns: :class:`telegram.Message`: On success, if edited message is not an inline message, the edited Message is returned, otherwise ``True`` is returned. @@ -3700,6 +3778,7 @@ async def edit_media( pool_timeout=pool_timeout, api_kwargs=api_kwargs, inline_message_id=None, + business_connection_id=self.business_connection_id, ) async def edit_reply_markup( @@ -3715,7 +3794,10 @@ async def edit_reply_markup( """Shortcut for:: await bot.edit_message_reply_markup( - chat_id=message.chat_id, message_id=message.message_id, *args, **kwargs + chat_id=message.chat_id, + message_id=message.message_id, + business_connection_id=message.business_connection_id, + *args, **kwargs ) For the documentation of the arguments, please see @@ -3726,6 +3808,9 @@ async def edit_reply_markup( of methods) or channel posts, if the bot is an admin in that channel. However, this behaviour is undocumented and might be changed by Telegram. + .. versionchanged:: 21.4 + Now also passes :attr:`business_connection_id`. + Returns: :class:`telegram.Message`: On success, if edited message is sent by the bot, the edited Message is returned, otherwise ``True`` is returned. @@ -3740,6 +3825,7 @@ async def edit_reply_markup( pool_timeout=pool_timeout, api_kwargs=api_kwargs, inline_message_id=None, + business_connection_id=self.business_connection_id, ) async def edit_live_location( @@ -3762,7 +3848,10 @@ async def edit_live_location( """Shortcut for:: await bot.edit_message_live_location( - chat_id=message.chat_id, message_id=message.message_id, *args, **kwargs + chat_id=message.chat_id, + message_id=message.message_id, + business_connection_id=message.business_connection_id, + *args, **kwargs ) For the documentation of the arguments, please see @@ -3773,6 +3862,9 @@ async def edit_live_location( of methods) or channel posts, if the bot is an admin in that channel. However, this behaviour is undocumented and might be changed by Telegram. + .. versionchanged:: 21.4 + Now also passes :attr:`business_connection_id`. + Returns: :class:`telegram.Message`: On success, if edited message is sent by the bot, the edited Message is returned, otherwise :obj:`True` is returned. @@ -3794,6 +3886,7 @@ async def edit_live_location( proximity_alert_radius=proximity_alert_radius, live_period=live_period, inline_message_id=None, + business_connection_id=self.business_connection_id, ) async def stop_live_location( @@ -3809,7 +3902,10 @@ async def stop_live_location( """Shortcut for:: await bot.stop_message_live_location( - chat_id=message.chat_id, message_id=message.message_id, *args, **kwargs + chat_id=message.chat_id, + message_id=message.message_id, + business_connection_id=message.business_connection_id, + *args, **kwargs ) For the documentation of the arguments, please see @@ -3820,6 +3916,9 @@ async def stop_live_location( of methods) or channel posts, if the bot is an admin in that channel. However, this behaviour is undocumented and might be changed by Telegram. + .. versionchanged:: 21.4 + Now also passes :attr:`business_connection_id`. + Returns: :class:`telegram.Message`: On success, if edited message is sent by the bot, the edited Message is returned, otherwise :obj:`True` is returned. @@ -3834,6 +3933,7 @@ async def stop_live_location( pool_timeout=pool_timeout, api_kwargs=api_kwargs, inline_message_id=None, + business_connection_id=self.business_connection_id, ) async def set_game_score( @@ -3964,11 +4064,17 @@ async def stop_poll( """Shortcut for:: await bot.stop_poll( - chat_id=message.chat_id, message_id=message.message_id, *args, **kwargs + chat_id=message.chat_id, + message_id=message.message_id, + business_connection_id=message.business_connection_id, + *args, **kwargs ) For the documentation of the arguments, please see :meth:`telegram.Bot.stop_poll`. + .. versionchanged:: 21.4 + Now also passes :attr:`business_connection_id`. + Returns: :class:`telegram.Poll`: On success, the stopped Poll with the final results is returned. @@ -3983,6 +4089,7 @@ async def stop_poll( connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + business_connection_id=self.business_connection_id, ) async def pin( diff --git a/telegram/_messageentity.py b/telegram/_messageentity.py index 2f7fb7d6179..302f3a1c080 100644 --- a/telegram/_messageentity.py +++ b/telegram/_messageentity.py @@ -18,7 +18,9 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram MessageEntity.""" -from typing import TYPE_CHECKING, Final, List, Optional +import copy +import itertools +from typing import TYPE_CHECKING, Dict, Final, List, Optional, Sequence from telegram import constants from telegram._telegramobject import TelegramObject @@ -129,7 +131,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["MessageEntity"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["MessageEntity"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -140,6 +144,81 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["MessageEntit return super().de_json(data=data, bot=bot) + @staticmethod + def adjust_message_entities_to_utf_16( + text: str, entities: Sequence["MessageEntity"] + ) -> Sequence["MessageEntity"]: + """Utility functionality for converting the offset and length of entities from + Unicode (:obj:`str`) to UTF-16 (``utf-16-le`` encoded :obj:`bytes`). + + Tip: + Only the offsets and lengths calulated in UTF-16 is acceptable by the Telegram Bot API. + If they are calculated using the Unicode string (:obj:`str` object), errors may occur + when the text contains characters that are not in the Basic Multilingual Plane (BMP). + For more information, see `Unicode `_ and + `Plane (Unicode) `_. + + .. versionadded:: 21.4 + + Examples: + Below is a snippet of code that demonstrates how to use this function to convert + entities from Unicode to UTF-16 space. The ``unicode_entities`` are calculated in + Unicode and the `utf_16_entities` are calculated in UTF-16. + + .. code-block:: python + + text = "𠌕 bold 𝄢 italic underlined: 𝛙𝌢𑁍" + unicode_entities = [ + MessageEntity(offset=2, length=4, type=MessageEntity.BOLD), + MessageEntity(offset=9, length=6, type=MessageEntity.ITALIC), + MessageEntity(offset=28, length=3, type=MessageEntity.UNDERLINE), + ] + utf_16_entities = MessageEntity.adjust_message_entities_to_utf_16( + text, unicode_entities + ) + await bot.send_message( + chat_id=123, + text=text, + entities=utf_16_entities, + ) + # utf_16_entities[0]: offset=3, length=4 + # utf_16_entities[1]: offset=11, length=6 + # utf_16_entities[2]: offset=30, length=6 + + Args: + text (:obj:`str`): The text that the entities belong to + entities (Sequence[:class:`telegram.MessageEntity`]): Sequence of entities + with offset and length calculated in Unicode + + Returns: + Sequence[:class:`telegram.MessageEntity`]: Sequence of entities + with offset and length calculated in UTF-16 encoding + """ + # get sorted positions + positions = sorted(itertools.chain(*((x.offset, x.offset + x.length) for x in entities))) + accumulated_length = 0 + # calculate the length of each slice text[:position] in utf-16 accordingly, + # store the position translations + position_translation: Dict[int, int] = {} + for i, position in enumerate(positions): + last_position = positions[i - 1] if i > 0 else 0 + text_slice = text[last_position:position] + accumulated_length += len(text_slice.encode("utf-16-le")) // 2 + position_translation[position] = accumulated_length + # get the final output entites + out = [] + for entity in entities: + translated_positions = position_translation[entity.offset] + translated_length = ( + position_translation[entity.offset + entity.length] - translated_positions + ) + new_entity = copy.copy(entity) + with new_entity._unfrozen(): + new_entity.offset = translated_positions + new_entity.length = translated_length + out.append(new_entity) + return out + ALL_TYPES: Final[List[str]] = list(constants.MessageEntityType) """List[:obj:`str`]: A list of all available message entity types.""" BLOCKQUOTE: Final[str] = constants.MessageEntityType.BLOCKQUOTE diff --git a/telegram/_messageorigin.py b/telegram/_messageorigin.py index 3577e6091f8..534583adb8b 100644 --- a/telegram/_messageorigin.py +++ b/telegram/_messageorigin.py @@ -94,7 +94,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["MessageOrigin"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["MessageOrigin"]: """Converts JSON data to the appropriate :class:`MessageOrigin` object, i.e. takes care of selecting the correct subclass. """ diff --git a/telegram/_messagereactionupdated.py b/telegram/_messagereactionupdated.py index 8366d1c083c..d4d4033a647 100644 --- a/telegram/_messagereactionupdated.py +++ b/telegram/_messagereactionupdated.py @@ -86,7 +86,7 @@ def __init__( @classmethod def de_json( - cls, data: Optional[JSONDict], bot: "Bot" + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None ) -> Optional["MessageReactionCountUpdated"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -186,7 +186,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["MessageReactionUpdated"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["MessageReactionUpdated"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_paidmedia.py b/telegram/_paidmedia.py new file mode 100644 index 00000000000..fe78cca28e0 --- /dev/null +++ b/telegram/_paidmedia.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# 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 objects that represent paid media in Telegram.""" + +from typing import TYPE_CHECKING, Dict, Final, Optional, Sequence, Tuple, Type + +from telegram import constants +from telegram._files.photosize import PhotoSize +from telegram._files.video import Video +from telegram._telegramobject import TelegramObject +from telegram._utils import enum +from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class PaidMedia(TelegramObject): + """Describes the paid media added to a message. Currently, it can be one of: + + * :class:`telegram.PaidMediaPreview` + * :class:`telegram.PaidMediaPhoto` + * :class:`telegram.PaidMediaVideo` + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`type` is equal. + + .. versionadded:: 21.4 + + Args: + type (:obj:`str`): Type of the paid media. + + Attributes: + type (:obj:`str`): Type of the paid media. + """ + + __slots__ = ("type",) + + PREVIEW: Final[str] = constants.PaidMediaType.PREVIEW + """:const:`telegram.constants.PaidMediaType.PREVIEW`""" + PHOTO: Final[str] = constants.PaidMediaType.PHOTO + """:const:`telegram.constants.PaidMediaType.PHOTO`""" + VIDEO: Final[str] = constants.PaidMediaType.VIDEO + """:const:`telegram.constants.PaidMediaType.VIDEO`""" + + def __init__( + self, + type: str, # pylint: disable=redefined-builtin + *, + api_kwargs: Optional[JSONDict] = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.type: str = enum.get_member(constants.PaidMediaType, type, type) + + self._id_attrs = (self.type,) + self._freeze() + + @classmethod + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["PaidMedia"]: + """Converts JSON data to the appropriate :class:`PaidMedia` object, i.e. takes + care of selecting the correct subclass. + + Args: + data (Dict[:obj:`str`, ...]): The JSON data. + bot (:class:`telegram.Bot`, optional): The bot associated with this object. + + Returns: + The Telegram object. + + """ + data = cls._parse_data(data) + + if data is None: + return None + + if not data and cls is PaidMedia: + return None + + _class_mapping: Dict[str, Type[PaidMedia]] = { + cls.PREVIEW: PaidMediaPreview, + cls.PHOTO: PaidMediaPhoto, + cls.VIDEO: PaidMediaVideo, + } + + if cls is PaidMedia and data.get("type") in _class_mapping: + return _class_mapping[data.pop("type")].de_json(data=data, bot=bot) + + return super().de_json(data=data, bot=bot) + + +class PaidMediaPreview(PaidMedia): + """The paid media isn't available before the payment. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`width`, :attr:`height`, and :attr:`duration` + are equal. + + .. versionadded:: 21.4 + + Args: + type (:obj:`str`): Type of the paid media, always :tg-const:`telegram.PaidMedia.PREVIEW`. + width (:obj:`int`, optional): Media width as defined by the sender. + height (:obj:`int`, optional): Media height as defined by the sender. + duration (:obj:`int`, optional): Duration of the media in seconds as defined by the sender. + + Attributes: + type (:obj:`str`): Type of the paid media, always :tg-const:`telegram.PaidMedia.PREVIEW`. + width (:obj:`int`): Optional. Media width as defined by the sender. + height (:obj:`int`): Optional. Media height as defined by the sender. + duration (:obj:`int`): Optional. Duration of the media in seconds as defined by the sender. + """ + + __slots__ = ("duration", "height", "width") + + def __init__( + self, + width: Optional[int] = None, + height: Optional[int] = None, + duration: Optional[int] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ) -> None: + super().__init__(type=PaidMedia.PREVIEW, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.width: Optional[int] = width + self.height: Optional[int] = height + self.duration: Optional[int] = duration + + self._id_attrs = (self.type, self.width, self.height, self.duration) + + +class PaidMediaPhoto(PaidMedia): + """ + The paid media is a photo. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`photo` are equal. + + .. versionadded:: 21.4 + + Args: + type (:obj:`str`): Type of the paid media, always :tg-const:`telegram.PaidMedia.PHOTO`. + photo (Sequence[:class:`telegram.PhotoSize`]): The photo. + + Attributes: + type (:obj:`str`): Type of the paid media, always :tg-const:`telegram.PaidMedia.PHOTO`. + photo (Tuple[:class:`telegram.PhotoSize`]): The photo. + """ + + __slots__ = ("photo",) + + def __init__( + self, + photo: Sequence["PhotoSize"], + *, + api_kwargs: Optional[JSONDict] = None, + ) -> None: + super().__init__(type=PaidMedia.PHOTO, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.photo: Tuple[PhotoSize, ...] = parse_sequence_arg(photo) + + self._id_attrs = (self.type, self.photo) + + @classmethod + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["PaidMediaPhoto"]: + data = cls._parse_data(data) + + if not data: + return None + + data["photo"] = PhotoSize.de_list(data.get("photo"), bot=bot) + return super().de_json(data=data, bot=bot) # type: ignore[return-value] + + +class PaidMediaVideo(PaidMedia): + """ + The paid media is a video. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`video` are equal. + + .. versionadded:: 21.4 + + Args: + type (:obj:`str`): Type of the paid media, always :tg-const:`telegram.PaidMedia.VIDEO`. + video (:class:`telegram.Video`): The video. + + Attributes: + type (:obj:`str`): Type of the paid media, always :tg-const:`telegram.PaidMedia.VIDEO`. + video (:class:`telegram.Video`): The video. + """ + + __slots__ = ("video",) + + def __init__( + self, + video: Video, + *, + api_kwargs: Optional[JSONDict] = None, + ) -> None: + super().__init__(type=PaidMedia.VIDEO, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.video: Video = video + + self._id_attrs = (self.type, self.video) + + @classmethod + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["PaidMediaVideo"]: + data = cls._parse_data(data) + + if not data: + return None + + data["video"] = Video.de_json(data.get("video"), bot=bot) + return super().de_json(data=data, bot=bot) # type: ignore[return-value] + + +class PaidMediaInfo(TelegramObject): + """ + Describes the paid media added to a message. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`star_count` and :attr:`paid_media` are equal. + + .. versionadded:: 21.4 + + Args: + star_count (:obj:`int`): The number of Telegram Stars that must be paid to buy access to + the media. + paid_media (Sequence[:class:`telegram.PaidMedia`]): Information about the paid media. + + Attributes: + star_count (:obj:`int`): The number of Telegram Stars that must be paid to buy access to + the media. + paid_media (Tuple[:class:`telegram.PaidMedia`]): Information about the paid media. + """ + + __slots__ = ("paid_media", "star_count") + + def __init__( + self, + star_count: int, + paid_media: Sequence[PaidMedia], + *, + api_kwargs: Optional[JSONDict] = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.star_count: int = star_count + self.paid_media: Tuple[PaidMedia, ...] = parse_sequence_arg(paid_media) + + self._id_attrs = (self.star_count, self.paid_media) + self._freeze() + + @classmethod + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["PaidMediaInfo"]: + data = cls._parse_data(data) + + if not data: + return None + + data["paid_media"] = PaidMedia.de_list(data.get("paid_media"), bot=bot) + return super().de_json(data=data, bot=bot) diff --git a/telegram/_passport/credentials.py b/telegram/_passport/credentials.py index 525dd473e17..514f7fffb6c 100644 --- a/telegram/_passport/credentials.py +++ b/telegram/_passport/credentials.py @@ -232,7 +232,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Credentials"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["Credentials"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -342,7 +344,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["SecureData"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["SecureData"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -448,7 +452,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["SecureValue"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["SecureValue"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_passport/encryptedpassportelement.py b/telegram/_passport/encryptedpassportelement.py index f0d869fe2e3..b05003f2cbd 100644 --- a/telegram/_passport/encryptedpassportelement.py +++ b/telegram/_passport/encryptedpassportelement.py @@ -193,7 +193,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["EncryptedPassportElement"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["EncryptedPassportElement"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -210,14 +212,21 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["EncryptedPas @classmethod def de_json_decrypted( - cls, data: Optional[JSONDict], bot: "Bot", credentials: "Credentials" + cls, data: Optional[JSONDict], bot: Optional["Bot"], credentials: "Credentials" ) -> Optional["EncryptedPassportElement"]: """Variant of :meth:`telegram.TelegramObject.de_json` that also takes into account passport credentials. Args: data (Dict[:obj:`str`, ...]): The JSON data. - bot (:class:`telegram.Bot`): The bot associated with this object. + bot (:class:`telegram.Bot` | :obj:`None`): The bot associated with these object. + May be :obj:`None`, in which case shortcut methods will not be available. + + .. versionchanged:: 21.4 + :paramref:`bot` is now optional and defaults to :obj:`None` + + .. deprecated:: 21.4 + This argument will be converted to an optional argument in future versions. credentials (:class:`telegram.FileCredentials`): The credentials Returns: diff --git a/telegram/_passport/passportdata.py b/telegram/_passport/passportdata.py index 0dae4ba68c8..32e3879bc4d 100644 --- a/telegram/_passport/passportdata.py +++ b/telegram/_passport/passportdata.py @@ -81,7 +81,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["PassportData"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["PassportData"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_passport/passportfile.py b/telegram/_passport/passportfile.py index 3c69e9eb570..61b70486279 100644 --- a/telegram/_passport/passportfile.py +++ b/telegram/_passport/passportfile.py @@ -118,14 +118,21 @@ def file_date(self) -> int: @classmethod def de_json_decrypted( - cls, data: Optional[JSONDict], bot: "Bot", credentials: "FileCredentials" + cls, data: Optional[JSONDict], bot: Optional["Bot"], credentials: "FileCredentials" ) -> Optional["PassportFile"]: """Variant of :meth:`telegram.TelegramObject.de_json` that also takes into account passport credentials. Args: data (Dict[:obj:`str`, ...]): The JSON data. - bot (:class:`telegram.Bot`): The bot associated with this object. + bot (:class:`telegram.Bot` | :obj:`None`): The bot associated with these object. + May be :obj:`None`, in which case shortcut methods will not be available. + + .. versionchanged:: 21.4 + :paramref:`bot` is now optional and defaults to :obj:`None` + + .. deprecated:: 21.4 + This argument will be converted to an optional argument in future versions. credentials (:class:`telegram.FileCredentials`): The credentials Returns: @@ -143,7 +150,10 @@ def de_json_decrypted( @classmethod def de_list_decrypted( - cls, data: Optional[List[JSONDict]], bot: "Bot", credentials: List["FileCredentials"] + cls, + data: Optional[List[JSONDict]], + bot: Optional["Bot"], + credentials: List["FileCredentials"], ) -> Tuple[Optional["PassportFile"], ...]: """Variant of :meth:`telegram.TelegramObject.de_list` that also takes into account passport credentials. @@ -155,7 +165,14 @@ def de_list_decrypted( Args: data (List[Dict[:obj:`str`, ...]]): The JSON data. - bot (:class:`telegram.Bot`): The bot associated with these objects. + bot (:class:`telegram.Bot` | :obj:`None`): The bot associated with these object. + May be :obj:`None`, in which case shortcut methods will not be available. + + .. versionchanged:: 21.4 + :paramref:`bot` is now optional and defaults to :obj:`None` + + .. deprecated:: 21.4 + This argument will be converted to an optional argument in future versions. credentials (:class:`telegram.FileCredentials`): The credentials Returns: diff --git a/telegram/_payment/invoice.py b/telegram/_payment/invoice.py index 141f0c8fdd4..804ac040304 100644 --- a/telegram/_payment/invoice.py +++ b/telegram/_payment/invoice.py @@ -39,9 +39,9 @@ class Invoice(TelegramObject): generate this invoice. currency (:obj:`str`): Three-letter ISO 4217 currency code, or ``XTR`` for payments in |tg_stars|. - total_amount (:obj:`int`): Total price in the smallest units of the currency (integer, not - float/double). For example, for a price of US$ 1.45 pass ``amount = 145``. See the - ``exp`` parameter in + total_amount (:obj:`int`): Total price in the smallest units of the currency (integer, + **not** float/double). For example, for a price of ``US$ 1.45`` pass ``amount = 145``. + See the ``exp`` parameter in `currencies.json `_, it shows the number of digits past the decimal point for each currency (2 for the majority of currencies). @@ -53,9 +53,9 @@ class Invoice(TelegramObject): generate this invoice. currency (:obj:`str`): Three-letter ISO 4217 currency code, or ``XTR`` for payments in |tg_stars|. - total_amount (:obj:`int`): Total price in the smallest units of the currency (integer, not - float/double). For example, for a price of US$ 1.45 ``amount`` is ``145``. See the - ``exp`` parameter in + total_amount (:obj:`int`): Total price in the smallest units of the currency (integer, + **not** float/double). For example, for a price of ``US$ 1.45`` pass ``amount = 145``. + See the ``exp`` parameter in `currencies.json `_, it shows the number of digits past the decimal point for each currency (2 for the majority of currencies). diff --git a/telegram/_payment/labeledprice.py b/telegram/_payment/labeledprice.py index b668931b9d4..ec02f1f7029 100644 --- a/telegram/_payment/labeledprice.py +++ b/telegram/_payment/labeledprice.py @@ -36,7 +36,7 @@ class LabeledPrice(TelegramObject): Args: label (:obj:`str`): Portion label. amount (:obj:`int`): Price of the product in the smallest units of the currency (integer, - not float/double). For example, for a price of US$ 1.45 pass ``amount = 145``. + **not** float/double). For example, for a price of ``US$ 1.45`` pass ``amount = 145``. See the ``exp`` parameter in `currencies.json `_, it shows the number of digits past the decimal point for each currency @@ -45,7 +45,7 @@ class LabeledPrice(TelegramObject): Attributes: label (:obj:`str`): Portion label. amount (:obj:`int`): Price of the product in the smallest units of the currency (integer, - not float/double). For example, for a price of US$ 1.45 ``amount`` is ``145``. + **not** float/double). For example, for a price of ``US$ 1.45`` pass ``amount = 145``. See the ``exp`` parameter in `currencies.json `_, it shows the number of digits past the decimal point for each currency diff --git a/telegram/_payment/orderinfo.py b/telegram/_payment/orderinfo.py index b3a41b54345..7d3ee84a37b 100644 --- a/telegram/_payment/orderinfo.py +++ b/telegram/_payment/orderinfo.py @@ -71,7 +71,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["OrderInfo"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["OrderInfo"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_payment/precheckoutquery.py b/telegram/_payment/precheckoutquery.py index fb57127fc11..60e1d6078a1 100644 --- a/telegram/_payment/precheckoutquery.py +++ b/telegram/_payment/precheckoutquery.py @@ -44,13 +44,13 @@ class PreCheckoutQuery(TelegramObject): from_user (:class:`telegram.User`): User who sent the query. currency (:obj:`str`): Three-letter ISO 4217 currency code, or ``XTR`` for payments in |tg_stars|. - total_amount (:obj:`int`): Total price in the smallest units of the currency (integer, not - float/double). For example, for a price of US$ 1.45 pass ``amount = 145``. + total_amount (:obj:`int`): Total price in the smallest units of the currency (integer, + **not** float/double). For example, for a price of ``US$ 1.45`` pass ``amount = 145``. See the ``exp`` parameter in `currencies.json `_, it shows the number of digits past the decimal point for each currency (2 for the majority of currencies). - invoice_payload (:obj:`str`): Bot specified invoice payload. + invoice_payload (:obj:`str`): Bot-specified invoice payload. shipping_option_id (:obj:`str`, optional): Identifier of the shipping option chosen by the user. order_info (:class:`telegram.OrderInfo`, optional): Order info provided by the user. @@ -60,13 +60,13 @@ class PreCheckoutQuery(TelegramObject): from_user (:class:`telegram.User`): User who sent the query. currency (:obj:`str`): Three-letter ISO 4217 currency code, or ``XTR`` for payments in |tg_stars|. - total_amount (:obj:`int`): Total price in the smallest units of the currency (integer, not - float/double). For example, for a price of US$ 1.45 ``amount`` is ``145``. + total_amount (:obj:`int`): Total price in the smallest units of the currency (integer, + **not** float/double). For example, for a price of ``US$ 1.45`` pass ``amount = 145``. See the ``exp`` parameter in `currencies.json `_, it shows the number of digits past the decimal point for each currency (2 for the majority of currencies). - invoice_payload (:obj:`str`): Bot specified invoice payload. + invoice_payload (:obj:`str`): Bot-specified invoice payload. shipping_option_id (:obj:`str`): Optional. Identifier of the shipping option chosen by the user. order_info (:class:`telegram.OrderInfo`): Optional. Order info provided by the user. @@ -110,7 +110,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["PreCheckoutQuery"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["PreCheckoutQuery"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_payment/refundedpayment.py b/telegram/_payment/refundedpayment.py new file mode 100644 index 00000000000..19bdfe84649 --- /dev/null +++ b/telegram/_payment/refundedpayment.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# 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 object that represents a Telegram RefundedPayment.""" + +from typing import Optional + +from telegram._telegramobject import TelegramObject +from telegram._utils.types import JSONDict + + +class RefundedPayment(TelegramObject): + """This object contains basic information about a refunded payment. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`telegram_payment_charge_id` is equal. + + Args: + currency (:obj:`str`): Three-letter ISO 4217 `currency + `_ code, or ``XTR`` for + payments in |tg_stars|. Currently, always ``XTR``. + total_amount (:obj:`int`): Total refunded price in the *smallest units* of the currency + (integer, **not** float/double). For example, for a price of ``US$ 1.45``, + ``total_amount = 145``. See the *exp* parameter in + `currencies.json `_, + it shows the number of digits past the decimal point for each currency + (2 for the majority of currencies). + invoice_payload (:obj:`str`): Bot-specified invoice payload. + telegram_payment_charge_id (:obj:`str`): Telegram payment identifier. + provider_payment_charge_id (:obj:`str`, optional): Provider payment identifier. + + Attributes: + currency (:obj:`str`): Three-letter ISO 4217 `currency + `_ code, or ``XTR`` for + payments in |tg_stars|. Currently, always ``XTR``. + total_amount (:obj:`int`): Total refunded price in the *smallest units* of the currency + (integer, **not** float/double). For example, for a price of ``US$ 1.45``, + ``total_amount = 145``. See the *exp* parameter in + `currencies.json `_, + it shows the number of digits past the decimal point for each currency + (2 for the majority of currencies). + invoice_payload (:obj:`str`): Bot-specified invoice payload. + telegram_payment_charge_id (:obj:`str`): Telegram payment identifier. + provider_payment_charge_id (:obj:`str`): Optional. Provider payment identifier. + + """ + + __slots__ = ( + "currency", + "invoice_payload", + "provider_payment_charge_id", + "telegram_payment_charge_id", + "total_amount", + ) + + def __init__( + self, + currency: str, + total_amount: int, + invoice_payload: str, + telegram_payment_charge_id: str, + provider_payment_charge_id: Optional[str] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.currency: str = currency + self.total_amount: int = total_amount + self.invoice_payload: str = invoice_payload + self.telegram_payment_charge_id: str = telegram_payment_charge_id + # Optional + self.provider_payment_charge_id: Optional[str] = provider_payment_charge_id + + self._id_attrs = (self.telegram_payment_charge_id,) + + self._freeze() diff --git a/telegram/_payment/shippingquery.py b/telegram/_payment/shippingquery.py index ab7e5a1b2f4..cf81b4ecfa6 100644 --- a/telegram/_payment/shippingquery.py +++ b/telegram/_payment/shippingquery.py @@ -43,13 +43,13 @@ class ShippingQuery(TelegramObject): Args: id (:obj:`str`): Unique query identifier. from_user (:class:`telegram.User`): User who sent the query. - invoice_payload (:obj:`str`): Bot specified invoice payload. + invoice_payload (:obj:`str`): Bot-specified invoice payload. shipping_address (:class:`telegram.ShippingAddress`): User specified shipping address. Attributes: id (:obj:`str`): Unique query identifier. from_user (:class:`telegram.User`): User who sent the query. - invoice_payload (:obj:`str`): Bot specified invoice payload. + invoice_payload (:obj:`str`): Bot-specified invoice payload. shipping_address (:class:`telegram.ShippingAddress`): User specified shipping address. @@ -77,7 +77,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ShippingQuery"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["ShippingQuery"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_payment/stars.py b/telegram/_payment/stars.py new file mode 100644 index 00000000000..b176f2315fe --- /dev/null +++ b/telegram/_payment/stars.py @@ -0,0 +1,507 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# 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/]. +# pylint: disable=redefined-builtin +"""This module contains the classes for Telegram Stars transactions.""" + +from datetime import datetime +from typing import TYPE_CHECKING, Dict, Final, Optional, Sequence, Tuple, Type + +from telegram import constants +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils import enum +from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class RevenueWithdrawalState(TelegramObject): + """This object escribes the state of a revenue withdrawal operation. Currently, it can be one + of: + + * :class:`telegram.RevenueWithdrawalStatePending` + * :class:`telegram.RevenueWithdrawalStateSucceeded` + * :class:`telegram.RevenueWithdrawalStateFailed` + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`type` is equal. + + .. versionadded:: 21.4 + + Args: + type (:obj:`str`): The type of the state. + + Attributes: + type (:obj:`str`): The type of the state. + """ + + __slots__ = ("type",) + + PENDING: Final[str] = constants.RevenueWithdrawalStateType.PENDING + """:const:`telegram.constants.RevenueWithdrawalStateType.PENDING`""" + SUCCEEDED: Final[str] = constants.RevenueWithdrawalStateType.SUCCEEDED + """:const:`telegram.constants.RevenueWithdrawalStateType.SUCCEEDED`""" + FAILED: Final[str] = constants.RevenueWithdrawalStateType.FAILED + """:const:`telegram.constants.RevenueWithdrawalStateType.FAILED`""" + + def __init__(self, type: str, *, api_kwargs: Optional[JSONDict] = None) -> None: + super().__init__(api_kwargs=api_kwargs) + self.type: str = enum.get_member(constants.RevenueWithdrawalStateType, type, type) + + self._id_attrs = (self.type,) + self._freeze() + + @classmethod + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["RevenueWithdrawalState"]: + data = cls._parse_data(data) + + if not data: + return None + + _class_mapping: Dict[str, Type[RevenueWithdrawalState]] = { + cls.PENDING: RevenueWithdrawalStatePending, + cls.SUCCEEDED: RevenueWithdrawalStateSucceeded, + cls.FAILED: RevenueWithdrawalStateFailed, + } + + if cls is RevenueWithdrawalState and data.get("type") in _class_mapping: + return _class_mapping[data.pop("type")].de_json(data=data, bot=bot) + + return super().de_json(data=data, bot=bot) + + +class RevenueWithdrawalStatePending(RevenueWithdrawalState): + """The withdrawal is in progress. + + .. versionadded:: 21.4 + + Attributes: + type (:obj:`str`): The type of the state, always + :tg-const:`telegram.RevenueWithdrawalState.PENDING`. + """ + + __slots__ = () + + def __init__(self, *, api_kwargs: Optional[JSONDict] = None) -> None: + super().__init__(type=RevenueWithdrawalState.PENDING, api_kwargs=api_kwargs) + self._freeze() + + +class RevenueWithdrawalStateSucceeded(RevenueWithdrawalState): + """The withdrawal succeeded. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`date` are equal. + + .. versionadded:: 21.4 + + Args: + date (:obj:`datetime.datetime`): Date the withdrawal was completed as a datetime object. + url (:obj:`str`): An HTTPS URL that can be used to see transaction details. + + Attributes: + type (:obj:`str`): The type of the state, always + :tg-const:`telegram.RevenueWithdrawalState.SUCCEEDED`. + date (:obj:`datetime.datetime`): Date the withdrawal was completed as a datetime object. + url (:obj:`str`): An HTTPS URL that can be used to see transaction details. + """ + + __slots__ = ("date", "url") + + def __init__( + self, + date: datetime, + url: str, + *, + api_kwargs: Optional[JSONDict] = None, + ) -> None: + super().__init__(type=RevenueWithdrawalState.SUCCEEDED, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.date: datetime = date + self.url: str = url + self._id_attrs = ( + self.type, + self.date, + ) + + @classmethod + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["RevenueWithdrawalStateSucceeded"]: + data = cls._parse_data(data) + + if not data: + return None + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + data["date"] = from_timestamp(data.get("date", None), tzinfo=loc_tzinfo) + + return super().de_json(data=data, bot=bot) # type: ignore[return-value] + + +class RevenueWithdrawalStateFailed(RevenueWithdrawalState): + """The withdrawal failed and the transaction was refunded. + + .. versionadded:: 21.4 + + Attributes: + type (:obj:`str`): The type of the state, always + :tg-const:`telegram.RevenueWithdrawalState.FAILED`. + """ + + __slots__ = () + + def __init__(self, *, api_kwargs: Optional[JSONDict] = None) -> None: + super().__init__(type=RevenueWithdrawalState.FAILED, api_kwargs=api_kwargs) + self._freeze() + + +class TransactionPartner(TelegramObject): + """This object describes the source of a transaction, or its recipient for outgoing + transactions. Currently, it can be one of: + + * :class:`TransactionPartnerUser` + * :class:`TransactionPartnerFragment` + * :class:`TransactionPartnerTelegramAds` + * :class:`TransactionPartnerOther` + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`type` is equal. + + .. versionadded:: 21.4 + + Args: + type (:obj:`str`): The type of the transaction partner. + + Attributes: + type (:obj:`str`): The type of the transaction partner. + """ + + __slots__ = ("type",) + + FRAGMENT: Final[str] = constants.TransactionPartnerType.FRAGMENT + """:const:`telegram.constants.TransactionPartnerType.FRAGMENT`""" + USER: Final[str] = constants.TransactionPartnerType.USER + """:const:`telegram.constants.TransactionPartnerType.USER`""" + OTHER: Final[str] = constants.TransactionPartnerType.OTHER + """:const:`telegram.constants.TransactionPartnerType.OTHER`""" + TELEGRAM_ADS: Final[str] = constants.TransactionPartnerType.TELEGRAM_ADS + """:const:`telegram.constants.TransactionPartnerType.TELEGRAM_ADS`""" + + def __init__(self, type: str, *, api_kwargs: Optional[JSONDict] = None) -> None: + super().__init__(api_kwargs=api_kwargs) + self.type: str = enum.get_member(constants.TransactionPartnerType, type, type) + + self._id_attrs = (self.type,) + self._freeze() + + @classmethod + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["TransactionPartner"]: + """Converts JSON data to the appropriate :class:`TransactionPartner` object, i.e. takes + care of selecting the correct subclass. + + Args: + data (Dict[:obj:`str`, ...]): The JSON data. + bot (:class:`telegram.Bot`): The bot associated with this object. + + Returns: + The Telegram object. + + """ + data = cls._parse_data(data) + + if data is None: + return None + + if not data and cls is TransactionPartner: + return None + + _class_mapping: Dict[str, Type[TransactionPartner]] = { + cls.FRAGMENT: TransactionPartnerFragment, + cls.USER: TransactionPartnerUser, + cls.OTHER: TransactionPartnerOther, + cls.TELEGRAM_ADS: TransactionPartnerTelegramAds, + } + + if cls is TransactionPartner and data.get("type") in _class_mapping: + return _class_mapping[data.pop("type")].de_json(data=data, bot=bot) + + return super().de_json(data=data, bot=bot) + + +class TransactionPartnerFragment(TransactionPartner): + """Describes a withdrawal transaction with Fragment. + + .. versionadded:: 21.4 + + Args: + withdrawal_state (:obj:`telegram.RevenueWithdrawalState`, optional): State of the + transaction if the transaction is outgoing. + + Attributes: + type (:obj:`str`): The type of the transaction partner, + always :tg-const:`telegram.TransactionPartner.FRAGMENT`. + withdrawal_state (:obj:`telegram.RevenueWithdrawalState`): Optional. State of the + transaction if the transaction is outgoing. + """ + + __slots__ = ("withdrawal_state",) + + def __init__( + self, + withdrawal_state: Optional["RevenueWithdrawalState"] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ) -> None: + super().__init__(type=TransactionPartner.FRAGMENT, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.withdrawal_state: Optional[RevenueWithdrawalState] = withdrawal_state + + @classmethod + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["TransactionPartnerFragment"]: + data = cls._parse_data(data) + + if not data: + return None + + data["withdrawal_state"] = RevenueWithdrawalState.de_json( + data.get("withdrawal_state"), bot + ) + + return super().de_json(data=data, bot=bot) # type: ignore[return-value] + + +class TransactionPartnerUser(TransactionPartner): + """Describes a transaction with a user. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`user` are equal. + + .. versionadded:: 21.4 + + Args: + user (:class:`telegram.User`): Information about the user. + invoice_payload (:obj:`str`, optional): Bot-specified invoice payload. + + Attributes: + type (:obj:`str`): The type of the transaction partner, + always :tg-const:`telegram.TransactionPartner.USER`. + user (:class:`telegram.User`): Information about the user. + invoice_payload (:obj:`str`): Optional. Bot-specified invoice payload. + """ + + __slots__ = ("invoice_payload", "user") + + def __init__( + self, + user: "User", + invoice_payload: Optional[str] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ) -> None: + super().__init__(type=TransactionPartner.USER, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.user: User = user + self.invoice_payload: Optional[str] = invoice_payload + self._id_attrs = ( + self.type, + self.user, + ) + + @classmethod + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["TransactionPartnerUser"]: + data = cls._parse_data(data) + + if not data: + return None + + data["user"] = User.de_json(data.get("user"), bot) + + return super().de_json(data=data, bot=bot) # type: ignore[return-value] + + +class TransactionPartnerOther(TransactionPartner): + """Describes a transaction with an unknown partner. + + .. versionadded:: 21.4 + + Attributes: + type (:obj:`str`): The type of the transaction partner, + always :tg-const:`telegram.TransactionPartner.OTHER`. + """ + + __slots__ = () + + def __init__(self, *, api_kwargs: Optional[JSONDict] = None) -> None: + super().__init__(type=TransactionPartner.OTHER, api_kwargs=api_kwargs) + self._freeze() + + +class TransactionPartnerTelegramAds(TransactionPartner): + """Describes a withdrawal transaction to the Telegram Ads platform. + + .. versionadded:: 21.4 + + Attributes: + type (:obj:`str`): The type of the transaction partner, + always :tg-const:`telegram.TransactionPartner.TELEGRAM_ADS`. + """ + + __slots__ = () + + def __init__(self, *, api_kwargs: Optional[JSONDict] = None) -> None: + super().__init__(type=TransactionPartner.TELEGRAM_ADS, api_kwargs=api_kwargs) + self._freeze() + + +class StarTransaction(TelegramObject): + """Describes a Telegram Star transaction. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`id`, :attr:`source`, and :attr:`receiver` are equal. + + .. versionadded:: 21.4 + + Args: + id (:obj:`str`): Unique identifier of the transaction. Coincides with the identifer + of the original transaction for refund transactions. + Coincides with :attr:`SuccessfulPayment.telegram_payment_charge_id` for + successful incoming payments from users. + amount (:obj:`int`): Number of Telegram Stars transferred by the transaction. + date (:obj:`datetime.datetime`): Date the transaction was created as a datetime object. + source (:class:`telegram.TransactionPartner`, optional): Source of an incoming transaction + (e.g., a user purchasing goods or services, Fragment refunding a failed withdrawal). + Only for incoming transactions. + receiver (:class:`telegram.TransactionPartner`, optional): Receiver of an outgoing + transaction (e.g., a user for a purchase refund, Fragment for a withdrawal). Only for + outgoing transactions. + + Attributes: + id (:obj:`str`): Unique identifier of the transaction. Coincides with the identifer + of the original transaction for refund transactions. + Coincides with :attr:`SuccessfulPayment.telegram_payment_charge_id` for + successful incoming payments from users. + amount (:obj:`int`): Number of Telegram Stars transferred by the transaction. + date (:obj:`datetime.datetime`): Date the transaction was created as a datetime object. + source (:class:`telegram.TransactionPartner`): Optional. Source of an incoming transaction + (e.g., a user purchasing goods or services, Fragment refunding a failed withdrawal). + Only for incoming transactions. + receiver (:class:`telegram.TransactionPartner`): Optional. Receiver of an outgoing + transaction (e.g., a user for a purchase refund, Fragment for a withdrawal). Only for + outgoing transactions. + """ + + __slots__ = ("amount", "date", "id", "receiver", "source") + + def __init__( + self, + id: str, + amount: int, + date: datetime, + source: Optional[TransactionPartner] = None, + receiver: Optional[TransactionPartner] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.id: str = id + self.amount: int = amount + self.date: datetime = date + self.source: Optional[TransactionPartner] = source + self.receiver: Optional[TransactionPartner] = receiver + + self._id_attrs = ( + self.id, + self.source, + self.receiver, + ) + self._freeze() + + @classmethod + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["StarTransaction"]: + data = cls._parse_data(data) + + if not data: + return None + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + data["date"] = from_timestamp(data.get("date", None), tzinfo=loc_tzinfo) + + data["source"] = TransactionPartner.de_json(data.get("source"), bot) + data["receiver"] = TransactionPartner.de_json(data.get("receiver"), bot) + + return super().de_json(data=data, bot=bot) + + +class StarTransactions(TelegramObject): + """ + Contains a list of Telegram Star transactions. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`transactions` are equal. + + .. versionadded:: 21.4 + + Args: + transactions (Sequence[:class:`telegram.StarTransaction`]): The list of transactions. + + Attributes: + transactions (Tuple[:class:`telegram.StarTransaction`]): The list of transactions. + """ + + __slots__ = ("transactions",) + + def __init__( + self, transactions: Sequence[StarTransaction], *, api_kwargs: Optional[JSONDict] = None + ): + super().__init__(api_kwargs=api_kwargs) + self.transactions: Tuple[StarTransaction, ...] = parse_sequence_arg(transactions) + + self._id_attrs = (self.transactions,) + self._freeze() + + @classmethod + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["StarTransactions"]: + data = cls._parse_data(data) + + if data is None: + return None + + data["transactions"] = StarTransaction.de_list(data.get("transactions"), bot) + return super().de_json(data=data, bot=bot) diff --git a/telegram/_payment/successfulpayment.py b/telegram/_payment/successfulpayment.py index a7424feba22..34bce29142e 100644 --- a/telegram/_payment/successfulpayment.py +++ b/telegram/_payment/successfulpayment.py @@ -38,13 +38,13 @@ class SuccessfulPayment(TelegramObject): Args: currency (:obj:`str`): Three-letter ISO 4217 currency code, or ``XTR`` for payments in |tg_stars|. - total_amount (:obj:`int`): Total price in the smallest units of the currency (integer, not - float/double). For example, for a price of US$ 1.45 pass ``amount = 145``. + total_amount (:obj:`int`): Total price in the smallest units of the currency (integer, + **not** float/double). For example, for a price of ``US$ 1.45`` pass ``amount = 145``. See the ``exp`` parameter in `currencies.json `_, it shows the number of digits past the decimal point for each currency (2 for the majority of currencies). - invoice_payload (:obj:`str`): Bot specified invoice payload. + invoice_payload (:obj:`str`): Bot-specified invoice payload. shipping_option_id (:obj:`str`, optional): Identifier of the shipping option chosen by the user. order_info (:class:`telegram.OrderInfo`, optional): Order info provided by the user. @@ -54,13 +54,13 @@ class SuccessfulPayment(TelegramObject): Attributes: currency (:obj:`str`): Three-letter ISO 4217 currency code, or ``XTR`` for payments in |tg_stars|. - total_amount (:obj:`int`): Total price in the smallest units of the currency (integer, not - float/double). For example, for a price of US$ 1.45 ``amount`` is ``145``. + total_amount (:obj:`int`): Total price in the smallest units of the currency (integer, + **not** float/double). For example, for a price of ``US$ 1.45`` pass ``amount = 145``. See the ``exp`` parameter in `currencies.json `_, it shows the number of digits past the decimal point for each currency (2 for the majority of currencies). - invoice_payload (:obj:`str`): Bot specified invoice payload. + invoice_payload (:obj:`str`): Bot-specified invoice payload. shipping_option_id (:obj:`str`): Optional. Identifier of the shipping option chosen by the user. order_info (:class:`telegram.OrderInfo`): Optional. Order info provided by the user. @@ -105,7 +105,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["SuccessfulPayment"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["SuccessfulPayment"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_poll.py b/telegram/_poll.py index cd6397cf733..8ea387a0950 100644 --- a/telegram/_poll.py +++ b/telegram/_poll.py @@ -38,7 +38,7 @@ class InputPollOption(TelegramObject): """ - This object contains information about one answer option in a poll to send. + This object contains information about one answer option in a poll to be sent. Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their :attr:`text` is equal. @@ -90,7 +90,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["InputPollOption"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["InputPollOption"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -154,7 +156,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["PollOption"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["PollOption"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -301,7 +305,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["PollAnswer"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["PollAnswer"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -467,7 +473,7 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Poll"]: + def de_json(cls, data: Optional[JSONDict], bot: Optional["Bot"] = None) -> Optional["Poll"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_proximityalerttriggered.py b/telegram/_proximityalerttriggered.py index dd05c1ddd95..0880ca9a6f6 100644 --- a/telegram/_proximityalerttriggered.py +++ b/telegram/_proximityalerttriggered.py @@ -67,7 +67,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ProximityAlertTriggered"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["ProximityAlertTriggered"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_reaction.py b/telegram/_reaction.py index 60bdbebc489..d1ba718f0d6 100644 --- a/telegram/_reaction.py +++ b/telegram/_reaction.py @@ -65,7 +65,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ReactionType"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["ReactionType"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -192,7 +194,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ReactionCount"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["ReactionCount"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_reply.py b/telegram/_reply.py index 973cee5ddfe..0c15844c8d5 100644 --- a/telegram/_reply.py +++ b/telegram/_reply.py @@ -37,6 +37,7 @@ from telegram._linkpreviewoptions import LinkPreviewOptions from telegram._messageentity import MessageEntity from telegram._messageorigin import MessageOrigin +from telegram._paidmedia import PaidMediaInfo from telegram._payment.invoice import Invoice from telegram._poll import Poll from telegram._story import Story @@ -80,8 +81,9 @@ class ExternalReplyInfo(TelegramObject): sticker. story (:class:`telegram.Story`, optional): Message is a forwarded story. video (:class:`telegram.Video`, optional): Message is a video, information about the video. - video_note (:class:`telegram.VideoNote`, optional): Message is a video note, information - about the video message. + video_note (:class:`telegram.VideoNote`, optional): Message is a `video note + `_, information about the video + message. voice (:class:`telegram.Voice`, optional): Message is a voice message, information about the file. has_media_spoiler (:obj:`bool`, optional): :obj:`True`, if the message media is covered by @@ -90,17 +92,23 @@ class ExternalReplyInfo(TelegramObject): about the contact. dice (:class:`telegram.Dice`, optional): Message is a dice with random value. game (:Class:`telegram.Game`. optional): Message is a game, information about the game. + `More about games >> `_. giveaway (:class:`telegram.Giveaway`, optional): Message is a scheduled giveaway, information about the giveaway. giveaway_winners (:class:`telegram.GiveawayWinners`, optional): A giveaway with public winners was completed. invoice (:class:`telegram.Invoice`, optional): Message is an invoice for a payment, - information about the invoice. + information about the invoice. `More about payments >> + `_. location (:class:`telegram.Location`, optional): Message is a shared location, information about the location. poll (:class:`telegram.Poll`, optional): Message is a native poll, information about the poll. venue (:class:`telegram.Venue`, optional): Message is a venue, information about the venue. + paid_media (:class:`telegram.PaidMedia`, optional): Message contains paid media; + information about the paid media. + + .. versionadded:: 21.4 Attributes: origin (:class:`telegram.MessageOrigin`): Origin of the message replied to by the given @@ -123,8 +131,9 @@ class ExternalReplyInfo(TelegramObject): sticker. story (:class:`telegram.Story`): Optional. Message is a forwarded story. video (:class:`telegram.Video`): Optional. Message is a video, information about the video. - video_note (:class:`telegram.VideoNote`): Optional. Message is a video note, information - about the video message. + video_note (:class:`telegram.VideoNote`): Optional. Message is a `video note + `_, information about the video + message. voice (:class:`telegram.Voice`): Optional. Message is a voice message, information about the file. has_media_spoiler (:obj:`bool`): Optional. :obj:`True`, if the message media is covered by @@ -133,17 +142,23 @@ class ExternalReplyInfo(TelegramObject): about the contact. dice (:class:`telegram.Dice`): Optional. Message is a dice with random value. game (:Class:`telegram.Game`): Optional. Message is a game, information about the game. + `More about games >> `_. giveaway (:class:`telegram.Giveaway`): Optional. Message is a scheduled giveaway, information about the giveaway. giveaway_winners (:class:`telegram.GiveawayWinners`): Optional. A giveaway with public winners was completed. invoice (:class:`telegram.Invoice`): Optional. Message is an invoice for a payment, - information about the invoice. + information about the invoice. `More about payments >> + `_. location (:class:`telegram.Location`): Optional. Message is a shared location, information about the location. poll (:class:`telegram.Poll`): Optional. Message is a native poll, information about the poll. venue (:class:`telegram.Venue`): Optional. Message is a venue, information about the venue. + paid_media (:class:`telegram.PaidMedia`): Optional. Message contains paid media; + information about the paid media. + + .. versionadded:: 21.4 """ __slots__ = ( @@ -162,6 +177,7 @@ class ExternalReplyInfo(TelegramObject): "location", "message_id", "origin", + "paid_media", "photo", "poll", "sticker", @@ -197,6 +213,7 @@ def __init__( location: Optional[Location] = None, poll: Optional[Poll] = None, venue: Optional[Venue] = None, + paid_media: Optional[PaidMediaInfo] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -225,13 +242,16 @@ def __init__( self.location: Optional[Location] = location self.poll: Optional[Poll] = poll self.venue: Optional[Venue] = venue + self.paid_media: Optional[PaidMediaInfo] = paid_media self._id_attrs = (self.origin,) self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ExternalReplyInfo"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["ExternalReplyInfo"]: """See :obj:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -261,6 +281,7 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ExternalRepl data["location"] = Location.de_json(data.get("location"), bot) data["poll"] = Poll.de_json(data.get("poll"), bot) data["venue"] = Venue.de_json(data.get("venue"), bot) + data["paid_media"] = PaidMediaInfo.de_json(data.get("paid_media"), bot) return super().de_json(data=data, bot=bot) @@ -329,7 +350,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["TextQuote"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["TextQuote"]: """See :obj:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -434,7 +457,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ReplyParameters"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["ReplyParameters"]: """See :obj:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_replykeyboardmarkup.py b/telegram/_replykeyboardmarkup.py index cfca12cc350..1b410ebc709 100644 --- a/telegram/_replykeyboardmarkup.py +++ b/telegram/_replykeyboardmarkup.py @@ -41,7 +41,7 @@ class ReplyKeyboardMarkup(TelegramObject): A reply keyboard with reply options. .. seealso:: - An another kind of keyboard would be the :class:`telegram.InlineKeyboardMarkup`. + Another kind of keyboard would be the :class:`telegram.InlineKeyboardMarkup`. Examples: * Example usage: A user requests to change the bot's language, bot replies to the request diff --git a/telegram/_shared.py b/telegram/_shared.py index 8c791154e2f..b4ce2c4d5a0 100644 --- a/telegram/_shared.py +++ b/telegram/_shared.py @@ -83,7 +83,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["UsersShared"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["UsersShared"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -172,7 +174,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatShared"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["ChatShared"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -250,7 +254,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["SharedUser"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["SharedUser"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_story.py b/telegram/_story.py index 4cb4a8454b6..40d17cdb16d 100644 --- a/telegram/_story.py +++ b/telegram/_story.py @@ -71,7 +71,7 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Story"]: + def de_json(cls, data: Optional[JSONDict], bot: Optional["Bot"] = None) -> Optional["Story"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_telegramobject.py b/telegram/_telegramobject.py index 2f61dace88e..4f7ba92d602 100644 --- a/telegram/_telegramobject.py +++ b/telegram/_telegramobject.py @@ -403,7 +403,7 @@ def _parse_data(data: Optional[JSONDict]) -> Optional[JSONDict]: def _de_json( cls: Type[Tele_co], data: Optional[JSONDict], - bot: "Bot", + bot: Optional["Bot"], api_kwargs: Optional[JSONDict] = None, ) -> Optional[Tele_co]: if data is None: @@ -432,12 +432,18 @@ def _de_json( return obj @classmethod - def de_json(cls: Type[Tele_co], data: Optional[JSONDict], bot: "Bot") -> Optional[Tele_co]: + def de_json( + cls: Type[Tele_co], data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional[Tele_co]: """Converts JSON data to a Telegram object. Args: data (Dict[:obj:`str`, ...]): The JSON data. - bot (:class:`telegram.Bot`): The bot associated with this object. + bot (:class:`telegram.Bot`, optional): The bot associated with this object. Defaults to + :obj:`None`, in which case shortcut methods will not be available. + + .. versionchanged:: 21.4 + :paramref:`bot` is now optional and defaults to :obj:`None` Returns: The Telegram object. @@ -447,7 +453,7 @@ def de_json(cls: Type[Tele_co], data: Optional[JSONDict], bot: "Bot") -> Optiona @classmethod def de_list( - cls: Type[Tele_co], data: Optional[List[JSONDict]], bot: "Bot" + cls: Type[Tele_co], data: Optional[List[JSONDict]], bot: Optional["Bot"] = None ) -> Tuple[Tele_co, ...]: """Converts a list of JSON objects to a tuple of Telegram objects. @@ -458,7 +464,11 @@ def de_list( Args: data (List[Dict[:obj:`str`, ...]]): The JSON data. - bot (:class:`telegram.Bot`): The bot associated with these objects. + bot (:class:`telegram.Bot`, optional): The bot associated with these object. Defaults + to :obj:`None`, in which case shortcut methods will not be available. + + .. versionchanged:: 21.4 + :paramref:`bot` is now optional and defaults to :obj:`None` Returns: A tuple of Telegram objects. diff --git a/telegram/_update.py b/telegram/_update.py index 784dea52aba..579cb008580 100644 --- a/telegram/_update.py +++ b/telegram/_update.py @@ -446,7 +446,7 @@ def __init__( ) self._effective_user: Optional[User] = None - self._effective_sender: Optional[Union["User", "Chat"]] = None + self._effective_sender: Optional[Union[User, Chat]] = None self._effective_chat: Optional[Chat] = None self._effective_message: Optional[Message] = None @@ -568,7 +568,7 @@ def effective_sender(self) -> Optional[Union["User", "Chat"]]: if self._effective_sender: return self._effective_sender - sender: Optional[Union["User", "Chat"]] = None + sender: Optional[Union[User, Chat]] = None if message := ( self.message @@ -729,7 +729,7 @@ def effective_message(self) -> Optional[Message]: return message @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Update"]: + def de_json(cls, data: Optional[JSONDict], bot: Optional["Bot"] = None) -> Optional["Update"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_userprofilephotos.py b/telegram/_userprofilephotos.py index 36e260da9f2..9a5e4a066ef 100644 --- a/telegram/_userprofilephotos.py +++ b/telegram/_userprofilephotos.py @@ -70,7 +70,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["UserProfilePhotos"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["UserProfilePhotos"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_utils/datetime.py b/telegram/_utils/datetime.py index 1c0da085434..40d931efffe 100644 --- a/telegram/_utils/datetime.py +++ b/telegram/_utils/datetime.py @@ -188,13 +188,16 @@ def from_timestamp( return dtm.datetime.fromtimestamp(unixtime, tz=UTC if tzinfo is None else tzinfo) -def extract_tzinfo_from_defaults(bot: "Bot") -> Union[dtm.tzinfo, None]: +def extract_tzinfo_from_defaults(bot: Optional["Bot"]) -> Union[dtm.tzinfo, None]: """ Extracts the timezone info from the default values of the bot. If the bot has no default values, :obj:`None` is returned. """ # We don't use `ininstance(bot, ExtBot)` here so that this works # without the job-queue extra dependencies as well + if bot is None: + return None + if hasattr(bot, "defaults") and bot.defaults: return bot.defaults.tzinfo return None diff --git a/telegram/_version.py b/telegram/_version.py index 34849536283..ec3f5618c21 100644 --- a/telegram/_version.py +++ b/telegram/_version.py @@ -19,7 +19,7 @@ # pylint: disable=missing-module-docstring from typing import Final, NamedTuple -__all__ = ("__bot_api_version__", "__bot_api_version_info__", "__version__", "__version_info__") +__all__ = ("__version__", "__version_info__") class Version(NamedTuple): @@ -51,15 +51,6 @@ def __str__(self) -> str: __version_info__: Final[Version] = Version( - major=21, minor=3, micro=0, releaselevel="final", serial=0 + major=21, minor=4, micro=0, releaselevel="final", serial=0 ) __version__: Final[str] = str(__version_info__) - -# # SETUP.PY MARKER -# Lines above this line will be `exec`-cuted in setup.py. Make sure that this only contains -# std-lib imports! - -from telegram import constants # noqa: E402 # pylint: disable=wrong-import-position - -__bot_api_version__: Final[str] = constants.BOT_API_VERSION -__bot_api_version_info__: Final[constants._BotAPIVersion] = constants.BOT_API_VERSION_INFO diff --git a/telegram/_videochat.py b/telegram/_videochat.py index 3e5027c99fd..b392fa6d65b 100644 --- a/telegram/_videochat.py +++ b/telegram/_videochat.py @@ -125,7 +125,7 @@ def __init__( @classmethod def de_json( - cls, data: Optional[JSONDict], bot: "Bot" + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None ) -> Optional["VideoChatParticipantsInvited"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) @@ -177,7 +177,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["VideoChatScheduled"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["VideoChatScheduled"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/_webhookinfo.py b/telegram/_webhookinfo.py index c1b049b7109..a6f309a930d 100644 --- a/telegram/_webhookinfo.py +++ b/telegram/_webhookinfo.py @@ -162,7 +162,9 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["WebhookInfo"]: + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["WebhookInfo"]: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) diff --git a/telegram/constants.py b/telegram/constants.py index 5e2c853baa7..fb4bc9a19a9 100644 --- a/telegram/constants.py +++ b/telegram/constants.py @@ -71,6 +71,7 @@ "InlineQueryResultType", "InlineQueryResultsButtonLimit", "InputMediaType", + "InputPaidMediaType", "InvoiceLimit", "KeyboardButtonRequestUsersLimit", "LocationLimit", @@ -82,6 +83,7 @@ "MessageLimit", "MessageOriginType", "MessageType", + "PaidMediaType", "ParseMode", "PollLimit", "PollType", @@ -90,10 +92,13 @@ "ReactionEmoji", "ReactionType", "ReplyLimit", + "RevenueWithdrawalStateType", + "StarTransactionsLimit", "StickerFormat", "StickerLimit", "StickerSetLimit", "StickerType", + "TransactionPartnerType", "UpdateType", "UserProfilePhotosLimit", "WebhookLimit", @@ -146,7 +151,7 @@ class _AccentColor(NamedTuple): #: :data:`telegram.__bot_api_version_info__`. #: #: .. versionadded:: 20.0 -BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=7, minor=4) +BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=7, minor=7) #: :obj:`str`: Telegram Bot API #: version supported by this version of `python-telegram-bot`. Also available as #: :data:`telegram.__bot_api_version__`. @@ -1256,6 +1261,21 @@ class InputMediaType(StringEnum): """:obj:`str`: Type of :class:`telegram.InputMediaVideo`.""" +class InputPaidMediaType(StringEnum): + """This enum contains the available types of :class:`telegram.InputPaidMedia`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 21.4 + """ + + __slots__ = () + + PHOTO = "photo" + """:obj:`str`: Type of :class:`telegram.InputMediaPhoto`.""" + VIDEO = "video" + """:obj:`str`: Type of :class:`telegram.InputMediaVideo`.""" + + class InlineQueryLimit(IntEnum): """This enum contains limitations for :class:`telegram.InlineQuery`/ :meth:`telegram.Bot.answer_inline_query`. The enum members of this enumeration are instances @@ -1599,6 +1619,11 @@ class MessageAttachmentType(StringEnum): """:obj:`str`: Messages with :attr:`telegram.Message.invoice`.""" LOCATION = "location" """:obj:`str`: Messages with :attr:`telegram.Message.location`.""" + PAID_MEDIA = "paid_media" + """:obj:`str`: Messages with :attr:`telegram.Message.paid_media`. + + .. versionadded:: 21.4 + """ PASSPORT_DATA = "passport_data" """:obj:`str`: Messages with :attr:`telegram.Message.passport_data`.""" PHOTO = "photo" @@ -1880,6 +1905,11 @@ class MessageType(StringEnum): """:obj:`str`: Messages with :attr:`telegram.Message.new_chat_title`.""" NEW_CHAT_PHOTO = "new_chat_photo" """:obj:`str`: Messages with :attr:`telegram.Message.new_chat_photo`.""" + PAID_MEDIA = "paid_media" + """:obj:`str`: Messages with :attr:`telegram.Message.paid_media`. + + .. versionadded:: 21.4 + """ PASSPORT_DATA = "passport_data" """:obj:`str`: Messages with :attr:`telegram.Message.passport_data`.""" PHOTO = "photo" @@ -1890,6 +1920,11 @@ class MessageType(StringEnum): """:obj:`str`: Messages with :attr:`telegram.Message.poll`.""" PROXIMITY_ALERT_TRIGGERED = "proximity_alert_triggered" """:obj:`str`: Messages with :attr:`telegram.Message.proximity_alert_triggered`.""" + REFUNDED_PAYMENT = "refunded_payment" + """:obj:`str`: Messages with :attr:`telegram.Message.refunded_payment`. + + .. versionadded:: 21.4 + """ REPLY_TO_STORY = "reply_to_story" """:obj:`str`: Messages with :attr:`telegram.Message.reply_to_story`. @@ -1948,6 +1983,24 @@ class MessageType(StringEnum): """ +class PaidMediaType(StringEnum): + """ + This enum contains the available types of :class:`telegram.PaidMedia`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 21.4 + """ + + __slots__ = () + + PREVIEW = "preview" + """:obj:`str`: The type of :class:`telegram.PaidMediaPreview`.""" + VIDEO = "video" + """:obj:`str`: The type of :class:`telegram.PaidMediaVideo`.""" + PHOTO = "photo" + """:obj:`str`: The type of :class:`telegram.PaidMediaPhoto`.""" + + class PollingLimit(IntEnum): """This enum contains limitations for :paramref:`telegram.Bot.get_updates.limit`. The enum members of this enumeration are instances of :class:`int` and can be treated as such. @@ -2303,6 +2356,42 @@ class ReplyLimit(IntEnum): """ +class RevenueWithdrawalStateType(StringEnum): + """This enum contains the available types of :class:`telegram.RevenueWithdrawalState`. + The enum members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 21.4 + """ + + __slots__ = () + + PENDING = "pending" + """:obj:`str`: A withdrawal in progress.""" + SUCCEEDED = "succeeded" + """:obj:`str`: A withdrawal succeeded.""" + FAILED = "failed" + """:obj:`str`: A withdrawal failed and the transaction was refunded.""" + + +class StarTransactionsLimit(IntEnum): + """This enum contains limitations for :class:`telegram.Bot.get_star_transactions`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 21.4 + """ + + __slots__ = () + + MIN_LIMIT = 1 + """:obj:`int`: Minimum value allowed for the + :paramref:`~telegram.Bot.get_star_transactions.limit` parameter of + :meth:`telegram.Bot.get_star_transactions`.""" + MAX_LIMIT = 100 + """:obj:`int`: Maximum value allowed for the + :paramref:`~telegram.Bot.get_star_transactions.limit` parameter of + :meth:`telegram.Bot.get_star_transactions`.""" + + class StickerFormat(StringEnum): """This enum contains the available formats of :class:`telegram.Sticker` in the set. The enum members of this enumeration are instances of :class:`str` and can be treated as such. @@ -2436,6 +2525,25 @@ class StickerType(StringEnum): """:obj:`str`: Custom emoji sticker.""" +class TransactionPartnerType(StringEnum): + """This enum contains the available types of :class:`telegram.TransactionPartner`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 21.4 + """ + + __slots__ = () + + FRAGMENT = "fragment" + """:obj:`str`: Withdrawal transaction with Fragment.""" + USER = "user" + """:obj:`str`: Transaction with a user.""" + OTHER = "other" + """:obj:`str`: Transaction with unknown source or recipient.""" + TELEGRAM_ADS = "telegram_ads" + """:obj:`str`: Transaction with Telegram Ads.""" + + class ParseMode(StringEnum): """This enum contains the available parse modes. The enum members of this enumeration are instances of :class:`str` and can be treated as such. diff --git a/telegram/ext/_application.py b/telegram/ext/_application.py index f5a9d6df49a..670793c9909 100644 --- a/telegram/ext/_application.py +++ b/telegram/ext/_application.py @@ -251,39 +251,44 @@ class Application(Generic[BT, CCT, UD, CD, BD, JQ], AsyncContextManager["Applica """ __slots__ = ( - "__create_task_tasks", - "__update_fetcher_task", - "__update_persistence_event", - "__update_persistence_lock", - "__update_persistence_task", + ( # noqa: RUF005 + "__create_task_tasks", + "__update_fetcher_task", + "__update_persistence_event", + "__update_persistence_lock", + "__update_persistence_task", + "__stop_running_marker", + "_chat_data", + "_chat_ids_to_be_deleted_in_persistence", + "_chat_ids_to_be_updated_in_persistence", + "_conversation_handler_conversations", + "_initialized", + "_job_queue", + "_running", + "_update_processor", + "_user_data", + "_user_ids_to_be_deleted_in_persistence", + "_user_ids_to_be_updated_in_persistence", + "bot", + "bot_data", + "chat_data", + "context_types", + "error_handlers", + "handlers", + "persistence", + "post_init", + "post_shutdown", + "post_stop", + "update_queue", + "updater", + "user_data", + ) # Allowing '__weakref__' creation here since we need it for the JobQueue - # Uncomment if necessary - currently the __weakref__ slot is already created - # in the AsyncContextManager base class - # "__weakref__", - "_chat_data", - "_chat_ids_to_be_deleted_in_persistence", - "_chat_ids_to_be_updated_in_persistence", - "_conversation_handler_conversations", - "_initialized", - "_job_queue", - "_running", - "_update_processor", - "_user_data", - "_user_ids_to_be_deleted_in_persistence", - "_user_ids_to_be_updated_in_persistence", - "bot", - "bot_data", - "chat_data", - "context_types", - "error_handlers", - "handlers", - "persistence", - "post_init", - "post_shutdown", - "post_stop", - "update_queue", - "updater", - "user_data", + # Currently the __weakref__ slot is already created + # in the AsyncContextManager base class for pythons < 3.13 + + ("__weakref__",) + if sys.version_info >= (3, 13) + else () ) def __init__( @@ -781,6 +786,11 @@ def run_polling( - :meth:`shutdown` - :meth:`post_shutdown` + A small wrapper is passed to :paramref:`telegram.ext.Updater.start_polling.error_callback` + which forwards errors occurring during polling to + :meth:`registered error handlers `. The update parameter of the callback + will be set to :obj:`None`. + .. include:: inclusions/application_run_tip.rst Args: @@ -1357,11 +1367,11 @@ def add_handler(self, handler: BaseHandler[Any, CCT], group: int = DEFAULT_GROUP The priority/order of handlers is determined as follows: - * Priority of the group (lower group number == higher priority) - * The first handler in a group which can handle an update (see - :attr:`telegram.ext.BaseHandler.check_update`) will be used. Other handlers from the - group will not be used. The order in which handlers were added to the group defines the - priority. + * Priority of the group (lower group number == higher priority) + * The first handler in a group which can handle an update (see + :attr:`telegram.ext.BaseHandler.check_update`) will be used. Other handlers from the + group will not be used. The order in which handlers were added to the group defines the + priority. Warning: Adding persistent :class:`telegram.ext.ConversationHandler` after the application has diff --git a/telegram/ext/_applicationbuilder.py b/telegram/ext/_applicationbuilder.py index 2da56279941..a54d491614b 100644 --- a/telegram/ext/_applicationbuilder.py +++ b/telegram/ext/_applicationbuilder.py @@ -322,9 +322,7 @@ def build( bot = self._updater.bot update_queue = self._updater.update_queue - application: Application[ - BT, CCT, UD, CD, BD, JQ - ] = DefaultValue.get_value( # pylint: disable=not-callable + application: Application[BT, CCT, UD, CD, BD, JQ] = DefaultValue.get_value( self._application_class )( bot=bot, diff --git a/telegram/ext/_basepersistence.py b/telegram/ext/_basepersistence.py index 5199a165bb6..126437a0a48 100644 --- a/telegram/ext/_basepersistence.py +++ b/telegram/ext/_basepersistence.py @@ -357,6 +357,10 @@ async def refresh_user_data(self, user_id: int, user_data: UD) -> None: :attr:`~telegram.ext.Application.user_data` to a callback. Can be used to update data stored in :attr:`~telegram.ext.Application.user_data` from an external source. + Tip: + This method is expected to edit the object :paramref:`user_data` in-place instead of + returning a new object. + Warning: When using :meth:`~telegram.ext.ApplicationBuilder.concurrent_updates`, this method may be called while a handler callback is still running. This might lead to race @@ -380,6 +384,10 @@ async def refresh_chat_data(self, chat_id: int, chat_data: CD) -> None: :attr:`~telegram.ext.Application.chat_data` to a callback. Can be used to update data stored in :attr:`~telegram.ext.Application.chat_data` from an external source. + Tip: + This method is expected to edit the object :paramref:`chat_data` in-place instead of + returning a new object. + Warning: When using :meth:`~telegram.ext.ApplicationBuilder.concurrent_updates`, this method may be called while a handler callback is still running. This might lead to race @@ -403,6 +411,10 @@ async def refresh_bot_data(self, bot_data: BD) -> None: :attr:`~telegram.ext.Application.bot_data` to a callback. Can be used to update data stored in :attr:`~telegram.ext.Application.bot_data` from an external source. + Tip: + This method is expected to edit the object :paramref:`bot_data` in-place instead of + returning a new object. + Warning: When using :meth:`~telegram.ext.ApplicationBuilder.concurrent_updates`, this method may be called while a handler callback is still running. This might lead to race diff --git a/telegram/ext/_callbackdatacache.py b/telegram/ext/_callbackdatacache.py index 893150b480d..150f3f055b0 100644 --- a/telegram/ext/_callbackdatacache.py +++ b/telegram/ext/_callbackdatacache.py @@ -30,6 +30,8 @@ CACHE_TOOLS_AVAILABLE = False +import contextlib + from telegram import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, User from telegram._utils.datetime import to_float_timestamp from telegram.error import TelegramError @@ -425,10 +427,8 @@ def drop_data(self, callback_query: CallbackQuery) -> None: raise KeyError("CallbackQuery was not found in cache.") from exc def __drop_keyboard(self, keyboard_uuid: str) -> None: - try: + with contextlib.suppress(KeyError): self._keyboard_data.pop(keyboard_uuid) - except KeyError: - return def clear_callback_data(self, time_cutoff: Optional[Union[float, datetime]] = None) -> None: """Clears the stored callback data. diff --git a/telegram/ext/_extbot.py b/telegram/ext/_extbot.py index 917e9d8ef97..7d8d10e4902 100644 --- a/telegram/ext/_extbot.py +++ b/telegram/ext/_extbot.py @@ -76,6 +76,7 @@ ReactionType, ReplyParameters, SentWebAppMessage, + StarTransactions, Sticker, StickerSet, TelegramObject, @@ -106,6 +107,7 @@ InputMediaDocument, InputMediaPhoto, InputMediaVideo, + InputPaidMedia, InputSticker, LabeledPrice, MessageEntity, @@ -767,6 +769,7 @@ async def stop_poll( chat_id: Union[int, str], message_id: int, reply_markup: Optional["InlineKeyboardMarkup"] = None, + business_connection_id: Optional[str] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -780,6 +783,7 @@ async def stop_poll( chat_id=chat_id, message_id=message_id, reply_markup=self._replace_keyboard(reply_markup), + business_connection_id=business_connection_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -1511,6 +1515,7 @@ async def edit_message_caption( parse_mode: ODVInput[str] = DEFAULT_NONE, caption_entities: Optional[Sequence["MessageEntity"]] = None, show_caption_above_media: Optional[bool] = None, + business_connection_id: Optional[str] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1527,6 +1532,7 @@ async def edit_message_caption( reply_markup=reply_markup, parse_mode=parse_mode, caption_entities=caption_entities, + business_connection_id=business_connection_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -1547,6 +1553,7 @@ async def edit_message_live_location( heading: Optional[int] = None, proximity_alert_radius: Optional[int] = None, live_period: Optional[int] = None, + business_connection_id: Optional[str] = None, *, location: Optional[Location] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1568,6 +1575,7 @@ async def edit_message_live_location( proximity_alert_radius=proximity_alert_radius, live_period=live_period, location=location, + business_connection_id=business_connection_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -1582,6 +1590,7 @@ async def edit_message_media( message_id: Optional[int] = None, inline_message_id: Optional[str] = None, reply_markup: Optional["InlineKeyboardMarkup"] = None, + business_connection_id: Optional[str] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1596,6 +1605,7 @@ async def edit_message_media( message_id=message_id, inline_message_id=inline_message_id, reply_markup=reply_markup, + business_connection_id=business_connection_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -1609,6 +1619,7 @@ async def edit_message_reply_markup( message_id: Optional[int] = None, inline_message_id: Optional[str] = None, reply_markup: Optional["InlineKeyboardMarkup"] = None, + business_connection_id: Optional[str] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1622,6 +1633,7 @@ async def edit_message_reply_markup( message_id=message_id, inline_message_id=inline_message_id, reply_markup=reply_markup, + business_connection_id=business_connection_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -1639,6 +1651,7 @@ async def edit_message_text( reply_markup: Optional["InlineKeyboardMarkup"] = None, entities: Optional[Sequence["MessageEntity"]] = None, link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, + business_connection_id: Optional[str] = None, *, disable_web_page_preview: Optional[bool] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1657,6 +1670,7 @@ async def edit_message_text( disable_web_page_preview=disable_web_page_preview, reply_markup=reply_markup, entities=entities, + business_connection_id=business_connection_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -3633,6 +3647,7 @@ async def stop_message_live_location( message_id: Optional[int] = None, inline_message_id: Optional[str] = None, reply_markup: Optional["InlineKeyboardMarkup"] = None, + business_connection_id: Optional[str] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -3646,6 +3661,7 @@ async def stop_message_live_location( message_id=message_id, inline_message_id=inline_message_id, reply_markup=reply_markup, + business_connection_id=business_connection_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -4179,6 +4195,72 @@ async def refund_star_payment( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def get_star_transactions( + self, + offset: Optional[int] = None, + limit: Optional[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: Optional[JSONDict] = None, + rate_limit_args: Optional[RLARGS] = None, + ) -> StarTransactions: + return await super().get_star_transactions( + 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 send_paid_media( + self, + chat_id: Union[str, int], + star_count: int, + media: Sequence["InputPaidMedia"], + caption: Optional[str] = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Optional[Sequence["MessageEntity"]] = None, + show_caption_above_media: Optional[bool] = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + reply_parameters: Optional["ReplyParameters"] = None, + reply_markup: Optional[ReplyMarkup] = None, + *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: Optional[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: Optional[JSONDict] = None, + rate_limit_args: Optional[RLARGS] = None, + ) -> Message: + return await super().send_paid_media( + chat_id=chat_id, + star_count=star_count, + media=media, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + show_caption_above_media=show_caption_above_media, + disable_notification=disable_notification, + protect_content=protect_content, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + allow_sending_without_reply=allow_sending_without_reply, + reply_to_message_id=reply_to_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), + ) + # updated camelCase aliases getMe = get_me sendMessage = send_message @@ -4301,3 +4383,5 @@ async def refund_star_payment( getBusinessConnection = get_business_connection replaceStickerInSet = replace_sticker_in_set refundStarPayment = refund_star_payment + getStarTransactions = get_star_transactions + sendPaidMedia = send_paid_media diff --git a/telegram/ext/_jobqueue.py b/telegram/ext/_jobqueue.py index 6edd5a892ea..db84841d5be 100644 --- a/telegram/ext/_jobqueue.py +++ b/telegram/ext/_jobqueue.py @@ -106,7 +106,9 @@ def __init__(self) -> None: self._application: Optional[weakref.ReferenceType[Application]] = None self._executor = AsyncIOExecutor() - self.scheduler: "AsyncIOScheduler" = AsyncIOScheduler(**self.scheduler_configuration) + self.scheduler: "AsyncIOScheduler" = AsyncIOScheduler( # noqa: UP037 + **self.scheduler_configuration + ) def __repr__(self) -> str: """Give a string representation of the JobQueue in the form ``JobQueue[application=...]``. diff --git a/telegram/ext/_utils/webhookhandler.py b/telegram/ext/_utils/webhookhandler.py index 828dbca4715..a174fbaa476 100644 --- a/telegram/ext/_utils/webhookhandler.py +++ b/telegram/ext/_utils/webhookhandler.py @@ -168,7 +168,8 @@ async def post(self) -> None: except Exception as exc: _LOGGER.critical( "Something went wrong processing the data received from Telegram. " - "Received data was *not* processed!", + "Received data was *not* processed! Received data was: %r", + data, exc_info=exc, ) raise tornado.web.HTTPError( diff --git a/telegram/ext/filters.py b/telegram/ext/filters.py index 5147574e07a..de105e28b6a 100644 --- a/telegram/ext/filters.py +++ b/telegram/ext/filters.py @@ -58,6 +58,7 @@ "IS_FROM_OFFLINE", "IS_TOPIC_MESSAGE", "LOCATION", + "PAID_MEDIA", "PASSPORT_DATA", "PHOTO", "POLL", @@ -1706,6 +1707,20 @@ def filter(self, message: Message) -> bool: return any(self._check_mention(message, mention) for mention in self._mentions) +class _PaidMedia(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.paid_media) + + +PAID_MEDIA = _PaidMedia(name="filters.PAID_MEDIA") +"""Messages that contain :attr:`telegram.Message.paid_media`. + +.. versionadded:: 21.4 +""" + + class _PassportData(MessageFilter): __slots__ = () @@ -1944,6 +1959,7 @@ def filter(self, update: Update) -> bool: or StatusUpdate.NEW_CHAT_TITLE.check_update(update) or StatusUpdate.PINNED_MESSAGE.check_update(update) or StatusUpdate.PROXIMITY_ALERT_TRIGGERED.check_update(update) + or StatusUpdate.REFUNDED_PAYMENT.check_update(update) or StatusUpdate.USERS_SHARED.check_update(update) or StatusUpdate.USER_SHARED.check_update(update) or StatusUpdate.VIDEO_CHAT_ENDED.check_update(update) @@ -2190,6 +2206,17 @@ def filter(self, message: Message) -> bool: ) """Messages that contain :attr:`telegram.Message.proximity_alert_triggered`.""" + class _RefundedPayment(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.refunded_payment) + + REFUNDED_PAYMENT = _RefundedPayment("filters.StatusUpdate.REFUNDED_PAYMENT") + """Messages that contain :attr:`telegram.Message.refunded_payment`. + .. versionadded:: 21.4 + """ + class _UserShared(MessageFilter): __slots__ = () diff --git a/telegram/request/_requestparameter.py b/telegram/request/_requestparameter.py index 6b16a5cae66..ab11cbce793 100644 --- a/telegram/request/_requestparameter.py +++ b/telegram/request/_requestparameter.py @@ -23,7 +23,7 @@ from typing import List, Optional, Sequence, Tuple, final from telegram._files.inputfile import InputFile -from telegram._files.inputmedia import InputMedia +from telegram._files.inputmedia import InputMedia, InputPaidMedia from telegram._files.inputsticker import InputSticker from telegram._telegramobject import TelegramObject from telegram._utils.datetime import to_timestamp @@ -90,7 +90,7 @@ def _value_and_input_files_from_input( # pylint: disable=too-many-return-statem value: object, ) -> Tuple[object, List[InputFile]]: """Converts `value` into something that we can json-dump. Returns two values: - 1. the JSON-dumpable value. Maybe be `None` in case the value is an InputFile which must + 1. the JSON-dumpable value. May be `None` in case the value is an InputFile which must not be uploaded via an attach:// URI 2. A list of InputFiles that should be uploaded for this value @@ -117,7 +117,7 @@ def _value_and_input_files_from_input( # pylint: disable=too-many-return-statem return value.attach_uri, [value] return None, [value] - if isinstance(value, InputMedia) and isinstance(value.media, InputFile): + if isinstance(value, (InputMedia, InputPaidMedia)) and isinstance(value.media, InputFile): # We call to_dict and change the returned dict instead of overriding # value.media in case the same value is reused for another request data = value.to_dict() diff --git a/tests/README.rst b/tests/README.rst index 753dd6a16ca..69591953bc3 100644 --- a/tests/README.rst +++ b/tests/README.rst @@ -4,7 +4,7 @@ Testing in PTB PTB uses `pytest`_ for testing. To run the tests, you need to have pytest installed along with a few other dependencies. You can find the list of dependencies -in the ``requirements-dev.txt`` file in the root of the repository. +in the ``pyproject.toml`` file in the root of the repository. Running tests ============= diff --git a/tests/_files/test_inputmedia.py b/tests/_files/test_inputmedia.py index cce7fdc07a5..d25a679ffc4 100644 --- a/tests/_files/test_inputmedia.py +++ b/tests/_files/test_inputmedia.py @@ -31,6 +31,8 @@ InputMediaDocument, InputMediaPhoto, InputMediaVideo, + InputPaidMediaPhoto, + InputPaidMediaVideo, Message, MessageEntity, ReplyParameters, @@ -134,6 +136,25 @@ def input_media_document(class_thumb_file): ) +@pytest.fixture(scope="module") +def input_paid_media_photo(): + return InputPaidMediaPhoto( + media=TestInputMediaPhotoBase.media, + ) + + +@pytest.fixture(scope="module") +def input_paid_media_video(class_thumb_file): + return InputPaidMediaVideo( + media=TestInputMediaVideoBase.media, + thumbnail=class_thumb_file, + width=TestInputMediaVideoBase.width, + height=TestInputMediaVideoBase.height, + duration=TestInputMediaVideoBase.duration, + supports_streaming=TestInputMediaVideoBase.supports_streaming, + ) + + class TestInputMediaVideoBase: type_ = "video" media = "NOTAREALFILEID" @@ -514,6 +535,91 @@ def test_with_local_files(self): assert input_media_document.thumbnail == data_file("telegram.jpg").as_uri() +class TestInputPaidMediaPhotoWithoutRequest(TestInputMediaPhotoBase): + def test_slot_behaviour(self, input_paid_media_photo): + inst = input_paid_media_photo + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, input_paid_media_photo): + assert input_paid_media_photo.type == self.type_ + assert input_paid_media_photo.media == self.media + + def test_to_dict(self, input_paid_media_photo): + input_paid_media_photo_dict = input_paid_media_photo.to_dict() + assert input_paid_media_photo_dict["type"] == input_paid_media_photo.type + assert input_paid_media_photo_dict["media"] == input_paid_media_photo.media + + def test_with_photo(self, photo): # noqa: F811 + # fixture found in test_photo + input_paid_media_photo = InputPaidMediaPhoto(photo) + assert input_paid_media_photo.type == self.type_ + assert input_paid_media_photo.media == photo.file_id + + def test_with_photo_file(self, photo_file): # noqa: F811 + # fixture found in test_photo + input_paid_media_photo = InputPaidMediaPhoto(photo_file) + assert input_paid_media_photo.type == self.type_ + assert isinstance(input_paid_media_photo.media, InputFile) + + def test_with_local_files(self): + input_paid_media_photo = InputPaidMediaPhoto(data_file("telegram.jpg")) + assert input_paid_media_photo.media == data_file("telegram.jpg").as_uri() + + +class TestInputPaidMediaVideoWithoutRequest(TestInputMediaVideoBase): + def test_slot_behaviour(self, input_paid_media_video): + inst = input_paid_media_video + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, input_paid_media_video): + assert input_paid_media_video.type == self.type_ + assert input_paid_media_video.media == self.media + assert input_paid_media_video.width == self.width + assert input_paid_media_video.height == self.height + assert input_paid_media_video.duration == self.duration + assert input_paid_media_video.supports_streaming == self.supports_streaming + assert isinstance(input_paid_media_video.thumbnail, InputFile) + + def test_to_dict(self, input_paid_media_video): + input_paid_media_video_dict = input_paid_media_video.to_dict() + assert input_paid_media_video_dict["type"] == input_paid_media_video.type + assert input_paid_media_video_dict["media"] == input_paid_media_video.media + assert input_paid_media_video_dict["width"] == input_paid_media_video.width + assert input_paid_media_video_dict["height"] == input_paid_media_video.height + assert input_paid_media_video_dict["duration"] == input_paid_media_video.duration + assert ( + input_paid_media_video_dict["supports_streaming"] + == input_paid_media_video.supports_streaming + ) + assert input_paid_media_video_dict["thumbnail"] == input_paid_media_video.thumbnail + + def test_with_video(self, video): # noqa: F811 + # fixture found in test_video + input_paid_media_video = InputPaidMediaVideo(video) + assert input_paid_media_video.type == self.type_ + assert input_paid_media_video.media == video.file_id + assert input_paid_media_video.width == video.width + assert input_paid_media_video.height == video.height + assert input_paid_media_video.duration == video.duration + + def test_with_video_file(self, video_file): # noqa: F811 + # fixture found in test_video + input_paid_media_video = InputPaidMediaVideo(video_file) + assert input_paid_media_video.type == self.type_ + assert isinstance(input_paid_media_video.media, InputFile) + + def test_with_local_files(self): + input_paid_media_video = InputPaidMediaVideo( + data_file("telegram.mp4"), thumbnail=data_file("telegram.jpg") + ) + assert input_paid_media_video.media == data_file("telegram.mp4").as_uri() + assert input_paid_media_video.thumbnail == data_file("telegram.jpg").as_uri() + + @pytest.fixture(scope="module") def media_group(photo, thumb): # noqa: F811 return [ @@ -1044,3 +1150,20 @@ def build_media(parse_mode, med_type): assert message.caption_entities == () # make sure that the media was not modified assert media.parse_mode == copied_media.parse_mode + + async def test_send_paid_media(self, bot, channel_id, photo_file, video_file): # noqa: F811 + msg = await bot.send_paid_media( + chat_id=channel_id, + star_count=20, + media=[ + InputPaidMediaPhoto(media=photo_file), + InputPaidMediaVideo(media=video_file), + ], + caption="bye onlyfans", + show_caption_above_media=True, + ) + + assert isinstance(msg, Message) + assert msg.caption == "bye onlyfans" + assert msg.show_caption_above_media + assert msg.paid_media.star_count == 20 diff --git a/tests/_payment/test_refundedpayment.py b/tests/_payment/test_refundedpayment.py new file mode 100644 index 00000000000..75e252660da --- /dev/null +++ b/tests/_payment/test_refundedpayment.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# 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/]. +import pytest + +from telegram import RefundedPayment +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def refunded_payment(): + return RefundedPayment( + TestRefundedPaymentBase.currency, + TestRefundedPaymentBase.total_amount, + TestRefundedPaymentBase.invoice_payload, + TestRefundedPaymentBase.telegram_payment_charge_id, + TestRefundedPaymentBase.provider_payment_charge_id, + ) + + +class TestRefundedPaymentBase: + invoice_payload = "invoice_payload" + currency = "EUR" + total_amount = 100 + telegram_payment_charge_id = "telegram_payment_charge_id" + provider_payment_charge_id = "provider_payment_charge_id" + + +class TestRefundedPaymentWithoutRequest(TestRefundedPaymentBase): + def test_slot_behaviour(self, refunded_payment): + inst = refunded_payment + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, bot): + json_dict = { + "invoice_payload": self.invoice_payload, + "currency": self.currency, + "total_amount": self.total_amount, + "telegram_payment_charge_id": self.telegram_payment_charge_id, + "provider_payment_charge_id": self.provider_payment_charge_id, + } + refunded_payment = RefundedPayment.de_json(json_dict, bot) + assert refunded_payment.api_kwargs == {} + + assert refunded_payment.invoice_payload == self.invoice_payload + assert refunded_payment.currency == self.currency + assert refunded_payment.total_amount == self.total_amount + assert refunded_payment.telegram_payment_charge_id == self.telegram_payment_charge_id + assert refunded_payment.provider_payment_charge_id == self.provider_payment_charge_id + + def test_to_dict(self, refunded_payment): + refunded_payment_dict = refunded_payment.to_dict() + + assert isinstance(refunded_payment_dict, dict) + assert refunded_payment_dict["invoice_payload"] == refunded_payment.invoice_payload + assert refunded_payment_dict["currency"] == refunded_payment.currency + assert refunded_payment_dict["total_amount"] == refunded_payment.total_amount + assert ( + refunded_payment_dict["telegram_payment_charge_id"] + == refunded_payment.telegram_payment_charge_id + ) + assert ( + refunded_payment_dict["provider_payment_charge_id"] + == refunded_payment.provider_payment_charge_id + ) + + def test_equality(self): + a = RefundedPayment( + self.currency, + self.total_amount, + self.invoice_payload, + self.telegram_payment_charge_id, + self.provider_payment_charge_id, + ) + b = RefundedPayment( + self.currency, + self.total_amount, + self.invoice_payload, + self.telegram_payment_charge_id, + self.provider_payment_charge_id, + ) + c = RefundedPayment("", 0, "", self.telegram_payment_charge_id) + d = RefundedPayment( + self.currency, + self.total_amount, + self.invoice_payload, + "", + ) + + assert a == b + assert hash(a) == hash(b) + assert a is not b + + assert a == c + assert hash(a) == hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/_payment/test_successfulpayment.py b/tests/_payment/test_successfulpayment.py index a0de91198c7..a93238c537a 100644 --- a/tests/_payment/test_successfulpayment.py +++ b/tests/_payment/test_successfulpayment.py @@ -68,6 +68,7 @@ def test_de_json(self, bot): assert successful_payment.invoice_payload == self.invoice_payload assert successful_payment.shipping_option_id == self.shipping_option_id assert successful_payment.currency == self.currency + assert successful_payment.total_amount == self.total_amount assert successful_payment.order_info == self.order_info assert successful_payment.telegram_payment_charge_id == self.telegram_payment_charge_id assert successful_payment.provider_payment_charge_id == self.provider_payment_charge_id @@ -81,6 +82,7 @@ def test_to_dict(self, successful_payment): successful_payment_dict["shipping_option_id"] == successful_payment.shipping_option_id ) assert successful_payment_dict["currency"] == successful_payment.currency + assert successful_payment_dict["total_amount"] == successful_payment.total_amount assert successful_payment_dict["order_info"] == successful_payment.order_info.to_dict() assert ( successful_payment_dict["telegram_payment_charge_id"] diff --git a/tests/auxil/pytest_classes.py b/tests/auxil/pytest_classes.py index 5586a8ea0b7..1b976b02e6c 100644 --- a/tests/auxil/pytest_classes.py +++ b/tests/auxil/pytest_classes.py @@ -21,7 +21,7 @@ pytest framework. A common change is to allow monkeypatching of the class members by not enforcing slots in the subclasses.""" from telegram import Bot, Message, User -from telegram.ext import Application, ExtBot +from telegram.ext import Application, ExtBot, Updater from tests.auxil.ci_bots import BOT_INFO_PROVIDER from tests.auxil.constants import PRIVATE_KEY from tests.auxil.envvars import TEST_WITH_OPT_DEPS @@ -89,6 +89,10 @@ class PytestMessage(Message): pass +class PytestUpdater(Updater): + pass + + def make_bot(bot_info=None, **kwargs): """ Tests are executed on tg.ext.ExtBot, as that class only extends the functionality of tg.bot diff --git a/tests/conftest.py b/tests/conftest.py index 213bcff4a23..a9ef3e68641 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,7 @@ import datetime import logging import sys +from pathlib import Path from typing import Dict, List from uuid import uuid4 @@ -291,6 +292,5 @@ def timezone(tzinfo): @pytest.fixture() -def tmp_file(tmp_path): - with tmp_path / uuid4().hex as file: - yield file +def tmp_file(tmp_path) -> Path: + return tmp_path / uuid4().hex diff --git a/tests/ext/test_application.py b/tests/ext/test_application.py index 714abf8537a..a74f3c739bf 100644 --- a/tests/ext/test_application.py +++ b/tests/ext/test_application.py @@ -61,7 +61,7 @@ from tests.auxil.build_messages import make_message_update from tests.auxil.files import PROJECT_ROOT_PATH from tests.auxil.networking import send_webhook_message -from tests.auxil.pytest_classes import make_bot +from tests.auxil.pytest_classes import PytestApplication, PytestUpdater, make_bot from tests.auxil.slots import mro_slots @@ -1581,7 +1581,13 @@ def thread_target(): async def post_init(app: Application) -> None: events.append("post_init") - app = Application.builder().bot(one_time_bot).post_init(post_init).build() + app = ( + Application.builder() + .application_class(PytestApplication) + .updater(PytestUpdater(one_time_bot, asyncio.Queue())) + .post_init(post_init) + .build() + ) app.bot._unfreeze() monkeypatch.setattr(app.bot, "get_updates", get_updates) monkeypatch.setattr( @@ -1624,7 +1630,13 @@ def thread_target(): async def post_shutdown(app: Application) -> None: events.append("post_shutdown") - app = Application.builder().bot(one_time_bot).post_shutdown(post_shutdown).build() + app = ( + Application.builder() + .application_class(PytestApplication) + .updater(PytestUpdater(one_time_bot, asyncio.Queue())) + .post_shutdown(post_shutdown) + .build() + ) app.bot._unfreeze() monkeypatch.setattr(app.bot, "get_updates", get_updates) monkeypatch.setattr( @@ -1650,7 +1662,7 @@ async def post_shutdown(app: Application) -> None: platform.system() == "Windows", reason="Can't send signals without stopping whole process on windows", ) - def test_run_polling_post_stop(self, bot, monkeypatch): + def test_run_polling_post_stop(self, one_time_bot, monkeypatch): events = [] async def get_updates(*args, **kwargs): @@ -1671,7 +1683,13 @@ def thread_target(): async def post_stop(app: Application) -> None: events.append("post_stop") - app = Application.builder().token(bot.token).post_stop(post_stop).build() + app = ( + Application.builder() + .application_class(PytestApplication) + .updater(PytestUpdater(one_time_bot, asyncio.Queue())) + .post_stop(post_stop) + .build() + ) app.bot._unfreeze() monkeypatch.setattr(app.bot, "get_updates", get_updates) monkeypatch.setattr(app, "stop", call_after(app.stop, lambda _: events.append("stop"))) @@ -1863,7 +1881,13 @@ def thread_target(): async def post_init(app: Application) -> None: events.append("post_init") - app = Application.builder().bot(one_time_bot).post_init(post_init).build() + app = ( + Application.builder() + .post_init(post_init) + .application_class(PytestApplication) + .updater(PytestUpdater(one_time_bot, asyncio.Queue())) + .build() + ) app.bot._unfreeze() monkeypatch.setattr(app.bot, "set_webhook", set_webhook) monkeypatch.setattr(app.bot, "delete_webhook", delete_webhook) @@ -1923,7 +1947,13 @@ def thread_target(): async def post_shutdown(app: Application) -> None: events.append("post_shutdown") - app = Application.builder().bot(one_time_bot).post_shutdown(post_shutdown).build() + app = ( + Application.builder() + .application_class(PytestApplication) + .updater(PytestUpdater(one_time_bot, asyncio.Queue())) + .post_shutdown(post_shutdown) + .build() + ) app.bot._unfreeze() monkeypatch.setattr(app.bot, "set_webhook", set_webhook) monkeypatch.setattr(app.bot, "delete_webhook", delete_webhook) @@ -1960,7 +1990,7 @@ async def post_shutdown(app: Application) -> None: platform.system() == "Windows", reason="Can't send signals without stopping whole process on windows", ) - def test_run_webhook_post_stop(self, bot, monkeypatch): + def test_run_webhook_post_stop(self, one_time_bot, monkeypatch): events = [] async def delete_webhook(*args, **kwargs): @@ -1987,7 +2017,13 @@ def thread_target(): async def post_stop(app: Application) -> None: events.append("post_stop") - app = Application.builder().token(bot.token).post_stop(post_stop).build() + app = ( + Application.builder() + .application_class(PytestApplication) + .updater(PytestUpdater(one_time_bot, asyncio.Queue())) + .post_stop(post_stop) + .build() + ) app.bot._unfreeze() monkeypatch.setattr(app.bot, "set_webhook", set_webhook) monkeypatch.setattr(app.bot, "delete_webhook", delete_webhook) @@ -2424,7 +2460,7 @@ async def callback(*args, **kwargs): monkeypatch.setattr(Application, "start", functools.partial(callback, name="start")) monkeypatch.setattr( - Updater, "start_polling", functools.partial(callback, name="start_polling") + Updater, "start_polling", functools.partialmethod(callback, name="start_polling") ) app = ( @@ -2480,7 +2516,13 @@ async def task(app): app.create_task(task(app)) - app = ApplicationBuilder().bot(one_time_bot).post_init(post_init).build() + app = ( + ApplicationBuilder() + .application_class(PytestApplication) + .updater(PytestUpdater(one_time_bot, asyncio.Queue())) + .post_init(post_init) + .build() + ) monkeypatch.setattr(app.bot, "get_updates", get_updates) monkeypatch.setattr(app.bot, "set_webhook", set_webhook) monkeypatch.setattr(app.bot, "delete_webhook", delete_webhook) diff --git a/tests/ext/test_filters.py b/tests/ext/test_filters.py index 97d17e2ebaf..9cf47dc47fa 100644 --- a/tests/ext/test_filters.py +++ b/tests/ext/test_filters.py @@ -902,6 +902,11 @@ def test_filters_story(self, update): update.message.story = "test" assert filters.STORY.check_update(update) + def test_filters_paid_media(self, update): + assert not filters.PAID_MEDIA.check_update(update) + update.message.paid_media = "test" + assert filters.PAID_MEDIA.check_update(update) + def test_filters_video(self, update): assert not filters.VIDEO.check_update(update) update.message.video = "test" @@ -1095,6 +1100,11 @@ def test_filters_status_update(self, update): assert filters.StatusUpdate.CHAT_BACKGROUND_SET.check_update(update) update.message.chat_background_set = None + update.message.refunded_payment = "refunded_payment" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.REFUNDED_PAYMENT.check_update(update) + update.message.refunded_payment = None + def test_filters_forwarded(self, update, message_origin_user): assert filters.FORWARDED.check_update(update) update.message.forward_origin = MessageOriginHiddenUser(datetime.datetime.utcnow(), 1) diff --git a/tests/ext/test_updater.py b/tests/ext/test_updater.py index feed189c662..1fd8985dea1 100644 --- a/tests/ext/test_updater.py +++ b/tests/ext/test_updater.py @@ -1179,6 +1179,7 @@ def de_json_fails(*args, **kwargs): assert len(caplog.records) == 1 assert caplog.records[-1].getMessage().startswith("Something went wrong processing") + assert "Received data was: {" in caplog.records[-1].getMessage() assert caplog.records[-1].name == "telegram.ext.Updater" assert response.status_code == 400 assert response.text == self.response_text.format( diff --git a/tests/request/test_request.py b/tests/request/test_request.py index 47e7d2125f6..0f664cbdbcf 100644 --- a/tests/request/test_request.py +++ b/tests/request/test_request.py @@ -47,6 +47,7 @@ from telegram.request._requestparameter import RequestParameter from telegram.warnings import PTBDeprecationWarning from tests.auxil.envvars import TEST_WITH_OPT_DEPS +from tests.auxil.networking import NonchalantHttpxRequest from tests.auxil.slots import mro_slots # We only need mixed_rqs fixture, but it uses the others, so pytest needs us to import them as well @@ -72,7 +73,7 @@ async def make_assertion(*args, **kwargs): @pytest.fixture() async def httpx_request(): - async with HTTPXRequest() as rq: + async with NonchalantHttpxRequest() as rq: yield rq @@ -137,7 +138,7 @@ async def initialize(): async def shutdown(): self.test_flag.append("stop") - httpx_request = HTTPXRequest() + httpx_request = NonchalantHttpxRequest() monkeypatch.setattr(httpx_request, "initialize", initialize) monkeypatch.setattr(httpx_request, "shutdown", shutdown) @@ -154,7 +155,7 @@ async def initialize(): async def shutdown(): self.test_flag = "stop" - httpx_request = HTTPXRequest() + httpx_request = NonchalantHttpxRequest() monkeypatch.setattr(httpx_request, "initialize", initialize) monkeypatch.setattr(httpx_request, "shutdown", shutdown) @@ -545,7 +546,7 @@ async def initialize(): async def aclose(*args): self.test_flag.append("stop") - httpx_request = HTTPXRequest() + httpx_request = NonchalantHttpxRequest() monkeypatch.setattr(httpx_request, "initialize", initialize) monkeypatch.setattr(httpx.AsyncClient, "aclose", aclose) @@ -562,7 +563,7 @@ async def initialize(): async def aclose(*args): self.test_flag = "stop" - httpx_request = HTTPXRequest() + httpx_request = NonchalantHttpxRequest() monkeypatch.setattr(httpx_request, "initialize", initialize) monkeypatch.setattr(httpx.AsyncClient, "aclose", aclose) @@ -604,9 +605,9 @@ async def make_assertion(_, **kwargs): read_timeout=default_timeouts.read, write_timeout=default_timeouts.write, pool_timeout=default_timeouts.pool, - ) as httpx_request: + ) as httpx_request_ctx: monkeypatch.setattr(httpx.AsyncClient, "request", make_assertion) - await httpx_request.do_request( + await httpx_request_ctx.do_request( method="GET", url="URL", connect_timeout=manual_timeouts.connect, diff --git a/tests/test_bot.py b/tests/test_bot.py index 8fa53628193..85232a8c708 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -73,6 +73,8 @@ ReplyParameters, SentWebAppMessage, ShippingOption, + StarTransaction, + StarTransactions, Update, User, WebAppInfo, @@ -97,7 +99,7 @@ from tests.auxil.ci_bots import FALLBACKS from tests.auxil.envvars import GITHUB_ACTION, TEST_WITH_OPT_DEPS from tests.auxil.files import data_file -from tests.auxil.networking import expect_bad_request +from tests.auxil.networking import NonchalantHttpxRequest, expect_bad_request from tests.auxil.pytest_classes import PytestBot, PytestExtBot, make_bot from tests.auxil.slots import mro_slots @@ -253,7 +255,7 @@ async def initialize(*args, **kwargs): async def stop(*args, **kwargs): self.test_flag.append("stop") - temp_bot = PytestBot(token=bot.token) + temp_bot = PytestBot(token=bot.token, request=NonchalantHttpxRequest()) orig_stop = temp_bot.request.shutdown try: @@ -533,6 +535,29 @@ async def post(url, request_data: RequestData, *args, **kwargs): 123, "text", api_kwargs={"unknown_kwarg_1": 7, "unknown_kwarg_2": 5} ) + async def test_get_updates_deserialization_error(self, bot, monkeypatch, caplog): + async def faulty_do_request(*args, **kwargs): + return ( + HTTPStatus.OK, + b'{"ok": true, "result": [{"update_id": "1", "message": "unknown_format"}]}', + ) + + monkeypatch.setattr(HTTPXRequest, "do_request", faulty_do_request) + + bot = PytestExtBot(get_updates_request=HTTPXRequest(), token=bot.token) + + with caplog.at_level(logging.CRITICAL), pytest.raises(AttributeError): + await bot.get_updates() + + assert len(caplog.records) == 1 + assert caplog.records[0].name == "telegram.ext.ExtBot" + assert caplog.records[0].levelno == logging.CRITICAL + assert caplog.records[0].getMessage() == ( + "Error while parsing updates! Received data was " + "[{'update_id': '1', 'message': 'unknown_format'}]" + ) + assert caplog.records[0].exc_info[0] is AttributeError + async def test_answer_web_app_query(self, bot, raw_bot, monkeypatch): params = False @@ -559,11 +584,11 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): copied_result = copy.copy(result) ext_bot = bot - for bot in (ext_bot, raw_bot): + for bot_type in (ext_bot, raw_bot): # We need to test 1) below both the bot and raw_bot and setting this up with # pytest.parametrize appears to be difficult ... - monkeypatch.setattr(bot.request, "post", make_assertion) - web_app_msg = await bot.answer_web_app_query("12345", result) + monkeypatch.setattr(bot_type.request, "post", make_assertion) + web_app_msg = await bot_type.answer_web_app_query("12345", result) assert params, "something went wrong with passing arguments to the request" assert isinstance(web_app_msg, SentWebAppMessage) assert web_app_msg.inline_message_id == "321" @@ -761,11 +786,11 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): copied_results = copy.copy(results) ext_bot = bot - for bot in (ext_bot, raw_bot): + for bot_type in (ext_bot, raw_bot): # We need to test 1) below both the bot and raw_bot and setting this up with # pytest.parametrize appears to be difficult ... - monkeypatch.setattr(bot.request, "post", make_assertion) - assert await bot.answer_inline_query( + monkeypatch.setattr(bot_type.request, "post", make_assertion) + assert await bot_type.answer_inline_query( 1234, results=results, cache_time=300, @@ -790,7 +815,7 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): copied_results[idx].input_message_content, "disable_web_page_preview", None ) - monkeypatch.delattr(bot.request, "post") + monkeypatch.delattr(bot_type.request, "post") async def test_answer_inline_query_no_default_parse_mode(self, monkeypatch, bot): async def make_assertion(url, request_data: RequestData, *args, **kwargs): @@ -2171,14 +2196,17 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): async def test_business_connection_id_argument(self, bot, monkeypatch): """We can't connect to a business acc, so we just test that the correct data is passed. - We also can't test every single method easily, so we just test one. Our linting will catch - any unused args with the others.""" + We also can't test every single method easily, so we just test a few. Our linting will + catch any unused args with the others.""" async def make_assertion(url, request_data: RequestData, *args, **kwargs): - return request_data.parameters.get("business_connection_id") == 42 + assert request_data.parameters.get("business_connection_id") == 42 + return {} monkeypatch.setattr(bot.request, "post", make_assertion) - assert await bot.send_message(2, "text", business_connection_id=42) + + await bot.send_message(2, "text", business_connection_id=42) + await bot.stop_poll(chat_id=1, message_id=2, business_connection_id=42) async def test_message_effect_id_argument(self, bot, monkeypatch): """We can't test every single method easily, so we just test one. Our linting will catch @@ -2221,6 +2249,20 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): monkeypatch.setattr(bot.request, "post", make_assertion) assert await bot.refund_star_payment(42, "37") + async def test_get_star_transactions(self, bot, monkeypatch): + # we just want to test the offset parameter + st = StarTransactions([StarTransaction("1", 1, dtm.datetime.now())]).to_json() + + async def do_request(url, request_data: RequestData, *args, **kwargs): + offset = request_data.parameters.get("offset") == 3 + if offset: + return 200, f'{{"ok": true, "result": {st}}}'.encode() + return 400, b'{"ok": false, "result": []}' + + monkeypatch.setattr(bot.request, "do_request", do_request) + obj = await bot.get_star_transactions(offset=3) + assert isinstance(obj, StarTransactions) + class TestBotWithRequest: """ @@ -2326,11 +2368,15 @@ async def test_delete_message_old_message(self, bot, chat_id): # test modules. No need to duplicate here. async def test_delete_messages(self, bot, chat_id): - msg1 = await bot.send_message(chat_id, text="will be deleted") - msg2 = await bot.send_message(chat_id, text="will be deleted") - await asyncio.sleep(2) + msg1, msg2 = await asyncio.gather( + bot.send_message(chat_id, text="will be deleted"), + bot.send_message(chat_id, text="will be deleted"), + ) - assert await bot.delete_messages(chat_id=chat_id, message_ids=(msg1.id, msg2.id)) is True + assert ( + await bot.delete_messages(chat_id=chat_id, message_ids=sorted((msg1.id, msg2.id))) + is True + ) async def test_send_venue(self, bot, chat_id): longitude = -46.788279 @@ -3873,7 +3919,7 @@ async def test_copy_messages(self, bot, chat_id): msg1, msg2 = await tasks copy_messages = await bot.copy_messages( - chat_id, from_chat_id=chat_id, message_ids=(msg1.message_id, msg2.message_id) + chat_id, from_chat_id=chat_id, message_ids=sorted((msg1.message_id, msg2.message_id)) ) assert isinstance(copy_messages, tuple) @@ -4208,3 +4254,8 @@ async def test_do_api_request_list_return_type(self, bot, chat_id, return_type): @pytest.mark.parametrize("return_type", [Message, None]) async def test_do_api_request_bool_return_type(self, bot, chat_id, return_type): assert await bot.do_api_request("delete_my_commands", return_type=return_type) is True + + async def test_get_star_transactions(self, bot): + transactions = await bot.get_star_transactions(limit=1) + assert isinstance(transactions, StarTransactions) + assert len(transactions.transactions) == 0 diff --git a/tests/test_callbackquery.py b/tests/test_callbackquery.py index 66dc6856924..5e41b5993cf 100644 --- a/tests/test_callbackquery.py +++ b/tests/test_callbackquery.py @@ -68,8 +68,8 @@ class TestCallbackQueryWithoutRequest(TestCallbackQueryBase): @staticmethod def skip_params(callback_query: CallbackQuery): if callback_query.inline_message_id: - return {"message_id", "chat_id"} - return {"inline_message_id"} + return {"message_id", "chat_id", "business_connection_id"} + return {"inline_message_id", "business_connection_id"} @staticmethod def shortcut_kwargs(callback_query: CallbackQuery): @@ -178,7 +178,7 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( CallbackQuery.edit_message_text, Bot.edit_message_text, - ["inline_message_id", "message_id", "chat_id"], + ["inline_message_id", "message_id", "chat_id", "business_connection_id"], [], ) assert await check_shortcut_call( @@ -210,7 +210,7 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( CallbackQuery.edit_message_caption, Bot.edit_message_caption, - ["inline_message_id", "message_id", "chat_id"], + ["inline_message_id", "message_id", "chat_id", "business_connection_id"], [], ) assert await check_shortcut_call( @@ -242,7 +242,7 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( CallbackQuery.edit_message_reply_markup, Bot.edit_message_reply_markup, - ["inline_message_id", "message_id", "chat_id"], + ["inline_message_id", "message_id", "chat_id", "business_connection_id"], [], ) assert await check_shortcut_call( @@ -274,7 +274,7 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( CallbackQuery.edit_message_media, Bot.edit_message_media, - ["inline_message_id", "message_id", "chat_id"], + ["inline_message_id", "message_id", "chat_id", "business_connection_id"], [], ) assert await check_shortcut_call( @@ -308,7 +308,7 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( CallbackQuery.edit_message_live_location, Bot.edit_message_live_location, - ["inline_message_id", "message_id", "chat_id"], + ["inline_message_id", "message_id", "chat_id", "business_connection_id"], [], ) assert await check_shortcut_call( @@ -340,7 +340,7 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( CallbackQuery.stop_message_live_location, Bot.stop_message_live_location, - ["inline_message_id", "message_id", "chat_id"], + ["inline_message_id", "message_id", "chat_id", "business_connection_id"], [], ) assert await check_shortcut_call( diff --git a/tests/test_chat.py b/tests/test_chat.py index a11b40c647b..682bdbe514a 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -1244,6 +1244,22 @@ async def make_assertion(*_, **kwargs): 123, [ReactionTypeEmoji(ReactionEmoji.THUMBS_DOWN)], True ) + async def test_instance_method_send_paid_media(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == chat.id + and kwargs["media"] == "media" + and kwargs["star_count"] == 42 + and kwargs["caption"] == "stars" + ) + + assert check_shortcut_signature(Chat.send_paid_media, Bot.send_paid_media, ["chat_id"], []) + assert await check_shortcut_call(chat.send_paid_media, chat.get_bot(), "send_paid_media") + assert await check_defaults_handling(chat.send_paid_media, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "send_paid_media", make_assertion) + assert await chat.send_paid_media(media="media", star_count=42, caption="stars") + def test_mention_html(self): chat = Chat(id=1, type="foo") with pytest.raises(TypeError, match="Can not create a mention to a private group chat"): diff --git a/tests/test_chatfullinfo.py b/tests/test_chatfullinfo.py index b547e4de913..ee9d697ca71 100644 --- a/tests/test_chatfullinfo.py +++ b/tests/test_chatfullinfo.py @@ -55,13 +55,15 @@ def chat_full_info(bot): bio=TestChatFullInfoBase.bio, linked_chat_id=TestChatFullInfoBase.linked_chat_id, location=TestChatFullInfoBase.location, - has_private_forwards=True, - has_protected_content=True, - has_visible_history=True, - join_to_send_messages=True, - join_by_request=True, - has_restricted_voice_and_video_messages=True, - is_forum=True, + has_private_forwards=TestChatFullInfoBase.has_private_forwards, + has_protected_content=TestChatFullInfoBase.has_protected_content, + has_visible_history=TestChatFullInfoBase.has_visible_history, + join_to_send_messages=TestChatFullInfoBase.join_to_send_messages, + join_by_request=TestChatFullInfoBase.join_by_request, + has_restricted_voice_and_video_messages=( + TestChatFullInfoBase.has_restricted_voice_and_video_messages + ), + is_forum=TestChatFullInfoBase.is_forum, active_usernames=TestChatFullInfoBase.active_usernames, emoji_status_custom_emoji_id=TestChatFullInfoBase.emoji_status_custom_emoji_id, emoji_status_expiration_date=TestChatFullInfoBase.emoji_status_expiration_date, @@ -76,10 +78,11 @@ def chat_full_info(bot): business_intro=TestChatFullInfoBase.business_intro, business_location=TestChatFullInfoBase.business_location, business_opening_hours=TestChatFullInfoBase.business_opening_hours, - birthdate=Birthdate(1, 1), + birthdate=TestChatFullInfoBase.birthdate, personal_chat=TestChatFullInfoBase.personal_chat, - first_name="first_name", - last_name="last_name", + first_name=TestChatFullInfoBase.first_name, + last_name=TestChatFullInfoBase.last_name, + can_send_paid_media=TestChatFullInfoBase.can_send_paid_media, ) chat.set_bot(bot) chat._unfreeze() @@ -136,6 +139,7 @@ class TestChatFullInfoBase: personal_chat = Chat(3, "private", "private") first_name = "first_name" last_name = "last_name" + can_send_paid_media = True class TestChatFullInfoWithoutRequest(TestChatFullInfoBase): @@ -188,6 +192,7 @@ def test_de_json(self, bot): "personal_chat": self.personal_chat.to_dict(), "first_name": self.first_name, "last_name": self.last_name, + "can_send_paid_media": self.can_send_paid_media, } cfi = ChatFullInfo.de_json(json_dict, bot) assert cfi.id == self.id_ @@ -232,6 +237,7 @@ def test_de_json(self, bot): assert cfi.first_name == self.first_name assert cfi.last_name == self.last_name assert cfi.max_reaction_count == self.max_reaction_count + assert cfi.can_send_paid_media == self.can_send_paid_media def test_de_json_localization(self, bot, raw_bot, tz_bot): json_dict = { @@ -305,6 +311,7 @@ def test_to_dict(self, chat_full_info): assert cfi_dict["personal_chat"] == cfi.personal_chat.to_dict() assert cfi_dict["first_name"] == cfi.first_name assert cfi_dict["last_name"] == cfi.last_name + assert cfi_dict["can_send_paid_media"] == cfi.can_send_paid_media assert cfi_dict["max_reaction_count"] == cfi.max_reaction_count diff --git a/tests/test_constants.py b/tests/test_constants.py index 75368857325..dc76bea3aef 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -216,6 +216,8 @@ def test_message_attachment_type_completeness_reverse(self): name = to_snake_case(match.group(1)) if name == "photo_size": name = "photo" + if name == "paid_media_info": + name = "paid_media" try: constants.MessageAttachmentType(name) except ValueError: @@ -232,6 +234,11 @@ async def test_max_message_length(self, bot, chat_id): return_exceptions=True, ) good_msg, bad_msg = await tasks + + if isinstance(good_msg, BaseException): + # handling xfails + raise good_msg + assert good_msg.text == good_text assert isinstance(bad_msg, BadRequest) assert "Message is too long" in str(bad_msg) @@ -245,6 +252,11 @@ async def test_max_caption_length(self, bot, chat_id): return_exceptions=True, ) good_msg, bad_msg = await tasks + + if isinstance(good_msg, BaseException): + # handling xfails + raise good_msg + assert good_msg.caption == good_caption assert isinstance(bad_msg, BadRequest) assert "Message caption is too long" in str(bad_msg) diff --git a/tests/test_menubutton.py b/tests/test_menubutton.py index bb859a20609..48c9c30c9f2 100644 --- a/tests/test_menubutton.py +++ b/tests/test_menubutton.py @@ -136,6 +136,13 @@ def test_de_json_subclass(self, scope_class, bot): json_dict = {"type": "invalid", "text": self.text, "web_app": self.web_app.to_dict()} assert type(scope_class.de_json(json_dict, bot)) is scope_class + def test_de_json_empty_data(self, scope_class): + if scope_class in (MenuButtonWebApp,): + pytest.skip( + "This test is not relevant for subclasses that have more attributes than just type" + ) + assert isinstance(scope_class.de_json({}, None), scope_class) + def test_to_dict(self, menu_button): menu_button_dict = menu_button.to_dict() diff --git a/tests/test_message.py b/tests/test_message.py index c51e3a92a68..9e575a99f45 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -46,11 +46,14 @@ MessageAutoDeleteTimerChanged, MessageEntity, MessageOriginChat, + PaidMediaInfo, + PaidMediaPreview, PassportData, PhotoSize, Poll, PollOption, ProximityAlertTriggered, + RefundedPayment, ReplyParameters, SharedUser, Sticker, @@ -275,6 +278,8 @@ def message(bot): {"chat_background_set": ChatBackground(type=BackgroundTypeChatTheme("ice"))}, {"effect_id": "123456789"}, {"show_caption_above_media": True}, + {"paid_media": PaidMediaInfo(5, [PaidMediaPreview(10, 10, 10)])}, + {"refunded_payment": RefundedPayment("EUR", 243, "payload", "charge_id", "provider_id")}, ], ids=[ "reply", @@ -346,6 +351,8 @@ def message(bot): "chat_background_set", "effect_id", "show_caption_above_media", + "paid_media", + "refunded_payment", ], ) def message_params(bot, request): @@ -1221,6 +1228,7 @@ def test_effective_attachment(self, message_params): "game", "invoice", "location", + "paid_media", "passport_data", "photo", "poll", @@ -2347,7 +2355,7 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.edit_text, Bot.edit_message_text, - ["chat_id", "message_id", "inline_message_id"], + ["chat_id", "message_id", "inline_message_id", "business_connection_id"], [], ) assert await check_shortcut_call( @@ -2355,7 +2363,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "edit_message_text", skip_params=["inline_message_id"], - shortcut_kwargs=["message_id", "chat_id"], + shortcut_kwargs=["message_id", "chat_id", "business_connection_id"], ) assert await check_defaults_handling(message.edit_text, message.get_bot()) @@ -2372,7 +2380,7 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.edit_caption, Bot.edit_message_caption, - ["chat_id", "message_id", "inline_message_id"], + ["chat_id", "message_id", "inline_message_id", "business_connection_id"], [], ) assert await check_shortcut_call( @@ -2380,7 +2388,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "edit_message_caption", skip_params=["inline_message_id"], - shortcut_kwargs=["message_id", "chat_id"], + shortcut_kwargs=["message_id", "chat_id", "business_connection_id"], ) assert await check_defaults_handling(message.edit_caption, message.get_bot()) @@ -2397,7 +2405,7 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.edit_media, Bot.edit_message_media, - ["chat_id", "message_id", "inline_message_id"], + ["chat_id", "message_id", "inline_message_id", "business_connection_id"], [], ) assert await check_shortcut_call( @@ -2405,7 +2413,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "edit_message_media", skip_params=["inline_message_id"], - shortcut_kwargs=["message_id", "chat_id"], + shortcut_kwargs=["message_id", "chat_id", "business_connection_id"], ) assert await check_defaults_handling(message.edit_media, message.get_bot()) @@ -2422,7 +2430,7 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.edit_reply_markup, Bot.edit_message_reply_markup, - ["chat_id", "message_id", "inline_message_id"], + ["chat_id", "message_id", "inline_message_id", "business_connection_id"], [], ) assert await check_shortcut_call( @@ -2430,7 +2438,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "edit_message_reply_markup", skip_params=["inline_message_id"], - shortcut_kwargs=["message_id", "chat_id"], + shortcut_kwargs=["message_id", "chat_id", "business_connection_id"], ) assert await check_defaults_handling(message.edit_reply_markup, message.get_bot()) @@ -2449,7 +2457,7 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.edit_live_location, Bot.edit_message_live_location, - ["chat_id", "message_id", "inline_message_id"], + ["chat_id", "message_id", "inline_message_id", "business_connection_id"], [], ) assert await check_shortcut_call( @@ -2457,7 +2465,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "edit_message_live_location", skip_params=["inline_message_id"], - shortcut_kwargs=["message_id", "chat_id"], + shortcut_kwargs=["message_id", "chat_id", "business_connection_id"], ) assert await check_defaults_handling(message.edit_live_location, message.get_bot()) @@ -2473,7 +2481,7 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.stop_live_location, Bot.stop_message_live_location, - ["chat_id", "message_id", "inline_message_id"], + ["chat_id", "message_id", "inline_message_id", "business_connection_id"], [], ) assert await check_shortcut_call( @@ -2481,7 +2489,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "stop_message_live_location", skip_params=["inline_message_id"], - shortcut_kwargs=["message_id", "chat_id"], + shortcut_kwargs=["message_id", "chat_id", "business_connection_id"], ) assert await check_defaults_handling(message.stop_live_location, message.get_bot()) @@ -2561,9 +2569,17 @@ async def make_assertion(*_, **kwargs): return chat_id and message_id assert check_shortcut_signature( - Message.stop_poll, Bot.stop_poll, ["chat_id", "message_id"], [] + Message.stop_poll, + Bot.stop_poll, + ["chat_id", "message_id", "business_connection_id"], + [], + ) + assert await check_shortcut_call( + message.stop_poll, + message.get_bot(), + "stop_poll", + shortcut_kwargs=["business_connection_id"], ) - assert await check_shortcut_call(message.stop_poll, message.get_bot(), "stop_poll") assert await check_defaults_handling(message.stop_poll, message.get_bot()) monkeypatch.setattr(message.get_bot(), "stop_poll", make_assertion) @@ -2624,7 +2640,9 @@ async def make_assertion(*args, **kwargs): async def test_default_do_quote( self, bot, message, default_quote, chat_type, expected, monkeypatch ): - message.set_bot(PytestExtBot(token=bot.token, defaults=Defaults(do_quote=default_quote))) + original_bot = message.get_bot() + temp_bot = PytestExtBot(token=bot.token, defaults=Defaults(do_quote=default_quote)) + message.set_bot(temp_bot) async def make_assertion(*_, **kwargs): reply_parameters = kwargs.get("reply_parameters") or ReplyParameters(message_id=False) @@ -2637,7 +2655,7 @@ async def make_assertion(*_, **kwargs): message.chat.type = chat_type assert await message.reply_text("test") finally: - message.get_bot()._defaults = None + message.set_bot(original_bot) async def test_edit_forum_topic(self, monkeypatch, message): async def make_assertion(*_, **kwargs): @@ -2756,3 +2774,15 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(message.get_bot(), "unpin_all_forum_topic_messages", make_assertion) assert await message.unpin_all_forum_topic_messages() + + def test_attachement_successful_payment_deprecated(self, message, recwarn): + message.successful_payment = "something" + # kinda unnecessary to assert but one needs to call the function ofc so. Here we are. + assert message.effective_attachment == "something" + assert len(recwarn) == 1 + assert ( + "successful_payment will no longer be considered an attachment in future major " + "versions" in str(recwarn[0].message) + ) + assert recwarn[0].category is PTBDeprecationWarning + assert recwarn[0].filename == __file__ diff --git a/tests/test_messageentity.py b/tests/test_messageentity.py index b14ec79c321..8bab9fec7b9 100644 --- a/tests/test_messageentity.py +++ b/tests/test_messageentity.py @@ -16,6 +16,9 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import random +from typing import List, Tuple + import pytest from telegram import MessageEntity, User @@ -81,6 +84,25 @@ def test_enum_init(self): entity = MessageEntity(type="url", offset=0, length=1) assert entity.type is MessageEntityType.URL + def test_fix_utf16(self): + text = "𠌕 bold 𝄢 italic underlined: 𝛙𝌢𑁍" + inputs_outputs: List[Tuple[Tuple[int, int, str], Tuple[int, int]]] = [ + ((2, 4, MessageEntity.BOLD), (3, 4)), + ((9, 6, MessageEntity.ITALIC), (11, 6)), + ((28, 3, MessageEntity.UNDERLINE), (30, 6)), + ] + random.shuffle(inputs_outputs) + unicode_entities = [ + MessageEntity(offset=_input[0], length=_input[1], type=_input[2]) + for _input, _ in inputs_outputs + ] + utf_16_entities = MessageEntity.adjust_message_entities_to_utf_16(text, unicode_entities) + for out_entity, input_output in zip(utf_16_entities, inputs_outputs): + _, output = input_output + offset, length = output + assert out_entity.offset == offset + assert out_entity.length == length + def test_equality(self): a = MessageEntity(MessageEntity.BOLD, 2, 3) b = MessageEntity(MessageEntity.BOLD, 2, 3) diff --git a/tests/test_meta.py b/tests/test_meta.py index fd698585dbb..7b83e7bb93a 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -35,9 +35,4 @@ def _change_test_dir(request, monkeypatch): @skip_disabled def test_build(): - assert os.system("python setup.py bdist_dumb") == 0 # pragma: no cover - - -@skip_disabled -def test_build_raw(): - assert os.system("python setup_raw.py bdist_dumb") == 0 # pragma: no cover + assert os.system("python -m build") == 0 # pragma: no cover diff --git a/tests/test_official/exceptions.py b/tests/test_official/exceptions.py index 91f186ff738..c9e3b4e4650 100644 --- a/tests/test_official/exceptions.py +++ b/tests/test_official/exceptions.py @@ -125,6 +125,10 @@ class ParamTypeCheckingExceptions: "BackgroundType": {"type"}, # attributes common to all subclasses "BackgroundFill": {"type"}, # attributes common to all subclasses "InputTextMessageContent": {"disable_web_page_preview"}, # convenience arg, here for bw compat + "RevenueWithdrawalState": {"type"}, # attributes common to all subclasses + "TransactionPartner": {"type"}, # attributes common to all subclasses + "PaidMedia": {"type"}, # attributes common to all subclasses + "InputPaidMedia": {"type", "media"}, # attributes common to all subclasses } @@ -149,6 +153,10 @@ def ptb_extra_params(object_name: str) -> set[str]: r"ReactionType\w+": {"type"}, r"BackgroundType\w+": {"type"}, r"BackgroundFill\w+": {"type"}, + r"RevenueWithdrawalState\w+": {"type"}, + r"TransactionPartner\w+": {"type"}, + r"PaidMedia\w+": {"type"}, + r"InputPaidMedia\w+": {"type"}, } diff --git a/tests/test_paidmedia.py b/tests/test_paidmedia.py new file mode 100644 index 00000000000..f76bcf6310f --- /dev/null +++ b/tests/test_paidmedia.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# 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/]. + +from copy import deepcopy + +import pytest + +from telegram import ( + Dice, + PaidMedia, + PaidMediaInfo, + PaidMediaPhoto, + PaidMediaPreview, + PaidMediaVideo, + PhotoSize, + Video, +) +from telegram.constants import PaidMediaType +from tests.auxil.slots import mro_slots + + +@pytest.fixture( + scope="module", + params=[ + PaidMedia.PREVIEW, + PaidMedia.PHOTO, + PaidMedia.VIDEO, + ], +) +def pm_scope_type(request): + return request.param + + +@pytest.fixture( + scope="module", + params=[ + PaidMediaPreview, + PaidMediaPhoto, + PaidMediaVideo, + ], + ids=[ + PaidMedia.PREVIEW, + PaidMedia.PHOTO, + PaidMedia.VIDEO, + ], +) +def pm_scope_class(request): + return request.param + + +@pytest.fixture( + scope="module", + params=[ + ( + PaidMediaPreview, + PaidMedia.PREVIEW, + ), + ( + PaidMediaPhoto, + PaidMedia.PHOTO, + ), + ( + PaidMediaVideo, + PaidMedia.VIDEO, + ), + ], + ids=[ + PaidMedia.PREVIEW, + PaidMedia.PHOTO, + PaidMedia.VIDEO, + ], +) +def pm_scope_class_and_type(request): + return request.param + + +@pytest.fixture(scope="module") +def paid_media(pm_scope_class_and_type): + # We use de_json here so that we don't have to worry about which class gets which arguments + return pm_scope_class_and_type[0].de_json( + { + "type": pm_scope_class_and_type[1], + "width": TestPaidMediaBase.width, + "height": TestPaidMediaBase.height, + "duration": TestPaidMediaBase.duration, + "video": TestPaidMediaBase.video.to_dict(), + "photo": [p.to_dict() for p in TestPaidMediaBase.photo], + }, + bot=None, + ) + + +def paid_media_video(): + return PaidMediaVideo(video=TestPaidMediaBase.video) + + +def paid_media_photo(): + return PaidMediaPhoto(photo=TestPaidMediaBase.photo) + + +@pytest.fixture(scope="module") +def paid_media_info(): + return PaidMediaInfo( + star_count=TestPaidMediaInfoBase.star_count, + paid_media=[paid_media_video(), paid_media_photo()], + ) + + +class TestPaidMediaBase: + width = 640 + height = 480 + duration = 60 + video = Video( + file_id="video_file_id", + width=640, + height=480, + file_unique_id="file_unique_id", + duration=60, + ) + photo = ( + PhotoSize( + file_id="photo_file_id", + width=640, + height=480, + file_unique_id="file_unique_id", + ), + ) + + +class TestPaidMediaWithoutRequest(TestPaidMediaBase): + def test_slot_behaviour(self, paid_media): + inst = paid_media + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, bot, pm_scope_class_and_type): + cls = pm_scope_class_and_type[0] + type_ = pm_scope_class_and_type[1] + + json_dict = { + "type": type_, + "width": self.width, + "height": self.height, + "duration": self.duration, + "video": self.video.to_dict(), + "photo": [p.to_dict() for p in self.photo], + } + pm = PaidMedia.de_json(json_dict, bot) + assert set(pm.api_kwargs.keys()) == { + "width", + "height", + "duration", + "video", + "photo", + } - set(cls.__slots__) + + assert isinstance(pm, PaidMedia) + assert type(pm) is cls + assert pm.type == type_ + if "width" in cls.__slots__: + assert pm.width == self.width + assert pm.height == self.height + assert pm.duration == self.duration + if "video" in cls.__slots__: + assert pm.video == self.video + if "photo" in cls.__slots__: + assert pm.photo == self.photo + + assert cls.de_json(None, bot) is None + assert PaidMedia.de_json({}, bot) is None + + def test_de_json_invalid_type(self, bot): + json_dict = { + "type": "invalid", + "width": self.width, + "height": self.height, + "duration": self.duration, + "video": self.video.to_dict(), + "photo": [p.to_dict() for p in self.photo], + } + pm = PaidMedia.de_json(json_dict, bot) + assert pm.api_kwargs == { + "width": self.width, + "height": self.height, + "duration": self.duration, + "video": self.video.to_dict(), + "photo": [p.to_dict() for p in self.photo], + } + + assert type(pm) is PaidMedia + assert pm.type == "invalid" + + def test_de_json_subclass(self, pm_scope_class, bot): + """This makes sure that e.g. PaidMediaPreivew(data) never returns a + TransactionPartnerPhoto instance.""" + json_dict = { + "type": "invalid", + "width": self.width, + "height": self.height, + "duration": self.duration, + "video": self.video.to_dict(), + "photo": [p.to_dict() for p in self.photo], + } + assert type(pm_scope_class.de_json(json_dict, bot)) is pm_scope_class + + def test_to_dict(self, paid_media): + pm_dict = paid_media.to_dict() + + assert isinstance(pm_dict, dict) + assert pm_dict["type"] == paid_media.type + if hasattr(paid_media_info, "width"): + assert pm_dict["width"] == paid_media.width + assert pm_dict["height"] == paid_media.height + assert pm_dict["duration"] == paid_media.duration + if hasattr(paid_media_info, "video"): + assert pm_dict["video"] == paid_media.video.to_dict() + if hasattr(paid_media_info, "photo"): + assert pm_dict["photo"] == [p.to_dict() for p in paid_media.photo] + + def test_type_enum_conversion(self): + assert type(PaidMedia("video").type) is PaidMediaType + assert PaidMedia("unknown").type == "unknown" + + def test_equality(self, paid_media, bot): + a = PaidMedia("base_type") + b = PaidMedia("base_type") + c = paid_media + d = deepcopy(paid_media) + e = Dice(4, "emoji") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + assert a != e + assert hash(a) != hash(e) + + assert c == d + assert hash(c) == hash(d) + + assert c != e + assert hash(c) != hash(e) + + if hasattr(c, "video"): + json_dict = c.to_dict() + json_dict["video"] = Video("different", "d2", 1, 1, 1).to_dict() + f = c.__class__.de_json(json_dict, bot) + + assert c != f + assert hash(c) != hash(f) + + if hasattr(c, "photo"): + json_dict = c.to_dict() + json_dict["photo"] = [PhotoSize("different", "d2", 1, 1, 1).to_dict()] + f = c.__class__.de_json(json_dict, bot) + + assert c != f + assert hash(c) != hash(f) + + +class TestPaidMediaInfoBase: + star_count = 200 + paid_media = [paid_media_video(), paid_media_photo()] + + +class TestPaidMediaInfoWithoutRequest(TestPaidMediaInfoBase): + def test_slot_behaviour(self, paid_media_info): + inst = paid_media_info + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, bot): + json_dict = { + "star_count": self.star_count, + "paid_media": [t.to_dict() for t in self.paid_media], + } + pmi = PaidMediaInfo.de_json(json_dict, bot) + pmi_none = PaidMediaInfo.de_json(None, bot) + assert pmi.paid_media == tuple(self.paid_media) + assert pmi.star_count == self.star_count + assert pmi_none is None + + def test_to_dict(self, paid_media_info): + assert paid_media_info.to_dict() == { + "star_count": self.star_count, + "paid_media": [t.to_dict() for t in self.paid_media], + } + + def test_equality(self): + pmi1 = PaidMediaInfo( + star_count=self.star_count, paid_media=[paid_media_video(), paid_media_photo()] + ) + pmi2 = PaidMediaInfo( + star_count=self.star_count, paid_media=[paid_media_video(), paid_media_photo()] + ) + pmi3 = PaidMediaInfo(star_count=100, paid_media=[paid_media_photo()]) + + assert pmi1 == pmi2 + assert hash(pmi1) == hash(pmi2) + + assert pmi1 != pmi3 + assert hash(pmi1) != hash(pmi3) diff --git a/tests/test_reply.py b/tests/test_reply.py index 4d2c35e8d31..f41ed01eb59 100644 --- a/tests/test_reply.py +++ b/tests/test_reply.py @@ -29,6 +29,8 @@ LinkPreviewOptions, MessageEntity, MessageOriginUser, + PaidMediaInfo, + PaidMediaPreview, ReplyParameters, TextQuote, User, @@ -44,6 +46,7 @@ def external_reply_info(): message_id=TestExternalReplyInfoBase.message_id, link_preview_options=TestExternalReplyInfoBase.link_preview_options, giveaway=TestExternalReplyInfoBase.giveaway, + paid_media=TestExternalReplyInfoBase.paid_media, ) @@ -59,6 +62,7 @@ class TestExternalReplyInfoBase: dtm.datetime.now(dtm.timezone.utc).replace(microsecond=0), 1, ) + paid_media = PaidMediaInfo(5, [PaidMediaPreview(10, 10, 10)]) class TestExternalReplyInfoWithoutRequest(TestExternalReplyInfoBase): @@ -76,6 +80,7 @@ def test_de_json(self, bot): "message_id": self.message_id, "link_preview_options": self.link_preview_options.to_dict(), "giveaway": self.giveaway.to_dict(), + "paid_media": self.paid_media.to_dict(), } external_reply_info = ExternalReplyInfo.de_json(json_dict, bot) @@ -86,6 +91,7 @@ def test_de_json(self, bot): assert external_reply_info.message_id == self.message_id assert external_reply_info.link_preview_options == self.link_preview_options assert external_reply_info.giveaway == self.giveaway + assert external_reply_info.paid_media == self.paid_media assert ExternalReplyInfo.de_json(None, bot) is None @@ -98,6 +104,7 @@ def test_to_dict(self, external_reply_info): assert ext_reply_info_dict["message_id"] == self.message_id assert ext_reply_info_dict["link_preview_options"] == self.link_preview_options.to_dict() assert ext_reply_info_dict["giveaway"] == self.giveaway.to_dict() + assert ext_reply_info_dict["paid_media"] == self.paid_media.to_dict() def test_equality(self, external_reply_info): a = external_reply_info diff --git a/tests/test_stars.py b/tests/test_stars.py new file mode 100644 index 00000000000..fb1339a7217 --- /dev/null +++ b/tests/test_stars.py @@ -0,0 +1,598 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# 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/]. + +import datetime +from copy import deepcopy + +import pytest + +from telegram import ( + Dice, + RevenueWithdrawalState, + RevenueWithdrawalStateFailed, + RevenueWithdrawalStatePending, + RevenueWithdrawalStateSucceeded, + StarTransaction, + StarTransactions, + TransactionPartner, + TransactionPartnerFragment, + TransactionPartnerOther, + TransactionPartnerTelegramAds, + TransactionPartnerUser, + User, +) +from telegram._utils.datetime import UTC, from_timestamp, to_timestamp +from telegram.constants import RevenueWithdrawalStateType, TransactionPartnerType +from tests.auxil.slots import mro_slots + + +def withdrawal_state_succeeded(): + return RevenueWithdrawalStateSucceeded( + date=datetime.datetime(2024, 1, 1, 0, 0, 0, 0, tzinfo=UTC), + url="url", + ) + + +@pytest.fixture() +def withdrawal_state_failed(): + return RevenueWithdrawalStateFailed() + + +@pytest.fixture() +def withdrawal_state_pending(): + return RevenueWithdrawalStatePending() + + +def transaction_partner_user(): + return TransactionPartnerUser( + user=User(id=1, is_bot=False, first_name="first_name", username="username"), + ) + + +@pytest.fixture() +def transaction_partner_other(): + return TransactionPartnerOther() + + +def transaction_partner_fragment(): + return TransactionPartnerFragment( + withdrawal_state=withdrawal_state_succeeded(), + ) + + +def star_transaction(): + return StarTransaction( + id="1", + amount=1, + date=to_timestamp(datetime.datetime(2024, 1, 1, 0, 0, 0, 0, tzinfo=UTC)), + source=transaction_partner_user(), + receiver=transaction_partner_fragment(), + ) + + +@pytest.fixture() +def star_transactions(): + return StarTransactions( + transactions=[ + star_transaction(), + star_transaction(), + ] + ) + + +@pytest.fixture( + scope="module", + params=[ + TransactionPartner.FRAGMENT, + TransactionPartner.OTHER, + TransactionPartner.USER, + TransactionPartner.TELEGRAM_ADS, + ], +) +def tp_scope_type(request): + return request.param + + +@pytest.fixture( + scope="module", + params=[ + TransactionPartnerFragment, + TransactionPartnerOther, + TransactionPartnerUser, + TransactionPartnerTelegramAds, + ], + ids=[ + TransactionPartner.FRAGMENT, + TransactionPartner.OTHER, + TransactionPartner.USER, + TransactionPartner.TELEGRAM_ADS, + ], +) +def tp_scope_class(request): + return request.param + + +@pytest.fixture( + scope="module", + params=[ + (TransactionPartnerFragment, TransactionPartner.FRAGMENT), + (TransactionPartnerOther, TransactionPartner.OTHER), + (TransactionPartnerUser, TransactionPartner.USER), + (TransactionPartnerTelegramAds, TransactionPartner.TELEGRAM_ADS), + ], + ids=[ + TransactionPartner.FRAGMENT, + TransactionPartner.OTHER, + TransactionPartner.USER, + TransactionPartner.TELEGRAM_ADS, + ], +) +def tp_scope_class_and_type(request): + return request.param + + +@pytest.fixture(scope="module") +def transaction_partner(tp_scope_class_and_type): + # We use de_json here so that we don't have to worry about which class gets which arguments + return tp_scope_class_and_type[0].de_json( + { + "type": tp_scope_class_and_type[1], + "invoice_payload": TestTransactionPartnerBase.invoice_payload, + "withdrawal_state": TestTransactionPartnerBase.withdrawal_state.to_dict(), + "user": TestTransactionPartnerBase.user.to_dict(), + }, + bot=None, + ) + + +@pytest.fixture( + scope="module", + params=[ + RevenueWithdrawalState.FAILED, + RevenueWithdrawalState.SUCCEEDED, + RevenueWithdrawalState.PENDING, + ], +) +def rws_scope_type(request): + return request.param + + +@pytest.fixture( + scope="module", + params=[ + RevenueWithdrawalStateFailed, + RevenueWithdrawalStateSucceeded, + RevenueWithdrawalStatePending, + ], + ids=[ + RevenueWithdrawalState.FAILED, + RevenueWithdrawalState.SUCCEEDED, + RevenueWithdrawalState.PENDING, + ], +) +def rws_scope_class(request): + return request.param + + +@pytest.fixture( + scope="module", + params=[ + (RevenueWithdrawalStateFailed, RevenueWithdrawalState.FAILED), + (RevenueWithdrawalStateSucceeded, RevenueWithdrawalState.SUCCEEDED), + (RevenueWithdrawalStatePending, RevenueWithdrawalState.PENDING), + ], + ids=[ + RevenueWithdrawalState.FAILED, + RevenueWithdrawalState.SUCCEEDED, + RevenueWithdrawalState.PENDING, + ], +) +def rws_scope_class_and_type(request): + return request.param + + +@pytest.fixture(scope="module") +def revenue_withdrawal_state(rws_scope_class_and_type): + # We use de_json here so that we don't have to worry about which class gets which arguments + return rws_scope_class_and_type[0].de_json( + { + "type": rws_scope_class_and_type[1], + "date": to_timestamp(TestRevenueWithdrawalStateBase.date), + "url": TestRevenueWithdrawalStateBase.url, + }, + bot=None, + ) + + +class TestStarTransactionBase: + id = "2" + amount = 2 + date = to_timestamp(datetime.datetime(2024, 1, 1, 0, 0, 0, 0, tzinfo=UTC)) + source = TransactionPartnerUser( + user=User( + id=2, + is_bot=False, + first_name="first_name", + ), + ) + receiver = TransactionPartnerOther() + + +class TestStarTransactionWithoutRequest(TestStarTransactionBase): + def test_slot_behaviour(self): + inst = star_transaction() + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, bot): + json_dict = { + "id": self.id, + "amount": self.amount, + "date": self.date, + "source": self.source.to_dict(), + "receiver": self.receiver.to_dict(), + } + st = StarTransaction.de_json(json_dict, bot) + st_none = StarTransaction.de_json(None, bot) + assert st.api_kwargs == {} + assert st.id == self.id + assert st.amount == self.amount + assert st.date == from_timestamp(self.date) + assert st.source == self.source + assert st.receiver == self.receiver + assert st_none is None + + def test_de_json_star_transaction_localization(self, tz_bot, bot, raw_bot): + json_dict = star_transaction().to_dict() + st_raw = StarTransaction.de_json(json_dict, raw_bot) + st_bot = StarTransaction.de_json(json_dict, bot) + st_tz = StarTransaction.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + st_offset = st_tz.date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset(st_tz.date.replace(tzinfo=None)) + + assert st_raw.date.tzinfo == UTC + assert st_bot.date.tzinfo == UTC + assert st_offset == tz_bot_offset + + def test_to_dict(self): + st = star_transaction() + expected_dict = { + "id": "1", + "amount": 1, + "date": st.date, + "source": st.source.to_dict(), + "receiver": st.receiver.to_dict(), + } + assert st.to_dict() == expected_dict + + def test_equality(self): + a = StarTransaction( + id=self.id, + amount=self.amount, + date=self.date, + source=self.source, + receiver=self.receiver, + ) + b = StarTransaction( + id=self.id, + amount=self.amount, + date=None, + source=self.source, + receiver=self.receiver, + ) + c = StarTransaction( + id="3", + amount=3, + date=to_timestamp(datetime.datetime.utcnow()), + source=TransactionPartnerUser( + user=User( + id=3, + is_bot=False, + first_name="first_name", + ), + ), + receiver=TransactionPartnerOther(), + ) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + +class TestStarTransactionsBase: + transactions = [star_transaction(), star_transaction()] + + +class TestStarTransactionsWithoutRequest(TestStarTransactionsBase): + def test_slot_behaviour(self, star_transactions): + inst = star_transactions + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, bot): + json_dict = { + "transactions": [t.to_dict() for t in self.transactions], + } + st = StarTransactions.de_json(json_dict, bot) + st_none = StarTransactions.de_json(None, bot) + assert st.api_kwargs == {} + assert st.transactions == tuple(self.transactions) + assert st_none is None + + def test_to_dict(self, star_transactions): + expected_dict = { + "transactions": [t.to_dict() for t in self.transactions], + } + assert star_transactions.to_dict() == expected_dict + + def test_equality(self): + a = StarTransactions( + transactions=self.transactions, + ) + b = StarTransactions( + transactions=self.transactions, + ) + c = StarTransactions( + transactions=[star_transaction()], + ) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + +class TestTransactionPartnerBase: + withdrawal_state = withdrawal_state_succeeded() + user = transaction_partner_user().user + invoice_payload = "payload" + + +class TestTransactionPartnerWithoutRequest(TestTransactionPartnerBase): + def test_slot_behaviour(self, transaction_partner): + inst = transaction_partner + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, bot, tp_scope_class_and_type): + cls = tp_scope_class_and_type[0] + type_ = tp_scope_class_and_type[1] + + json_dict = { + "type": type_, + "invoice_payload": self.invoice_payload, + "withdrawal_state": self.withdrawal_state.to_dict(), + "user": self.user.to_dict(), + } + tp = TransactionPartner.de_json(json_dict, bot) + assert set(tp.api_kwargs.keys()) == {"user", "withdrawal_state", "invoice_payload"} - set( + cls.__slots__ + ) + + assert isinstance(tp, TransactionPartner) + assert type(tp) is cls + assert tp.type == type_ + if "withdrawal_state" in cls.__slots__: + assert tp.withdrawal_state == self.withdrawal_state + if "user" in cls.__slots__: + assert tp.user == self.user + assert tp.invoice_payload == self.invoice_payload + + assert cls.de_json(None, bot) is None + assert TransactionPartner.de_json({}, bot) is None + + def test_de_json_invalid_type(self, bot): + json_dict = { + "type": "invalid", + "invoice_payload": self.invoice_payload, + "withdrawal_state": self.withdrawal_state.to_dict(), + "user": self.user.to_dict(), + } + tp = TransactionPartner.de_json(json_dict, bot) + assert tp.api_kwargs == { + "withdrawal_state": self.withdrawal_state.to_dict(), + "user": self.user.to_dict(), + "invoice_payload": self.invoice_payload, + } + + assert type(tp) is TransactionPartner + assert tp.type == "invalid" + + def test_de_json_subclass(self, tp_scope_class, bot): + """This makes sure that e.g. TransactionPartnerUser(data) never returns a + TransactionPartnerFragment instance.""" + json_dict = { + "type": "invalid", + "invoice_payload": self.invoice_payload, + "withdrawal_state": self.withdrawal_state.to_dict(), + "user": self.user.to_dict(), + } + assert type(tp_scope_class.de_json(json_dict, bot)) is tp_scope_class + + def test_to_dict(self, transaction_partner): + tp_dict = transaction_partner.to_dict() + + assert isinstance(tp_dict, dict) + assert tp_dict["type"] == transaction_partner.type + if hasattr(transaction_partner, "user"): + assert tp_dict["user"] == transaction_partner.user.to_dict() + assert tp_dict["invoice_payload"] == transaction_partner.invoice_payload + if hasattr(transaction_partner, "withdrawal_state"): + assert tp_dict["withdrawal_state"] == transaction_partner.withdrawal_state.to_dict() + + def test_type_enum_conversion(self): + assert type(TransactionPartner("other").type) is TransactionPartnerType + assert TransactionPartner("unknown").type == "unknown" + + def test_equality(self, transaction_partner, bot): + a = TransactionPartner("base_type") + b = TransactionPartner("base_type") + c = transaction_partner + d = deepcopy(transaction_partner) + e = Dice(4, "emoji") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + assert a != e + assert hash(a) != hash(e) + + assert c == d + assert hash(c) == hash(d) + + assert c != e + assert hash(c) != hash(e) + + if hasattr(c, "user"): + json_dict = c.to_dict() + json_dict["user"] = User(2, "something", True).to_dict() + f = c.__class__.de_json(json_dict, bot) + + assert c != f + assert hash(c) != hash(f) + + +class TestRevenueWithdrawalStateBase: + date = datetime.datetime(2024, 1, 1, 0, 0, 0, 0, tzinfo=UTC) + url = "url" + + +class TestRevenueWithdrawalStateWithoutRequest(TestRevenueWithdrawalStateBase): + def test_slot_behaviour(self, revenue_withdrawal_state): + inst = revenue_withdrawal_state + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, bot, rws_scope_class_and_type): + cls = rws_scope_class_and_type[0] + type_ = rws_scope_class_and_type[1] + + json_dict = { + "type": type_, + "date": to_timestamp(self.date), + "url": self.url, + } + rws = RevenueWithdrawalState.de_json(json_dict, bot) + assert set(rws.api_kwargs.keys()) == {"date", "url"} - set(cls.__slots__) + + assert isinstance(rws, RevenueWithdrawalState) + assert type(rws) is cls + assert rws.type == type_ + if "date" in cls.__slots__: + assert rws.date == self.date + if "url" in cls.__slots__: + assert rws.url == self.url + + assert cls.de_json(None, bot) is None + assert RevenueWithdrawalState.de_json({}, bot) is None + + def test_de_json_invalid_type(self, bot): + json_dict = { + "type": "invalid", + "date": to_timestamp(self.date), + "url": self.url, + } + rws = RevenueWithdrawalState.de_json(json_dict, bot) + assert rws.api_kwargs == { + "date": to_timestamp(self.date), + "url": self.url, + } + + assert type(rws) is RevenueWithdrawalState + assert rws.type == "invalid" + + def test_de_json_subclass(self, rws_scope_class, bot): + """This makes sure that e.g. RevenueWithdrawalState(data) never returns a + RevenueWithdrawalStateFailed instance.""" + json_dict = { + "type": "invalid", + "date": to_timestamp(self.date), + "url": self.url, + } + assert type(rws_scope_class.de_json(json_dict, bot)) is rws_scope_class + + def test_to_dict(self, revenue_withdrawal_state): + rws_dict = revenue_withdrawal_state.to_dict() + + assert isinstance(rws_dict, dict) + assert rws_dict["type"] == revenue_withdrawal_state.type + if hasattr(revenue_withdrawal_state, "date"): + assert rws_dict["date"] == to_timestamp(revenue_withdrawal_state.date) + if hasattr(revenue_withdrawal_state, "url"): + assert rws_dict["url"] == revenue_withdrawal_state.url + + def test_type_enum_conversion(self): + assert type(RevenueWithdrawalState("failed").type) is RevenueWithdrawalStateType + assert RevenueWithdrawalState("unknown").type == "unknown" + + def test_equality(self, revenue_withdrawal_state, bot): + a = RevenueWithdrawalState("base_type") + b = RevenueWithdrawalState("base_type") + c = revenue_withdrawal_state + d = deepcopy(revenue_withdrawal_state) + e = Dice(4, "emoji") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + assert a != e + assert hash(a) != hash(e) + + assert c == d + assert hash(c) == hash(d) + + assert c != e + assert hash(c) != hash(e) + + if hasattr(c, "url"): + json_dict = c.to_dict() + json_dict["url"] = "something" + f = c.__class__.de_json(json_dict, bot) + + assert c == f + assert hash(c) == hash(f) + + if hasattr(c, "date"): + json_dict = c.to_dict() + json_dict["date"] = to_timestamp(datetime.datetime.utcnow()) + f = c.__class__.de_json(json_dict, bot) + + assert c != f + assert hash(c) != hash(f) diff --git a/tests/test_telegramobject.py b/tests/test_telegramobject.py index 39f3aaff4aa..ca893dec4d8 100644 --- a/tests/test_telegramobject.py +++ b/tests/test_telegramobject.py @@ -90,6 +90,11 @@ def test_de_json_api_kwargs(self, bot): assert to.api_kwargs == {"foo": "bar"} assert to.get_bot() is bot + def test_de_json_optional_bot(self): + to = TelegramObject.de_json(data={}) + with pytest.raises(RuntimeError, match="no bot associated with it"): + to.get_bot() + def test_de_list(self, bot): class SubClass(TelegramObject): def __init__(self, arg: int, **kwargs):